Skip to content

Transport API

SpindleX's low-level transport layer handles packet I/O, key exchange, and channel management.

Core Transport

spindlex.transport.transport

SSH Transport Layer Implementation

Core SSH transport functionality including protocol handshake, key exchange, authentication, and secure packet transmission.

Classes

HandledMessage

Sentinel for messages handled internally by the transport.

Source code in spindlex/transport/transport.py
class HandledMessage:
    """Sentinel for messages handled internally by the transport."""

    msg_type = 0
    _data = b""

PacketProfiler

Optional per-stage packet timing. Activated by setting SPINDLEX_PROFILE=1.

Records build / encrypt / socket-write latencies for every packet sent through _send_message, then reports median and P95 via summary().

Source code in spindlex/transport/transport.py
class PacketProfiler:
    """
    Optional per-stage packet timing. Activated by setting SPINDLEX_PROFILE=1.

    Records build / encrypt / socket-write latencies for every packet sent
    through _send_message, then reports median and P95 via summary().
    """

    __slots__ = ("build", "encrypt", "write")

    def __init__(self) -> None:
        self.build: list[float] = []
        self.encrypt: list[float] = []
        self.write: list[float] = []

    def record(self, build_s: float, encrypt_s: float, write_s: float) -> None:
        self.build.append(build_s)
        self.encrypt.append(encrypt_s)
        self.write.append(write_s)

    def reset(self) -> None:
        self.build.clear()
        self.encrypt.clear()
        self.write.clear()

    def summary(self, pct: int = 95) -> str:
        n = len(self.build)
        if n == 0:
            return "PacketProfiler: no samples"

        def _stats(samples: list[float]) -> tuple[float, float]:
            s = sorted(samples)
            med = statistics.median(s)
            idx = max(0, int(len(s) * pct / 100) - 1)
            return med * 1e6, s[idx] * 1e6

        b_med, b_p = _stats(self.build)
        e_med, e_p = _stats(self.encrypt)
        w_med, w_p = _stats(self.write)
        total_med = b_med + e_med + w_med
        total_p = b_p + e_p + w_p
        return (
            f"PacketProfiler ({n} packets) - median / P{pct} in µs:\n"
            f"  build:   {b_med:7.1f} / {b_p:7.1f}\n"
            f"  encrypt: {e_med:7.1f} / {e_p:7.1f}\n"
            f"  write:   {w_med:7.1f} / {w_p:7.1f}\n"
            f"  total:   {total_med:7.1f} / {total_p:7.1f}"
        )

Transport

SSH transport layer implementation.

Manages SSH protocol handshake, key exchange, authentication, and secure packet transmission according to RFC 4251-4254.

Source code in spindlex/transport/transport.py
 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
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
class Transport:
    """
    SSH transport layer implementation.

    Manages SSH protocol handshake, key exchange, authentication,
    and secure packet transmission according to RFC 4251-4254.
    """

    def __init__(
        self,
        sock: socket.socket,
        rekey_bytes_limit: Optional[int] = None,
        rekey_time_limit: Optional[float] = None,
    ) -> None:
        """
        Initialize transport with socket connection.

        Args:
            sock: Connected socket for SSH communication
            rekey_bytes_limit: Number of bytes before rekeying (default: 1GB)
            rekey_time_limit: Seconds before rekeying (default: 1 hour)
        """
        self._socket = sock
        self._active = False
        self._server_mode = False
        self._channels: dict[int, Channel] = {}
        self._next_channel_id = 0

        # Connection state
        self._authenticated = False
        self._session_id: Optional[bytes] = None
        self._server_version: Optional[str] = None
        self._client_version: Optional[str] = None

        # Crypto state
        self._crypto_backend = default_crypto_backend
        self._encryption_key_c2s: Optional[bytes] = None
        self._encryption_key_s2c: Optional[bytes] = None
        self._mac_key_c2s: Optional[bytes] = None
        self._mac_key_s2c: Optional[bytes] = None
        self._iv_c2s: Optional[bytes] = None
        self._iv_s2c: Optional[bytes] = None
        self._cipher_c2s: Optional[str] = None
        self._cipher_s2c: Optional[str] = None
        self._mac_c2s: Optional[str] = None
        self._mac_s2c: Optional[str] = None

        # Active crypto state (updated only on NEWKEYS)
        self._mac_key_in_active: Optional[bytes] = None
        self._mac_key_out_active: Optional[bytes] = None
        self._mac_in_active: Optional[str] = None
        self._mac_out_active: Optional[str] = None

        # Rekeying policy (configurable)
        self._rekey_bytes_limit = rekey_bytes_limit or (
            1024 * 1024 * 1024
        )  # 1GB default
        self._rekey_time_limit = rekey_time_limit or 3600  # 1 hour default

        # Cipher instances
        self._encryptor_instance: Optional[Any] = None
        self._decryptor_instance: Optional[Any] = None
        self._chacha20_key_out: Optional[bytes] = None
        self._chacha20_key_in: Optional[bytes] = None

        self._sequence_number_in = 0
        self._sequence_number_out = 0
        self._packet_buffer = bytearray()
        # Lock ordering contract - to prevent ABBA deadlock, always acquire in this order:
        #   1. _read_lock  (socket-level serialisation; held while reading a packet)
        #   2. _lock       (state-level serialisation; held while mutating transport state)
        # Never acquire _read_lock while already holding _lock.
        self._lock = threading.RLock()
        self._read_lock = threading.RLock()
        self._kex_condition = threading.Condition(self._lock)
        self._server_host_key_blob: Optional[bytes] = None

        self._kex_in_progress = False
        self._kex = KeyExchange(self)
        self._bytes_since_rekey = 0
        self._last_rekey_time = time.time()

        # Timeouts
        self._connect_timeout: float = float(DEFAULT_CONNECT_TIMEOUT)
        self._auth_timeout: float = float(DEFAULT_AUTH_TIMEOUT)

        # Authentication state
        self._userauth_service_requested = False

        # Server interface for authentication callbacks
        self._server_interface: Optional[Any] = None

        # Port forwarding
        self._port_forwarding_manager: Optional[PortForwardingManager] = None

        # Message dispatching
        self._message_queue: deque[Message] = deque()
        self._timeout = 10.0

        self._logger = logging.getLogger(__name__)
        self._strict_kex = False
        self._stop_event = threading.Event()
        self._kex_thread: Optional[threading.Thread] = None
        self._server_key: Optional[Any] = None

        self._buffer_size = 32768
        env_buffer_size = os.environ.get("SPINDLEX_BUFFER_SIZE")
        if env_buffer_size:
            try:
                self._buffer_size = max(4096, int(env_buffer_size))
            except (ValueError, TypeError):
                pass

        self._profiler: Optional[PacketProfiler] = (
            PacketProfiler() if os.environ.get("SPINDLEX_PROFILE") == "1" else None
        )

    def get_timeout(self) -> Optional[float]:
        """
        Get transport timeout.

        Returns:
            Current timeout in seconds, or None if no timeout
        """
        return self._socket.gettimeout()

    def set_timeout(self, timeout: float) -> None:
        """Set default timeout for transport operations."""
        self._timeout = timeout
        if self._socket:
            self._socket.settimeout(timeout)

    def set_rekey_policy(
        self, bytes_limit: Optional[int] = None, time_limit: Optional[float] = None
    ) -> None:
        """
        Configure rekeying thresholds.

        Args:
            bytes_limit: Number of bytes before rekeying (default: 1GB)
            time_limit: Seconds before rekeying (default: 1 hour)
        """
        if bytes_limit is not None:
            self._rekey_bytes_limit = bytes_limit
        if time_limit is not None:
            self._rekey_time_limit = time_limit

    def start_client(self, timeout: Optional[float] = None) -> None:
        """
        Start SSH client transport.

        Args:
            timeout: Handshake timeout in seconds

        Raises:
            TransportException: If client start fails
        """
        if timeout is not None:
            self._connect_timeout = timeout

        try:
            with self._read_lock:
                with self._lock:
                    if self._active:
                        raise TransportException("Transport already active")

                    self._server_mode = False
                    self._client_version = create_version_string()

                    # Set socket timeout for handshake
                    old_timeout = self._socket.gettimeout()
                    self._socket.settimeout(self._connect_timeout)

                    try:
                        # Perform SSH handshake
                        self._logger.debug("Starting handshake...")
                        self._do_handshake()
                        self._logger.debug("Handshake complete.")

                    finally:
                        # Restore original socket timeout
                        try:
                            if self._socket and self._socket.fileno() != -1:
                                self._socket.settimeout(old_timeout)
                        except (OSError, AttributeError):
                            pass

                # Start key exchange (WITHOUT holding _lock)
                self._logger.debug("Starting KEX...")
                self._start_kex()
                self._logger.debug("KEX complete.")

                with self._lock:
                    self._active = True

        except (OSError, struct.error, SSHException) as e:
            self.close()
            if isinstance(e, SSHException):
                raise
            raise TransportException(f"Client start failed: {e}") from e

    def start_server(self, server_key: Any, timeout: Optional[float] = None) -> None:
        """
        Start SSH server transport.

        Args:
            server_key: Server's private key
            timeout: Handshake timeout in seconds

        Raises:
            TransportException: If server start fails
        """
        if timeout is not None:
            self._connect_timeout = timeout

        try:
            with self._read_lock:
                with self._lock:
                    if self._active:
                        raise TransportException("Transport already active")

                    self._server_mode = True
                    self._server_key = server_key
                    self._server_version = create_version_string()

                    # Set socket timeout for handshake
                    old_timeout = self._socket.gettimeout()
                    self._socket.settimeout(self._connect_timeout)

                    try:
                        # Perform SSH handshake
                        self._logger.debug("Starting handshake...")
                        self._do_handshake()
                        self._logger.debug("Handshake complete.")

                    finally:
                        # Restore original socket timeout
                        try:
                            if self._socket and self._socket.fileno() != -1:
                                self._socket.settimeout(old_timeout)
                        except (OSError, AttributeError):
                            pass

                # Start key exchange (WITHOUT holding _lock)
                self._logger.debug("Starting KEX...")
                self._start_kex()
                self._logger.debug("KEX complete.")

                with self._lock:
                    self._active = True

        except (OSError, struct.error, SSHException) as e:
            self.close()
            if isinstance(e, SSHException):
                raise
            raise TransportException(f"Server start failed: {e}") from e

    def auth_password(self, username: str, password: str) -> bool:
        """
        Authenticate using password.

        Args:
            username: Username for authentication
            password: Password for authentication

        Returns:
            True if authentication successful

        Raises:
            AuthenticationException: If authentication fails
        """
        if not self._active:
            raise AuthenticationException("Transport not active")

        if self._authenticated:
            return True

        # Request ssh-userauth service if not already done
        if not self._userauth_service_requested:
            self._request_userauth_service()

        from ..auth.password import PasswordAuth

        auth = PasswordAuth(self)
        msg = auth.authenticate(username, password)
        return self._handle_auth_response_message(msg)

    def auth_publickey(self, username: str, key: Any) -> bool:
        """
        Authenticate using public key.

        Args:
            username: Username for authentication
            key: Private key for authentication

        Returns:
            True if authentication successful

        Raises:
            AuthenticationException: If authentication fails
        """
        if not self._active:
            raise AuthenticationException("Transport not active")

        if self._authenticated:
            return True

        # Request ssh-userauth service if not already done
        if not self._userauth_service_requested:
            self._request_userauth_service()

        from ..auth.publickey import PublicKeyAuth

        auth = PublicKeyAuth(self)
        msg = auth.authenticate(username, key)
        return self._handle_auth_response_message(msg)

    def auth_keyboard_interactive(self, username: str, handler: Any) -> bool:
        """
        Authenticate using keyboard-interactive method.

        Args:
            username: Username for authentication
            handler: Callback function to handle prompts

        Returns:
            True if authentication successful

        Raises:
            AuthenticationException: If authentication fails
        """
        if not self._active:
            raise AuthenticationException("Transport not active")

        if self._authenticated:
            return True

        try:
            # Request ssh-userauth service if not already done
            if not self._userauth_service_requested:
                self._request_userauth_service()

            from ..auth.keyboard_interactive import KeyboardInteractiveAuth

            # Send initial keyboard-interactive request
            auth_request = UserAuthRequestMessage(
                username=username,
                service=SERVICE_CONNECTION,
                method=AUTH_KEYBOARD_INTERACTIVE,
                method_data=self._build_keyboard_interactive_data(),
            )

            self._send_message(auth_request)

            # Perform interactive authentication
            ki_auth = KeyboardInteractiveAuth(self)
            result = ki_auth.authenticate(username, handler)

            if result:
                self._authenticated = True

            return result

        except (OSError, struct.error, SSHException) as e:
            if isinstance(e, AuthenticationException):
                raise
            raise AuthenticationException(
                f"Keyboard-interactive authentication failed: {e}"
            )

    def _build_keyboard_interactive_data(self) -> bytes:
        """Build keyboard-interactive authentication method data."""
        data = bytearray()
        data.extend(write_string(""))  # language tag
        data.extend(write_string(""))  # submethods
        return bytes(data)

    def auth_gssapi(
        self,
        username: str,
        gss_host: Optional[str] = None,
        gss_deleg_creds: bool = False,
    ) -> bool:
        """
        Authenticate using GSSAPI method.

        Args:
            username: Username for authentication
            gss_host: GSSAPI hostname (optional)
            gss_deleg_creds: Whether to delegate credentials

        Returns:
            True if authentication successful

        Raises:
            AuthenticationException: If authentication fails
        """
        if not self._active:
            raise AuthenticationException("Transport not active")

        if self._authenticated:
            return True

        try:
            from ..auth.gssapi import GSSAPIAuth

            # Create GSSAPI authenticator
            gssapi_auth = GSSAPIAuth(self)

            # Perform GSSAPI authentication
            result = gssapi_auth.authenticate(username, gss_host, gss_deleg_creds)

            # Clean up GSSAPI resources
            gssapi_auth.cleanup()

            return result

        except ImportError:
            raise AuthenticationException("GSSAPI authentication not available")
        except (OSError, struct.error, SSHException) as e:
            if isinstance(e, AuthenticationException):
                raise
            raise AuthenticationException(f"GSSAPI authentication failed: {e}") from e

    def _request_userauth_service(self) -> None:
        """Request ssh-userauth service."""
        if self._userauth_service_requested:
            return

        service_request = ServiceRequestMessage(SERVICE_USERAUTH)
        self._send_message(service_request)

        # Wait for service accept
        msg = self._expect_message(MSG_SERVICE_ACCEPT)
        if not isinstance(msg, ServiceAcceptMessage):
            raise AuthenticationException(
                f"Expected SERVICE_ACCEPT, got {type(msg).__name__}"
            )

        if msg.service_name != SERVICE_USERAUTH:
            raise AuthenticationException(f"Service not accepted: {msg.service_name}")

        self._userauth_service_requested = True

    def _handle_auth_response_message(self, msg: Message) -> bool:
        """
        Handle a specific authentication response message.

        Args:
            msg: MSG_USERAUTH_SUCCESS or MSG_USERAUTH_FAILURE

        Returns:
            True if success

        Raises:
            AuthenticationException: If failure or unexpected message
        """
        if isinstance(msg, UserAuthSuccessMessage):
            self._authenticated = True
            return True
        elif isinstance(msg, UserAuthFailureMessage):
            # Check if partial success
            if msg.partial_success:
                raise AuthenticationException(
                    f"Partial success - additional methods required: {', '.join(msg.authentications)}"
                )
            else:
                return False
        elif msg.msg_type == MSG_USERAUTH_SUCCESS:
            self._authenticated = True
            return True
        elif msg.msg_type == MSG_USERAUTH_FAILURE:
            # Re-unpack as UserAuthFailureMessage to check partial_success
            auth_msg: UserAuthFailureMessage
            if not isinstance(msg, UserAuthFailureMessage):
                auth_msg = UserAuthFailureMessage.unpack(msg.pack())  # type: ignore[assignment]
            else:
                auth_msg = msg

            if auth_msg.partial_success:
                raise AuthenticationException(
                    f"Partial success - additional methods required: {', '.join(auth_msg.authentications)}"
                )
            return False
        else:
            raise AuthenticationException(
                f"Unexpected authentication response: {type(msg).__name__} (type {msg.msg_type})"
            )

    def _handle_auth_response(self) -> bool:
        """Receive and handle authentication response message."""
        msg = self._expect_message(MSG_USERAUTH_SUCCESS, MSG_USERAUTH_FAILURE)
        return self._handle_auth_response_message(msg)

    def open_channel(
        self, kind: str, dest_addr: Optional[tuple[str, int]] = None
    ) -> Channel:
        """
        Open new SSH channel.

        Args:
            kind: Channel type (session, direct-tcpip, etc.)
            dest_addr: Destination address for forwarding channels

        Returns:
            New Channel instance

        Raises:
            TransportException: If channel creation fails
        """
        if not self._active:
            raise TransportException("Transport not active")

        if not self._authenticated:
            raise TransportException("Transport not authenticated")

        with self._lock:
            # Check channel limit
            if len(self._channels) >= MAX_CHANNELS:
                raise TransportException("Maximum number of channels reached")

            # Find next available channel ID (recycling IDs)
            channel_id = self._next_channel_id
            while channel_id in self._channels:
                channel_id = (channel_id + 1) % MAX_CHANNELS

            self._next_channel_id = (channel_id + 1) % MAX_CHANNELS

            # Create channel instance
            channel = Channel(self, channel_id)

            # Register channel BEFORE sending open request to avoid race
            self._channels[channel_id] = channel

        try:
            # Build channel open message
            type_specific_data = b""
            if kind == CHANNEL_DIRECT_TCPIP and dest_addr:
                type_specific_data = self._build_direct_tcpip_data(dest_addr)

            open_msg = ChannelOpenMessage(
                channel_type=kind,
                sender_channel=channel_id,
                initial_window_size=DEFAULT_WINDOW_SIZE,
                maximum_packet_size=DEFAULT_MAX_PACKET_SIZE,
                type_specific_data=type_specific_data,
            )

            # Send channel open request
            self._send_message(open_msg)

            # Wait for response (CRITICAL: release lock before calling _expect_message)
            response = self._expect_message(
                MSG_CHANNEL_OPEN_CONFIRMATION,
                MSG_CHANNEL_OPEN_FAILURE,
                channel_id=channel_id,
            )

            if isinstance(response, ChannelOpenConfirmationMessage):
                # Channel opened successfully
                with self._lock:
                    channel._remote_channel_id = response.sender_channel
                    channel._remote_window_size = response.initial_window_size
                    channel._remote_max_packet_size = response.maximum_packet_size
                    channel._local_window_size = DEFAULT_WINDOW_SIZE
                    channel._local_max_packet_size = DEFAULT_MAX_PACKET_SIZE

                return channel

            elif isinstance(response, ChannelOpenFailureMessage):
                # Channel open failed - remove from channels
                with self._lock:
                    if channel_id in self._channels:
                        del self._channels[channel_id]
                raise TransportException(
                    f"Channel open failed: {response.description} (code: {response.reason_code})"
                )

            else:
                # Unexpected response - remove from channels
                with self._lock:
                    if channel_id in self._channels:
                        del self._channels[channel_id]
                raise TransportException(
                    f"Unexpected response to channel open: {type(response).__name__}"
                )

        except (OSError, struct.error, SSHException) as e:
            # Cleanup on error
            with self._lock:
                if channel_id in self._channels:
                    del self._channels[channel_id]
            if isinstance(e, SSHException):
                raise
            raise TransportException(f"Failed to open channel: {e}") from e

    def _build_direct_tcpip_data(self, dest_addr: tuple[str, int]) -> bytes:
        """Build type-specific data for direct-tcpip channel."""
        try:
            sockname = self._socket.getsockname()
            originator_ip = sockname[0]
            originator_port = sockname[1]
        except OSError:
            originator_ip = "127.0.0.1"
            originator_port = 0
        data = bytearray()
        data.extend(write_string(dest_addr[0]))  # destination host
        data.extend(write_uint32(dest_addr[1]))  # destination port
        data.extend(write_string(originator_ip))  # originator IP
        data.extend(write_uint32(originator_port))  # originator port (RFC 4254 §7.2)
        return bytes(data)

    def _close_channel(self, channel_id: int) -> None:
        """
        Close channel and remove from channels dict.

        Args:
            channel_id: Channel ID to close
        """
        with self._lock:
            if channel_id in self._channels:
                channel = self._channels[channel_id]

                # Send channel close message if not already closed
                if not channel.closed and channel._remote_channel_id is not None:
                    try:
                        close_msg = ChannelCloseMessage(channel._remote_channel_id)
                        self._send_message(close_msg)
                    except (OSError, TransportException):
                        pass  # Ignore errors during close

                # Remove from channels dict
                del self._channels[channel_id]

    def _handle_channel_message(self, msg: Message) -> None:
        """
        Handle channel-related messages.

        Args:
            msg: Channel message to handle
        """
        # Handle messages that don't have a recipient_channel field first
        if msg.msg_type == MSG_CHANNEL_OPEN:
            self._handle_channel_open(msg)
            return

        if msg.msg_type == MSG_GLOBAL_REQUEST:
            self._handle_global_request(msg)
            return

        # Other channel messages (91-100) have recipient_channel as first uint32
        recipient_channel = None
        if len(msg._data) >= 4:
            recipient_channel, _ = read_uint32(msg._data, 0)

        if recipient_channel is not None:
            channel = None
            with self._lock:
                channel = self._channels.get(recipient_channel)

            if channel:
                try:
                    if msg.msg_type == MSG_CHANNEL_DATA:
                        self._handle_channel_data(msg)
                    elif msg.msg_type == MSG_CHANNEL_EXTENDED_DATA:
                        self._handle_channel_extended_data(msg)
                    elif msg.msg_type == MSG_CHANNEL_EOF:
                        self._handle_channel_eof(msg)
                    elif msg.msg_type == MSG_CHANNEL_CLOSE:
                        self._handle_channel_close(msg)
                    elif msg.msg_type == MSG_CHANNEL_WINDOW_ADJUST:
                        self._handle_channel_window_adjust(msg)
                    elif msg.msg_type == MSG_CHANNEL_SUCCESS:
                        self._handle_channel_success(msg)
                    elif msg.msg_type == MSG_CHANNEL_FAILURE:
                        self._handle_channel_failure(msg)
                    elif msg.msg_type == MSG_CHANNEL_REQUEST:
                        self._handle_channel_request(msg)
                    else:
                        self._logger.debug(
                            f"Unhandled channel message type {msg.msg_type} for channel {recipient_channel}"
                        )
                except Exception as e:
                    self._logger.error(
                        f"Error handling channel message {msg.msg_type}: {e}"
                    )
            else:
                self._logger.debug(
                    f"Message type {msg.msg_type} for unknown channel {recipient_channel}"
                )

    def _handle_channel_open(self, msg: Message) -> None:
        """
        Handle incoming channel open request.

        Args:
            msg: Channel open message
        """
        # Parse the remote sender_channel up-front so we always have a valid
        # id to reference in a failure response, even if later fields are
        # malformed. If even this fails we cannot reply safely - the peer
        # will time out, which is the expected behaviour for a garbage frame.
        sender_channel: Optional[int] = None
        try:
            if not isinstance(msg, ChannelOpenMessage):
                msg = ChannelOpenMessage.unpack(msg.pack())

            channel_type = msg.channel_type  # type: ignore[attr-defined]
            sender_channel = msg.sender_channel  # type: ignore[attr-defined]
            initial_window_size = msg.initial_window_size  # type: ignore[attr-defined]
            maximum_packet_size = msg.maximum_packet_size  # type: ignore[attr-defined]
            type_specific_data = msg.type_specific_data  # type: ignore[attr-defined]

            if channel_type == CHANNEL_SESSION:
                self._handle_session_open(
                    sender_channel, initial_window_size, maximum_packet_size
                )
            elif channel_type == CHANNEL_FORWARDED_TCPIP:
                self._handle_forwarded_tcpip_open(
                    sender_channel,
                    initial_window_size,
                    maximum_packet_size,
                    type_specific_data,
                )
            else:
                # Unknown channel type - send failure
                failure_msg = ChannelOpenFailureMessage(
                    recipient_channel=sender_channel,
                    reason_code=SSH_OPEN_UNKNOWN_CHANNEL_TYPE,
                    description=f"Unknown channel type: {channel_type}",
                    language="",
                )
                self._send_message(failure_msg)

        except (
            OSError,
            struct.error,
            ValueError,
            UnicodeDecodeError,
            SSHException,
        ) as e:
            # Do not leak internal exception details to the remote peer.
            self._logger.error("Channel open failed: %s", e)
            if sender_channel is None:
                # No valid sender id parsed - can't send a targeted failure.
                return
            try:
                failure_msg = ChannelOpenFailureMessage(
                    recipient_channel=sender_channel,
                    reason_code=SSH_OPEN_CONNECT_FAILED,
                    description="Channel open failed",
                    language="",
                )
                self._send_message(failure_msg)
            except (TransportException, OSError) as send_err:
                self._logger.debug(
                    "Could not send channel open failure response: %s", send_err
                )

    def _handle_session_open(
        self, sender_channel: int, initial_window_size: int, maximum_packet_size: int
    ) -> None:
        """Handle session channel open request."""
        # 1. Check with server interface
        if self._server_interface:
            result = self._server_interface.check_channel_request(
                CHANNEL_SESSION, sender_channel
            )
            if result != 0:
                failure_msg = ChannelOpenFailureMessage(
                    sender_channel, result, "Administratively prohibited", ""
                )
                self._send_message(failure_msg)
                return

        # 2. Create channel
        with self._lock:
            if len(self._channels) >= MAX_CHANNELS:
                failure_msg = ChannelOpenFailureMessage(
                    sender_channel,
                    SSH_OPEN_RESOURCE_SHORTAGE,
                    "Too many open channels",
                    "",
                )
                self._send_message(failure_msg)
                return

            channel_id = self._next_channel_id
            self._next_channel_id = (self._next_channel_id + 1) % MAX_CHANNELS

            channel = Channel(self, channel_id)
            channel._remote_channel_id = sender_channel
            channel._remote_window_size = initial_window_size
            channel._remote_max_packet_size = maximum_packet_size
            channel._local_window_size = DEFAULT_WINDOW_SIZE
            channel._local_max_packet_size = MAX_PACKET_SIZE
            self._channels[channel_id] = channel

        # 3. Send confirmation
        confirm_msg = ChannelOpenConfirmationMessage(
            recipient_channel=sender_channel,
            sender_channel=channel_id,
            initial_window_size=channel._local_window_size,
            maximum_packet_size=channel._local_max_packet_size,
        )
        self._send_message(confirm_msg)

        # 4. Notify server interface
        if self._server_interface:
            self._server_interface.on_channel_opened(channel)

    def _handle_forwarded_tcpip_open(
        self,
        sender_channel: int,
        initial_window_size: int,
        maximum_packet_size: int,
        type_specific_data: bytes,
    ) -> None:
        """
        Handle forwarded-tcpip channel open.

        Args:
            sender_channel: Remote channel ID
            initial_window_size: Initial window size
            maximum_packet_size: Maximum packet size
            type_specific_data: Channel type specific data
        """
        try:
            # Parse forwarded-tcpip data
            offset = 0
            connected_address_bytes, offset = read_string(type_specific_data, offset)
            connected_port, offset = read_uint32(type_specific_data, offset)
            originator_address_bytes, offset = read_string(type_specific_data, offset)
            originator_port, offset = read_uint32(type_specific_data, offset)

            connected_address = connected_address_bytes.decode(SSH_STRING_ENCODING)
            originator_address = originator_address_bytes.decode(SSH_STRING_ENCODING)

            # Create local channel
            with self._lock:
                if len(self._channels) >= MAX_CHANNELS:
                    failure_msg = ChannelOpenFailureMessage(
                        sender_channel,
                        SSH_OPEN_RESOURCE_SHORTAGE,
                        "Too many open channels",
                        "",
                    )
                    self._send_message(failure_msg)
                    return

                channel_id = self._next_channel_id
                self._next_channel_id = (self._next_channel_id + 1) % MAX_CHANNELS

                channel = Channel(self, channel_id)
                channel._remote_channel_id = sender_channel
                channel._remote_window_size = initial_window_size
                channel._remote_max_packet_size = maximum_packet_size
                channel._local_window_size = DEFAULT_WINDOW_SIZE
                channel._local_max_packet_size = DEFAULT_MAX_PACKET_SIZE

                self._channels[channel_id] = channel

            # Send confirmation
            confirm_msg = ChannelOpenConfirmationMessage(
                recipient_channel=sender_channel,
                sender_channel=channel_id,
                initial_window_size=DEFAULT_WINDOW_SIZE,
                maximum_packet_size=DEFAULT_MAX_PACKET_SIZE,
                type_specific_data=b"",
            )
            self._send_message(confirm_msg)

            # Handle the forwarded connection
            if self._port_forwarding_manager:
                origin_addr = (originator_address, originator_port)
                dest_addr = (connected_address, connected_port)
                self._port_forwarding_manager.handle_forwarded_connection(
                    channel, origin_addr, dest_addr
                )

        except (
            OSError,
            struct.error,
            ValueError,
            UnicodeDecodeError,
            SSHException,
        ) as e:
            # Send failure response
            failure_msg = ChannelOpenFailureMessage(
                recipient_channel=sender_channel,
                reason_code=SSH_OPEN_CONNECT_FAILED,
                description=f"Forwarded connection failed: {e}",
                language="",
            )
            self._send_message(failure_msg)

    def _handle_channel_data(self, msg: Message) -> None:
        """Handle channel data message."""
        if isinstance(msg, ChannelDataMessage):
            channel = None
            with self._lock:
                channel = self._channels.get(msg.recipient_channel)

            if channel:
                channel._handle_data(msg.data)
            else:
                self._logger.debug(f"Data for UNKNOWN channel {msg.recipient_channel}")

    def _handle_channel_extended_data(self, msg: Message) -> None:
        """Handle channel extended data message."""
        # Parse extended data message
        data = msg._data
        offset = 0
        recipient_channel, offset = read_uint32(data, offset)
        data_type, offset = read_uint32(data, offset)
        message_data, offset = read_string(data, offset)

        channel = None
        with self._lock:
            channel = self._channels.get(recipient_channel)

        if channel:
            channel._handle_extended_data(data_type, message_data)

    def _handle_channel_eof(self, msg: Message) -> None:
        """Handle channel EOF message."""
        recipient_channel, _ = read_uint32(msg._data, 0)

        channel = None
        with self._lock:
            channel = self._channels.get(recipient_channel)

        if channel:
            channel._handle_eof()

    def _handle_channel_close(self, msg: Message) -> None:
        """Handle channel close message."""
        if isinstance(msg, ChannelCloseMessage):
            channel = None
            with self._lock:
                channel = self._channels.get(msg.recipient_channel)

            if channel:
                channel._handle_close()
                # Remove from channels dict
                with self._lock:
                    if msg.recipient_channel in self._channels:
                        del self._channels[msg.recipient_channel]

    def _handle_channel_window_adjust(self, msg: Message) -> None:
        """Handle channel window adjust message."""
        data = msg._data
        offset = 0
        recipient_channel, offset = read_uint32(data, offset)
        bytes_to_add, offset = read_uint32(data, offset)

        channel = None
        with self._lock:
            channel = self._channels.get(recipient_channel)

        if channel:
            channel._handle_window_adjust(bytes_to_add)

    def _handle_channel_success(self, msg: Message) -> None:
        """Handle channel success message."""
        recipient_channel, _ = read_uint32(msg._data, 0)

        channel = None
        with self._lock:
            channel = self._channels.get(recipient_channel)

        if channel:
            channel._handle_request_success()

    def _handle_channel_failure(self, msg: Message) -> None:
        """Handle channel failure message."""
        recipient_channel, _ = read_uint32(msg._data, 0)

        channel = None
        with self._lock:
            channel = self._channels.get(recipient_channel)

        if channel:
            channel._handle_request_failure()

    def _handle_channel_request(self, msg: Message) -> None:
        """Handle channel request message."""
        # Parse channel request message
        data = msg._data
        offset = 0
        recipient_channel, offset = read_uint32(data, offset)
        request_type_bytes, offset = read_string(data, offset)
        want_reply, offset = read_boolean(data, offset)

        request_type = request_type_bytes.decode(SSH_STRING_ENCODING)
        request_data = bytes(data[offset:]) if offset < len(data) else b""

        channel = None
        with self._lock:
            channel = self._channels.get(recipient_channel)

        if channel:
            # Handle channel request
            success = channel._handle_request(request_type, request_data)

            # Send reply if requested
            if want_reply:
                if success:
                    reply_msg = Message(MSG_CHANNEL_SUCCESS)
                else:
                    reply_msg = Message(MSG_CHANNEL_FAILURE)

                if channel._remote_channel_id is not None:
                    reply_msg.add_uint32(channel._remote_channel_id)
                    self._send_message(reply_msg)

    def _handle_exit_signal_request(self, channel: Channel, data: bytes) -> None:
        """Handle exit signal request data."""
        try:
            offset = 0
            signal_name_bytes, offset = read_string(data, offset)
            core_dumped, offset = read_boolean(data, offset)
            error_message_bytes, offset = read_string(data, offset)
            language_tag_bytes, offset = read_string(data, offset)

            signal_name = signal_name_bytes.decode(SSH_STRING_ENCODING)
            error_message = error_message_bytes.decode(
                SSH_STRING_ENCODING, errors="replace"
            )
            language_tag = language_tag_bytes.decode(
                SSH_STRING_ENCODING, errors="replace"
            )

            channel._handle_exit_signal(
                signal_name, core_dumped, error_message, language_tag
            )
        except (struct.error, ValueError, UnicodeDecodeError, IndexError):
            # Ignore malformed exit signal data
            pass

    def _send_channel_data(self, channel_id: int, data: bytes) -> None:
        """
        Send data through channel.

        Args:
            channel_id: Local channel ID
            data: Data to send
        """
        with self._lock:
            if channel_id not in self._channels:
                raise TransportException(f"Channel {channel_id} not found")

            channel = self._channels[channel_id]

            # Defensive check only - the channel layer owns _remote_window_size
            # and is the single decrement point (mirrors the async path in
            # AsyncChannel.send). Transport must not decrement here, otherwise
            # the window would be debited twice per send.
            if len(data) > channel._remote_window_size:
                raise TransportException("Remote window size exceeded")

            if len(data) > channel._remote_max_packet_size:
                raise TransportException("Remote max packet size exceeded")

            if channel._remote_channel_id is not None:
                data_msg = ChannelDataMessage(channel._remote_channel_id, data)
                self._send_message(data_msg)

    def _send_channel_window_adjust(self, channel_id: int, bytes_to_add: int) -> None:
        """
        Send channel window adjust message.

        Args:
            channel_id: Local channel ID
            bytes_to_add: Number of bytes to add to window
        """
        with self._lock:
            if channel_id not in self._channels:
                return

            channel = self._channels[channel_id]

            # Build window adjust message
            if channel._remote_channel_id is not None:
                msg = Message(MSG_CHANNEL_WINDOW_ADJUST)
                msg.add_uint32(channel._remote_channel_id)
                msg.add_uint32(bytes_to_add)

                self._send_message(msg)

            # Update local window size
            channel._local_window_size += bytes_to_add

    def _send_channel_request(
        self, channel_id: int, request_type: str, want_reply: bool, data: bytes
    ) -> None:
        """
        Send channel request message.

        Args:
            channel_id: Local channel ID
            request_type: Type of request
            want_reply: Whether reply is wanted
            data: Request-specific data
        """
        with self._lock:
            if channel_id not in self._channels:
                raise TransportException(f"Channel {channel_id} not found")

            channel = self._channels[channel_id]

            # Build channel request message
            if channel._remote_channel_id is not None:
                msg = Message(MSG_CHANNEL_REQUEST)
                msg.add_uint32(channel._remote_channel_id)
                msg.add_string(request_type)
                msg.add_boolean(want_reply)
                if data:
                    msg._data.extend(data)

                self._send_message(msg)

    def _send_channel_eof(self, channel_id: int) -> None:
        """
        Send channel EOF message.

        Args:
            channel_id: Local channel ID
        """
        with self._lock:
            if channel_id not in self._channels:
                return

            channel = self._channels[channel_id]

            # Build EOF message
            if channel._remote_channel_id is not None:
                msg = Message(MSG_CHANNEL_EOF)
                msg.add_uint32(channel._remote_channel_id)

                self._send_message(msg)

    def _send_global_request(
        self, request_name: str, want_reply: bool, data: bytes = b""
    ) -> bool:
        """
        Send global request message.

        Args:
            request_name: Name of the global request
            want_reply: Whether to wait for reply
            data: Request-specific data

        Returns:
            True if request succeeded (when want_reply=True)

        Raises:
            TransportException: If request fails
        """
        if not self._active:
            raise TransportException("Transport not active")

        try:
            # Build global request message
            msg = Message(MSG_GLOBAL_REQUEST)
            msg.add_string(request_name)
            msg.add_boolean(want_reply)
            if data:
                msg._data.extend(data)

            # Send request
            self._send_message(msg)

            if want_reply:
                # Wait for response
                response = self._expect_message(
                    MSG_REQUEST_SUCCESS, MSG_REQUEST_FAILURE
                )

                if response.msg_type == MSG_REQUEST_SUCCESS:
                    return True
                elif response.msg_type == MSG_REQUEST_FAILURE:
                    return False
                else:
                    raise TransportException(
                        f"Unexpected response to global request: {type(response).__name__}"
                    )

            return True  # No reply requested

        except (OSError, struct.error, SSHException) as e:
            if isinstance(e, SSHException):
                raise
            raise TransportException(f"Failed to send global request: {e}") from e

    def _handle_global_request(self, msg: Message) -> None:
        """
        Handle incoming global request.

        Args:
            msg: Global request message
        """
        try:
            # Parse global request message
            data = msg._data
            offset = 0

            request_name_bytes, offset = read_string(data, offset)
            want_reply, offset = read_boolean(data, offset)
            request_data = bytes(data[offset:]) if offset < len(data) else b""

            request_name = request_name_bytes.decode(SSH_STRING_ENCODING)

            # Handle specific request types
            success = False

            if request_name == "tcpip-forward":
                success = self._handle_tcpip_forward_request(request_data)
            elif request_name == "cancel-tcpip-forward":
                success = self._handle_cancel_tcpip_forward_request(request_data)
            else:
                # Unknown request type
                success = False

            # Send reply if requested
            if want_reply:
                if success:
                    reply_msg = Message(MSG_REQUEST_SUCCESS)
                else:
                    reply_msg = Message(MSG_REQUEST_FAILURE)

                self._send_message(reply_msg)

        except (OSError, struct.error, ValueError, UnicodeDecodeError, SSHException):
            # Send failure reply if requested
            if want_reply:
                try:
                    reply_msg = Message(MSG_REQUEST_FAILURE)
                    self._send_message(reply_msg)
                except (OSError, TransportException):
                    pass

    def _handle_tcpip_forward_request(self, data: bytes) -> bool:
        """
        Handle tcpip-forward global request.

        Args:
            data: Request data

        Returns:
            True if request should be accepted
        """
        try:
            offset = 0
            bind_address_bytes, offset = read_string(data, offset)
            bind_port, offset = read_uint32(data, offset)

            bind_address = bind_address_bytes.decode(SSH_STRING_ENCODING)

            # Delegate to server interface for validation
            if self._server_mode and self._server_interface:
                return bool(
                    self._server_interface.check_port_forward_request(
                        bind_address, bind_port
                    )
                )

            # Default to reject if no server interface
            return False

        except (struct.error, ValueError, UnicodeDecodeError, IndexError):
            return False

    def _handle_cancel_tcpip_forward_request(self, data: bytes) -> bool:
        """
        Handle cancel-tcpip-forward global request.

        Args:
            data: Request data

        Returns:
            True if request should be accepted
        """
        try:
            offset = 0
            bind_address_bytes, offset = read_string(data, offset)
            bind_port, offset = read_uint32(data, offset)

            bind_address = bind_address_bytes.decode(SSH_STRING_ENCODING)

            # Delegate to server interface for validation
            if self._server_mode and self._server_interface:
                return bool(
                    self._server_interface.check_port_forward_cancel_request(
                        bind_address, bind_port
                    )
                )

            # Default to reject if no server interface
            return False

        except (struct.error, ValueError, UnicodeDecodeError, IndexError):
            return False

    def __enter__(self) -> "Transport":
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        self.close()

    def close(self) -> None:
        """Close transport and cleanup resources."""
        kex_thread: Optional[threading.Thread] = None
        # Snapshot the channel list under the transport lock, then release it
        # before calling Channel.close(). Channel.close() takes the channel
        # lock and then re-enters _close_channel which takes the transport
        # lock - holding the transport lock here while calling channel.close()
        # would invert the order taken by any concurrent caller of
        # Channel.close() and deadlock the two threads.
        with self._lock:
            self._active = False
            self._stop_event.set()
            kex_thread = self._kex_thread
            channels_snapshot = list(self._channels.values())
            sock = self._socket

        for channel in channels_snapshot:
            try:
                channel.close()
            except (OSError, SSHException):
                pass

        with self._lock:
            self._channels.clear()

        if sock is not None:
            try:
                sock.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass
            try:
                sock.close()
            except OSError:
                pass

        # Join kex thread outside lock to avoid deadlock.
        # Socket closure above ensures the thread unblocks promptly.
        if (
            kex_thread is not None
            and kex_thread.is_alive()
            and kex_thread != threading.current_thread()
        ):
            kex_thread.join(timeout=5.0)

    def _do_handshake(self) -> None:
        """
        Perform SSH protocol handshake and version negotiation.

        Raises:
            TransportException: If handshake fails
            ProtocolException: If protocol error occurs
        """
        try:
            # RFC 4253, Section 4.2: Both sides MUST send an identification string.
            # It is recommended to send it before receiving the peer's string to avoid deadlock.
            self._send_version()
            self._recv_version()

        except (OSError, struct.error, SSHException) as e:
            if isinstance(e, SSHException):
                raise
            raise TransportException(f"Error during version exchange: {e}") from e

    def _send_version(self) -> None:
        """Send SSH version string."""
        version_string = create_version_string()
        if self._server_mode:
            self._server_version = version_string
        else:
            self._client_version = version_string

        version_line = version_string + "\r\n"
        self._socket.sendall(version_line.encode(SSH_STRING_ENCODING))

    def _recv_version(self) -> None:
        """Receive and validate SSH version string."""
        version_line = b""
        _MAX_BANNER_LINES = 20

        # RFC 4253: The server MAY send other lines of data before
        # sending the version string. ... The identification string
        # MUST start with 'SSH-'.
        banner_lines = 0
        while True:
            if banner_lines > _MAX_BANNER_LINES:
                raise ProtocolException(
                    "Too many banner lines before SSH version string"
                )
            banner_lines += 1

            # _recv_bytes is internally buffered (32 KB reads), so this per-byte
            # loop reads from memory on all but the first iteration.
            current_line = b""
            while True:
                char = self._recv_bytes(1)
                if not char:
                    raise TransportException("Connection closed during banner read")

                current_line += char
                if char == b"\n":
                    # Remove trailing CRLF or LF
                    if current_line.endswith(b"\r\n"):
                        current_line = current_line[:-2]
                    else:
                        current_line = current_line[:-1]
                    break

                if len(current_line) > MAX_VERSION_LINE_LENGTH:
                    raise ProtocolException("Version line too long")

            if current_line.startswith(b"SSH-"):
                version_line = current_line
                break

            self._logger.debug(f"Ignoring non-SSH line: {repr(current_line)}")

        try:
            version_string = version_line.decode(SSH_STRING_ENCODING)
        except UnicodeDecodeError:
            raise ProtocolException("Invalid version string encoding")

        self._remote_version = version_string.strip()
        self._logger.debug(f"Remote version: {self._remote_version}")

        # Parse and validate version
        try:
            protocol_version, software_version = parse_version_string(version_string)
        except ValueError as e:
            raise ProtocolException(f"Invalid version string: {e}") from e

        if not is_supported_version(protocol_version):
            raise ProtocolException(f"Unsupported protocol version: {protocol_version}")

        if self._server_mode:
            self._client_version = version_string
        else:
            self._server_version = version_string

    def _start_kex(self) -> None:
        """
        Start key exchange process in the background.
        Includes a 30-second watchdog to prevent hanging on stalled connections.
        """
        with self._lock:
            self._kex_in_progress = True
            self._kex_thread = threading.current_thread()
            self._kex_condition.notify_all()

        timeout = 30.0

        # Set a temporary timeout for key exchange
        old_timeout = self._socket.gettimeout()
        self._socket.settimeout(timeout)

        try:
            # Send KEXINIT message with our supported algorithms
            self._send_kexinit()

            # Receive server's KEXINIT message
            self._recv_kexinit()

            # Now perform key exchange using KeyExchange class
            self._logger.debug("Starting DH exchange phase...")
            self._kex.start_kex()
            self._logger.debug("Rekeying handshake complete.")

        except (OSError, struct.error, SSHException) as e:
            # _kex_in_progress / _active are reset under the lock in finally
            with self._lock:
                self._active = False
            try:
                self.close()
            except (OSError, SSHException):
                pass
            if isinstance(e, SSHException):
                raise
            raise TransportException(f"Rekeying failed or timed out: {e}") from e
        finally:
            # Clear flags and reset byte counter atomically under the lock so
            # _check_rekey / _send_packet observers see a consistent snapshot.
            with self._lock:
                self._kex_in_progress = False
                self._kex_thread = None
                self._bytes_since_rekey = 0
                self._last_rekey_time = time.time()
                self._kex_condition.notify_all()

            try:
                if self._socket and self._socket.fileno() != -1:
                    self._socket.settimeout(old_timeout)
            except (OSError, AttributeError):
                pass

    def _send_kexinit(self) -> None:
        """Send KEXINIT message with supported algorithms."""
        cookie = self._crypto_backend.generate_random(KEX_COOKIE_SIZE)

        # Use algorithms from CipherSuite to ensure consistency
        cipher_suite = self._kex._cipher_suite

        # Strict KEX (Terrapin defense)
        kex_algorithms = list(cipher_suite.KEX_ALGORITHMS)
        if self._server_mode:
            # Server sends kex-strict-s
            kex_algorithms = [
                a for a in kex_algorithms if a != "kex-strict-c-v00@openssh.com"
            ]
            if "kex-strict-s-v00@openssh.com" not in kex_algorithms:
                kex_algorithms.append("kex-strict-s-v00@openssh.com")
            # Servers don't send ext-info-c
            kex_algorithms = [a for a in kex_algorithms if a != "ext-info-c"]
        else:
            # Client: append client-side signaling tokens, strip server-only token
            for token in cipher_suite.KEX_SIGNAL_TOKENS:
                if token not in kex_algorithms:
                    kex_algorithms.append(token)
            kex_algorithms = [
                a for a in kex_algorithms if a != "kex-strict-s-v00@openssh.com"
            ]

        kexinit_msg = KexInitMessage(
            cookie=cookie,
            kex_algorithms=kex_algorithms,
            server_host_key_algorithms=cipher_suite.HOST_KEY_ALGORITHMS,
            encryption_algorithms_client_to_server=cipher_suite.ENCRYPTION_ALGORITHMS,
            encryption_algorithms_server_to_client=cipher_suite.ENCRYPTION_ALGORITHMS,
            mac_algorithms_client_to_server=cipher_suite.MAC_ALGORITHMS,
            mac_algorithms_server_to_client=cipher_suite.MAC_ALGORITHMS,
            compression_algorithms_client_to_server=[COMPRESS_NONE],
            compression_algorithms_server_to_client=[COMPRESS_NONE],
        )

        self._client_kexinit_blob = kexinit_msg.pack()
        self._send_message(kexinit_msg)

    def _recv_kexinit(self) -> None:
        """Receive and process KEXINIT message."""
        msg = self._expect_message(MSG_KEXINIT)

        if not isinstance(msg, KexInitMessage):
            raise ProtocolException(f"Expected KEXINIT, got {type(msg).__name__}")

        # Store peer's KEXINIT for algorithm negotiation
        self._peer_kexinit = msg
        self._logger.debug(f"Peer KEX algorithms: {msg.kex_algorithms}")

        # Check for strict KEX marker from peer
        self._peer_strict_kex_version = None
        for algo in msg.kex_algorithms:
            if algo in (
                "kex-strict-s-v00@openssh.com",
                "kex-strict-c-v00@openssh.com",
            ):
                self._peer_strict_kex_version = "v00"
                break

        if self._peer_strict_kex_version:
            self._strict_kex = True
            self._logger.debug("Strict KEX mode enabled (Terrapin defense)")

    def _check_rekey(self) -> None:
        """Check if rekeying is needed and start it if so."""
        if not self._active:
            return

        with self._lock:
            if self._kex_in_progress:
                return

            # Check byte limit, time limit, or sequence number (rekey every 2^31 packets)
            if (
                self._bytes_since_rekey >= self._rekey_bytes_limit
                or (time.time() - self._last_rekey_time) >= self._rekey_time_limit
                or self._sequence_number_out >= REKEY_SEQUENCE_THRESHOLD
                or self._sequence_number_in >= REKEY_SEQUENCE_THRESHOLD
            ):
                self._logger.debug(
                    f"Triggering rekeying: bytes={self._bytes_since_rekey}, limit={self._rekey_bytes_limit}"
                )
                # Trigger rekeying in a separate thread to avoid blocking current I/O
                # _kex_thread is set inside _start_kex() under self._lock (single owner)
                self._kex_in_progress = True
                kex_thread = threading.Thread(
                    target=self._start_kex, name="RekeyingThread", daemon=True
                )
                kex_thread.start()

    def _send_message(self, message: Message) -> None:
        """
        Send SSH message.

        Args:
            message: Message to send

        Raises:
            TransportException: If send fails
        """
        try:
            payload = message.pack()

            with self._lock:
                if self._profiler is not None:
                    t0 = perf_counter()
                    packet = self._build_packet(payload)
                    t1 = perf_counter()
                    if self._encryptor_instance or getattr(
                        self, "_cipher_out_active", None
                    ):
                        packet = self._encrypt_packet(packet)
                    t2 = perf_counter()
                    self._socket.sendall(packet)
                    t3 = perf_counter()
                    self._profiler.record(t1 - t0, t2 - t1, t3 - t2)
                else:
                    packet = self._build_packet(payload)

                    # Encrypt if we have an active cipher
                    if self._encryptor_instance or getattr(
                        self, "_cipher_out_active", None
                    ):
                        packet = self._encrypt_packet(packet)

                    self._socket.sendall(packet)

                # Track bytes sent for rekeying
                if message.msg_type not in [
                    MSG_KEXINIT,
                    MSG_NEWKEYS,
                ] and not (MSG_KEXDH_INIT <= message.msg_type <= MSG_KEXDH_REPLY):
                    self._bytes_since_rekey += len(packet)
                    self._check_rekey()

                # If this was a NEWKEYS message, activate encryption AFTER sending it
                if message.msg_type == MSG_NEWKEYS:
                    self._activate_outbound_encryption()

                self._sequence_number_out = (self._sequence_number_out + 1) & 0xFFFFFFFF

                # Strict-KEX (Terrapin defense, RFC): after sending NEWKEYS,
                # reset outbound sequence to 0 so the next packet uses nonce 0.
                if message.msg_type == MSG_NEWKEYS and self._strict_kex:
                    self._sequence_number_out = 0
                    self._logger.debug("Sequence number (out) reset for strict KEX")

        except (OSError, struct.error, TransportException) as e:
            if isinstance(e, TransportException):
                raise
            raise TransportException(f"Failed to send message: {e}") from e

    def _dispatch_packet(
        self, packet: bytes, single_pump: bool = False
    ) -> Optional[Message]:
        """
        Parse a raw SSH packet and dispatch it.

        Returns the message for the caller to consume, HandledMessage() if the
        packet was handled internally and single_pump=True, or None if it was
        handled internally and the caller should loop for the next packet.
        """
        payload = extract_message_from_packet(packet)

        with self._lock:
            msg = Message.unpack(payload)

            # Track bytes received for rekeying (unless it's KEX)
            if msg.msg_type not in [
                MSG_KEXINIT,
                MSG_NEWKEYS,
            ] and not (MSG_KEXDH_INIT <= msg.msg_type <= MSG_KEXDH_REPLY):
                self._bytes_since_rekey += len(packet)
                self._check_rekey()

            # ALWAYS increment sequence number for EVERY packet received
            self._sequence_number_in = (self._sequence_number_in + 1) & 0xFFFFFFFF

            if msg.msg_type in [MSG_IGNORE, MSG_DEBUG, MSG_EXT_INFO]:
                return HandledMessage() if single_pump else None  # type: ignore[return-value]

            if msg.msg_type == MSG_DISCONNECT:
                try:
                    d_msg = DisconnectMessage.unpack(payload)
                    reason = getattr(d_msg, "description", "Unknown")
                    code = getattr(d_msg, "reason_code", 0)
                except (struct.error, ValueError, UnicodeDecodeError, IndexError):
                    raise TransportException("Disconnected by peer")
                raise TransportException(f"Disconnected: {reason} (code: {code})")

            if msg.msg_type == MSG_NEWKEYS:
                self._activate_inbound_encryption()

            if msg.msg_type == MSG_KEXINIT and not self._kex_in_progress:
                # Peer initiated rekeying - set flag, queue message, start KEX thread.
                self._kex_in_progress = True
                self._message_queue.append(msg)
                self._kex_thread = threading.Thread(target=self._start_kex, daemon=True)
                self._kex_thread.start()
                return HandledMessage() if single_pump else None  # type: ignore[return-value]

            if (
                msg.msg_type == MSG_GLOBAL_REQUEST  # 80
                or msg.msg_type == MSG_CHANNEL_OPEN  # 90
                or (msg.msg_type >= 93 and msg.msg_type <= 100)
            ):
                self._handle_channel_message(msg)
                return HandledMessage() if single_pump else None  # type: ignore[return-value]

            # Server-side specific messages
            if self._server_mode:
                if msg.msg_type == MSG_SERVICE_REQUEST:
                    self._handle_service_request(msg)
                    return HandledMessage() if single_pump else None  # type: ignore[return-value]
                if msg.msg_type == MSG_USERAUTH_REQUEST:
                    self._handle_userauth_request(msg)
                    return HandledMessage() if single_pump else None  # type: ignore[return-value]

            return msg

    def _read_message(self, single_pump: bool = False) -> Optional[Message]:
        """
        Read next message from socket and dispatch if needed.
        Does NOT check the message queue.
        """
        try:
            while True:
                # If rekeying is in progress, only the rekeying thread is allowed to read from the socket.
                # Other threads must wait and check the queue (handled in _recv_message and _expect_message).
                if (
                    self._kex_in_progress
                    and not getattr(self, "_is_async", False)
                    and threading.current_thread() != self._kex_thread
                ):
                    # We should not be here if called from _recv_message or _expect_message
                    # as they have their own yielding loops, but for safety:
                    return None

                # Hold _read_lock while reading a complete packet to prevent
                # multiple threads from interleaving partial reads.
                with self._read_lock:
                    packet = self._recv_packet()
                    if not packet:
                        if not self._active:
                            return None
                        raise TransportException("Empty packet received")

                    msg = self._dispatch_packet(packet, single_pump)
                    if msg is not None:
                        return msg
                    # None means handled internally - loop to read the next packet.

        except (OSError, struct.error, SSHException, ProtocolException) as e:
            if isinstance(e, (SSHException, ProtocolException)):
                raise
            raise TransportException(f"Failed to receive message: {e}") from e

    def _pump(self) -> Optional[Union[Message, type[HandledMessage]]]:
        """
        Read next message and either handle it or queue it.
        This is used for background message processing to ensure no
        messages are lost when multiple threads are waiting for messages.

        Returns:
            The message read, or HandledMessage if it was handled internally.
        """
        # First check if we already have messages in the queue
        with self._lock:
            if self._message_queue:
                return self._message_queue.popleft()

        msg = self._read_message(single_pump=True)
        if msg:
            return msg
        return None

    def _recv_message(self) -> Message:
        """
        Receive SSH message, checking the queue first.
        """
        while True:
            while True:
                with self._lock:
                    if self._message_queue:
                        return self._message_queue.popleft()

                    # If no rekeying or we are the rekeying thread, proceed to read
                    if (
                        not self._kex_in_progress
                        or threading.current_thread() == self._kex_thread
                    ):
                        break

                # Release _lock before waiting so the kex thread can acquire it
                # (Event.wait does NOT release locks, causing starvation otherwise)
                if self._stop_event.wait(0.1):
                    raise TransportException("Transport is stopping")

            # Inner loop exited via break - safe to read from socket now
            msg = self._read_message()
            if msg is not None:
                return msg

    def _expect_message(
        self, *allowed_types: int, channel_id: Optional[int] = None
    ) -> Message:
        """
        Receive next message and ensure it's one of the allowed types.
        Messages of other types are queued for later processing.
        """
        while True:
            # 1. Check queue for allowed message
            while True:
                with self._lock:
                    for i, queued in enumerate(self._message_queue):
                        if queued.msg_type in allowed_types:
                            # If channel_id is specified, check if it matches
                            if channel_id is not None:
                                msg_channel_id = getattr(
                                    queued, "recipient_channel", None
                                )
                                if msg_channel_id is None and len(queued._data) >= 4:
                                    # Fallback for messages not fully parsed or without attribute
                                    msg_channel_id, _ = read_uint32(queued._data, 0)

                                if msg_channel_id != channel_id:
                                    continue

                            del self._message_queue[i]
                            return queued

                    # If no rekeying or we are the rekeying thread, proceed to read
                    if (
                        not self._kex_in_progress
                        or threading.current_thread() == self._kex_thread
                    ):
                        break

                # Release _lock before waiting so the kex thread can acquire it
                # (Event.wait does NOT release locks, causing starvation otherwise)
                if self._stop_event.wait(0.1):
                    raise TransportException("Transport is stopping")

            # 2. Not in queue, read from socket
            try:
                read_msg = self._read_message()
            except TransportException:
                if not self._active:
                    raise TransportException("Transport closed")
                raise

            if read_msg is None:
                continue

            if read_msg.msg_type in allowed_types:
                # Check channel_id if specified
                if channel_id is not None:
                    msg_channel_id = getattr(read_msg, "recipient_channel", None)
                    if msg_channel_id is None and len(read_msg._data) >= 4:
                        msg_channel_id, _ = read_uint32(read_msg._data, 0)

                    if msg_channel_id != channel_id:
                        # Not for this channel, queue it for others and continue looking
                        with self._lock:
                            if len(self._message_queue) >= MAX_QUEUE_SIZE:
                                raise TransportException(
                                    "Message queue size limit exceeded"
                                )
                            self._message_queue.append(read_msg)
                        continue
                return read_msg

            # 3. Not what we wanted, queue it for others
            with self._lock:
                if len(self._message_queue) >= MAX_QUEUE_SIZE:
                    raise TransportException("Message queue size limit exceeded")
                self._message_queue.append(read_msg)

    def get_server_host_key(self) -> Optional[Any]:
        """
        Get server's public host key.

        Returns:
            PKey object or None if not available
        """
        if not self._server_host_key_blob:
            return None

        from ..crypto.pkey import PKey

        try:
            return PKey.from_string(self._server_host_key_blob)
        except (ValueError, struct.error, SSHException):
            return None

    def _activate_outbound_encryption(self) -> None:
        """Activate outbound encryption using negotiated parameters."""
        if self._server_mode:
            cipher_name = self._cipher_s2c
            key = self._encryption_key_s2c
            iv = self._iv_s2c
            mac_name = self._mac_s2c
            mac_key = self._mac_key_s2c
        else:
            cipher_name = self._cipher_c2s
            key = self._encryption_key_c2s
            iv = self._iv_c2s
            mac_name = self._mac_c2s
            mac_key = self._mac_key_c2s

        if not cipher_name or not key:
            return

        if iv is None:
            raise TransportException("Encryption parameters not fully negotiated")

        self._cipher_out_active = cipher_name
        self._encryption_key_out_active = key
        self._iv_out_active = iv

        self._logger.info(f"Activating outbound encryption: {cipher_name}")

        if cipher_name == "chacha20-poly1305@openssh.com":
            self._chacha20_key_out = key
            self._encryptor_instance = None
            self._mac_out_active = None
            self._mac_key_out_active = None
            return

        encryptor = self._crypto_backend.create_cipher(cipher_name, key, iv)
        self._logger.debug(
            f"Activating {cipher_name}: key_len={len(key)}, iv_len={len(iv)}"
        )
        # AES-CTR needs separate encryptor instance for state
        self._encryptor_instance = encryptor.encryptor()

        self._mac_out_active = mac_name
        self._mac_key_out_active = mac_key

        # NOTE: strict-KEX outbound sequence reset happens in _send_message
        # AFTER the unconditional ``seq_out += 1`` that follows this call,
        # so the next packet after NEWKEYS goes out with sequence 0 as
        # required by the kex-strict-*@openssh.com extension. Resetting
        # here would be overwritten by the +1 and produce seq=1.

    def _activate_inbound_encryption(self) -> None:
        """Activate inbound encryption using negotiated parameters."""
        if self._server_mode:
            cipher_name = self._cipher_c2s
            key = self._encryption_key_c2s
            iv = self._iv_c2s
            mac_name = self._mac_c2s
            mac_key = self._mac_key_c2s
        else:
            cipher_name = self._cipher_s2c
            key = self._encryption_key_s2c
            iv = self._iv_s2c
            mac_name = self._mac_s2c
            mac_key = self._mac_key_s2c

        if not cipher_name or not key:
            return

        if iv is None:
            raise TransportException("Encryption parameters not fully negotiated")

        self._cipher_in_active = cipher_name
        self._encryption_key_in_active = key
        self._iv_in_active = iv

        self._logger.info(f"Activating inbound encryption: {cipher_name}")

        if cipher_name == "chacha20-poly1305@openssh.com":
            self._chacha20_key_in = key
            self._decryptor_instance = None
            self._mac_in_active = None
            self._mac_key_in_active = None
        else:
            decryptor = self._crypto_backend.create_cipher(cipher_name, key, iv)
            # AES-CTR needs separate decryptor instance for state
            self._decryptor_instance = decryptor.decryptor()

            self._mac_in_active = mac_name
            self._mac_key_in_active = mac_key

        # Strict-KEX (Terrapin defense): after receiving NEWKEYS, reset inbound
        # sequence to 0 so the next received packet is verified with nonce 0.
        if self._strict_kex:
            self._sequence_number_in = 0
            self._logger.debug("Sequence number (in) reset for strict KEX")

    def _encrypt_packet(self, packet: bytes) -> bytes:
        """Encrypt SSH packet and add MAC if needed."""
        if getattr(self, "_cipher_out_active", None) == "chacha20-poly1305@openssh.com":
            if self._chacha20_key_out is None:
                raise TransportException("ChaCha20 outbound key not initialised")
            return self._crypto_backend.chacha20_poly1305_encrypt(
                self._chacha20_key_out,
                self._sequence_number_out,
                packet[:PACKET_LENGTH_SIZE],
                packet[PACKET_LENGTH_SIZE:],
            )

        if self._encryptor_instance:
            # AES-CTR or similar
            encrypted = self._encryptor_instance.update(packet)

            # Add MAC
            if self._mac_out_active and self._mac_key_out_active:
                mac_data = (
                    struct.pack(">I", self._sequence_number_out & 0xFFFFFFFF) + packet
                )
                mac = self._crypto_backend.compute_mac(
                    self._mac_out_active, self._mac_key_out_active, mac_data
                )
                return bytes(encrypted + mac)
            return bytes(encrypted)

        return packet

    def _build_packet(self, payload: bytes) -> bytes:
        """
        Build SSH packet from payload.

        Args:
            payload: Message payload

        Returns:
            Complete SSH packet
        """
        cipher_name = getattr(self, "_cipher_out_active", None) or getattr(
            self, "_cipher_c2s", None
        )

        if cipher_name:
            if cipher_name not in _CIPHER_BLOCK_SIZES:
                raise TransportException(f"Unknown cipher: {cipher_name}")
            block_size = _CIPHER_BLOCK_SIZES[cipher_name]
        else:
            block_size = 8

        # For AEAD ciphers (e.g. chacha20-poly1305) the 4-byte length field is
        # encrypted separately, so only the body (packet_length bytes) must be a
        # multiple of block_size.  For unencrypted packets and CTR/stream ciphers
        # the entire packet (4-byte prefix + body) is one unit, so the prefix is
        # included in the alignment.  Use _cipher_out_active (the currently
        # active encryption cipher) not the negotiated _cipher_c2s, because
        # NEWKEYS is sent before encryption is activated and must use standard
        # alignment even though _cipher_c2s may already be set to chacha20.
        active_cipher = getattr(self, "_cipher_out_active", None)
        length_overhead = 0 if active_cipher in _AEAD_CIPHERS else PACKET_LENGTH_SIZE
        padding_length = block_size - (
            (len(payload) + PADDING_LENGTH_SIZE + length_overhead) % block_size
        )

        if padding_length < MIN_PADDING_SIZE:
            padding_length += block_size

        # Generate random padding
        padding = self._crypto_backend.generate_random(padding_length)

        # Build packet into a pre-allocated bytearray to avoid repeated copies
        packet_length = PADDING_LENGTH_SIZE + len(payload) + padding_length
        packet = bytearray(
            PACKET_LENGTH_SIZE + PADDING_LENGTH_SIZE + len(payload) + padding_length
        )
        struct.pack_into(">IB", packet, 0, packet_length, padding_length)
        payload_start = PACKET_LENGTH_SIZE + PADDING_LENGTH_SIZE
        packet[payload_start : payload_start + len(payload)] = payload
        packet[payload_start + len(payload) :] = padding

        return bytes(packet)

    def _recv_packet(self) -> bytes:
        """
        Receive complete SSH packet.

        Returns:
            Complete SSH packet

        Raises:
            TransportException: If receive fails
        """
        if getattr(self, "_cipher_in_active", None) == "chacha20-poly1305@openssh.com":
            enc_length = self._recv_bytes(PACKET_LENGTH_SIZE)
            if len(enc_length) < PACKET_LENGTH_SIZE:
                if not self._active:
                    return b""
                raise TransportException("Short read while receiving packet length")
            if self._chacha20_key_in is None:
                raise TransportException("ChaCha20 inbound key not initialised")
            plain_length = self._crypto_backend.chacha20_poly1305_decrypt_length(
                self._chacha20_key_in, self._sequence_number_in, enc_length
            )
            packet_length = struct.unpack(">I", plain_length)[0]
            # For AEAD ciphers the minimum valid body is one block (8 bytes):
            # padding_len(1) + payload(>=1) + padding(>=4), aligned to 8.
            if packet_length < 8 or packet_length > MAX_PACKET_SIZE:
                raise ProtocolException(f"Invalid packet length: {packet_length}")
            enc_body = self._recv_bytes(packet_length)
            tag = self._recv_bytes(16)
            plain_body = self._crypto_backend.chacha20_poly1305_decrypt_body(
                self._chacha20_key_in,
                self._sequence_number_in,
                enc_length,
                enc_body,
                tag,
            )
            return bytes(plain_length + plain_body)

        if self._decryptor_instance:
            # AES-CTR: length is encrypted
            encrypted_length = self._recv_bytes(PACKET_LENGTH_SIZE)
            if len(encrypted_length) < PACKET_LENGTH_SIZE:
                if not self._active:
                    return b""
                raise TransportException("Short read while receiving packet length")

            length_data = self._decryptor_instance.update(encrypted_length)
            packet_length = struct.unpack(">I", length_data)[0]

            # Validate length
            if (
                packet_length < MIN_PACKET_SIZE - PACKET_LENGTH_SIZE
                or packet_length > MAX_PACKET_SIZE
            ):
                raise ProtocolException(f"Invalid packet length: {packet_length}")

            # Read rest of packet (encrypted)
            encrypted_payload = self._recv_bytes(packet_length)
            packet_payload = self._decryptor_instance.update(encrypted_payload)

            # Verify MAC
            if self._mac_in_active and self._mac_key_in_active:
                # Get mac length from CipherSuite
                mac_info = self._kex._cipher_suite.get_mac_info(self._mac_in_active)
                mac_len = mac_info["digest_len"]

                received_mac = self._recv_bytes(mac_len)
                mac_data = (
                    struct.pack(">I", self._sequence_number_in & 0xFFFFFFFF)
                    + length_data
                    + packet_payload
                )
                expected_mac = self._crypto_backend.compute_mac(
                    self._mac_in_active, self._mac_key_in_active, mac_data
                )

                if not hmac.compare_digest(received_mac, expected_mac):
                    raise TransportException("MAC verification failed")

            return bytes(length_data + packet_payload)

        # Unencrypted
        # Read packet length
        length_data = self._recv_bytes(PACKET_LENGTH_SIZE)
        packet_length = struct.unpack(">I", length_data)[0]

        # Validate packet length
        if packet_length < MIN_PACKET_SIZE - PACKET_LENGTH_SIZE:
            raise ProtocolException(f"Invalid packet length: {packet_length}")

        if packet_length > MAX_PACKET_SIZE - PACKET_LENGTH_SIZE:
            raise ProtocolException(f"Packet too large: {packet_length}")

        # Read rest of packet
        packet_data = self._recv_bytes(packet_length)

        # Verify MAC if present (even if unencrypted)
        if self._mac_in_active and self._mac_key_in_active:
            mac_info = self._kex._cipher_suite.get_mac_info(self._mac_in_active)
            mac_len = mac_info["digest_len"]
            received_mac = self._recv_bytes(mac_len)

            mac_data = (
                struct.pack(">I", self._sequence_number_in & 0xFFFFFFFF)
                + length_data
                + packet_data
            )
            expected_mac = self._crypto_backend.compute_mac(
                self._mac_in_active, self._mac_key_in_active, mac_data
            )

            if not hmac.compare_digest(received_mac, expected_mac):
                raise TransportException("MAC verification failed")

        # Return complete packet
        return length_data + packet_data

    def _recv_bytes(self, length: int) -> bytes:
        """
        Receive exact number of bytes from socket using internal buffering.

        Args:
            length: Number of bytes to receive

        Returns:
            Received bytes

        Raises:
            TransportException: If receive fails
        """
        # Locking model:
        # * ``self._lock`` guards ``self._packet_buffer`` and is held only for
        #   short, non-blocking buffer slices.
        # * ``self._read_lock`` serializes the actual blocking ``socket.recv``
        #   call so two threads cannot race the kernel for the same socket.
        # Fast path: if the buffer already has enough bytes, consume under
        # ``self._lock`` only — no need to acquire ``_read_lock`` at all.
        # Slow path: acquire ``_read_lock``, re-check the buffer (another
        # thread may have filled it while we waited), then block in recv().
        # Buffer consumption in the slow path therefore happens under both
        # ``_read_lock`` and ``self._lock``.
        while True:
            with self._lock:
                if len(self._packet_buffer) >= length:
                    data = bytes(self._packet_buffer[:length])
                    del self._packet_buffer[:length]
                    return data

            with self._read_lock:
                # Re-check buffer after acquiring _read_lock: another reader
                # may have refilled it while we waited.
                with self._lock:
                    if len(self._packet_buffer) >= length:
                        data = bytes(self._packet_buffer[:length])
                        del self._packet_buffer[:length]
                        return data
                    short_by = length - len(self._packet_buffer)

                # Read at least _buffer_size to leverage buffering. The blocking
                # ``recv`` happens with ``_read_lock`` held but ``self._lock``
                # released, so other threads can both send messages and serve
                # themselves from the existing buffer.
                try:
                    to_read = max(self._buffer_size, short_by)
                    chunk = self._socket.recv(to_read)
                except socket.timeout:
                    raise TransportException("Timeout receiving data")
                except OSError as e:
                    if not self._active:
                        return b""
                    raise TransportException(f"Socket error: {e}") from e

                if not chunk:
                    if not self._active or self._stop_event.is_set():
                        return b""
                    self._logger.debug("Socket closed while receiving")
                    raise TransportException("Connection closed unexpectedly")

                with self._lock:
                    self._packet_buffer += chunk

    @property
    def active(self) -> bool:
        """Check if transport is active."""
        return self._active

    @property
    def server_mode(self) -> bool:
        """Check if transport is in server mode."""
        return self._server_mode

    @property
    def authenticated(self) -> bool:
        """Check if transport is authenticated."""
        return self._authenticated

    @property
    def session_id(self) -> Optional[bytes]:
        """Get session ID."""
        return self._session_id

    def set_server_interface(self, server_interface: Any) -> None:
        """
        Set server interface for authentication callbacks.

        Args:
            server_interface: Server interface implementing authentication methods
        """
        self._server_interface = server_interface

    def get_server_interface(self) -> Optional[Any]:
        """
        Get server interface.

        Returns:
            Server interface or None if not set
        """
        return self._server_interface

    def _handle_service_request(self, msg: Message) -> None:
        """Handle service request message (server mode)."""
        try:
            service_name_bytes, _ = read_string(msg._data, 0)
            service_name = service_name_bytes.decode(SSH_STRING_ENCODING)

            if service_name == SERVICE_USERAUTH:
                accept_msg = ServiceAcceptMessage(SERVICE_USERAUTH)
                self._send_message(accept_msg)
            else:
                self._logger.warning(f"Rejecting unsupported service: {service_name}")
        except (
            OSError,
            struct.error,
            ValueError,
            UnicodeDecodeError,
            SSHException,
        ) as e:
            self._logger.error(f"Error handling service request: {e}")

    def _handle_userauth_request(self, msg: Message) -> None:
        """Handle user authentication request message (server mode)."""
        if not self._server_interface:
            self._send_message(UserAuthFailureMessage(["password", "publickey"], False))
            return

        username = ""
        try:
            # Unpack request
            auth_req = UserAuthRequestMessage._unpack_data(bytes(msg._data))
            username = auth_req.username
            method = auth_req.method

            result = AUTH_FAILED
            if method == AUTH_PASSWORD:
                offset = 0
                # Read boolean (False) before password
                change_requested, offset = read_boolean(auth_req.method_data, offset)
                password_bytes, offset = read_string(auth_req.method_data, offset)
                password = password_bytes.decode(SSH_STRING_ENCODING)
                result = self._server_interface.check_auth_password(username, password)
            elif method == AUTH_PUBLICKEY:
                offset = 0
                has_signature, offset = read_boolean(auth_req.method_data, offset)
                algo_name_bytes, offset = read_string(auth_req.method_data, offset)
                algo_name = algo_name_bytes.decode(SSH_STRING_ENCODING)
                key_blob, offset = read_string(auth_req.method_data, offset)

                from ..crypto.pkey import PKey

                try:
                    key = PKey.from_string(key_blob)
                except (ValueError, struct.error, SSHException):
                    # Invalid key blob
                    self._send_message(UserAuthFailureMessage(["publickey"], False))
                    return

                if not has_signature:
                    # Client is just querying if the key is acceptable
                    if self._server_interface.check_auth_publickey(username, key):
                        # Send PK_OK to indicate key is acceptable
                        pk_ok = Message(MSG_USERAUTH_PK_OK)
                        pk_ok._data.extend(write_string(algo_name))
                        pk_ok._data.extend(write_string(key_blob))
                        self._send_message(pk_ok)
                        return
                    else:
                        result = AUTH_FAILED
                else:
                    # Full authentication request with signature
                    signature, offset = read_string(auth_req.method_data, offset)

                    # Build data that was signed:
                    # string session_id, byte MSG_USERAUTH_REQUEST, string username,
                    # string service, string "publickey", boolean TRUE,
                    # string algo_name, string key_blob
                    signed_data = write_string(self._session_id or b"")
                    signed_data += write_byte(MSG_USERAUTH_REQUEST)
                    signed_data += write_string(username)
                    signed_data += write_string(auth_req.service)
                    signed_data += write_string(AUTH_PUBLICKEY)
                    signed_data += write_boolean(True)
                    signed_data += write_string(algo_name)
                    signed_data += write_string(key_blob)

                    if key.verify(signature, signed_data):
                        result = self._server_interface.check_auth_publickey(
                            username, key
                        )
                    else:
                        self._logger.warning(
                            f"Public key signature verification failed for user {username}"
                        )
                        result = AUTH_FAILED

            # Send response
            if result == AUTH_SUCCESSFUL:
                self._authenticated = True
                self._server_interface.on_authentication_successful(username, method)
                self._send_message(UserAuthSuccessMessage())
            else:
                self._server_interface.on_authentication_failed(username, method)
                allowed_methods = self._server_interface.get_allowed_auths(username)
                self._send_message(UserAuthFailureMessage(allowed_methods, False))

        except (
            OSError,
            struct.error,
            ValueError,
            UnicodeDecodeError,
            SSHException,
        ) as e:
            self._logger.error(f"Error handling userauth request: {e}")
            allowed = self._server_interface.get_allowed_auths(username)
            self._send_message(UserAuthFailureMessage(allowed, False))

    def get_port_forwarding_manager(self) -> "PortForwardingManager":
        """
        Get port forwarding manager.

        Returns:
            Port forwarding manager instance
        """
        if self._port_forwarding_manager is None:
            from .forwarding import PortForwardingManager

            self._port_forwarding_manager = PortForwardingManager(self)

        return self._port_forwarding_manager
Attributes
active property

Check if transport is active.

authenticated property

Check if transport is authenticated.

server_mode property

Check if transport is in server mode.

session_id property

Get session ID.

Methods:
__init__(sock, rekey_bytes_limit=None, rekey_time_limit=None)

Initialize transport with socket connection.

Parameters:

Name Type Description Default
sock socket

Connected socket for SSH communication

required
rekey_bytes_limit Optional[int]

Number of bytes before rekeying (default: 1GB)

None
rekey_time_limit Optional[float]

Seconds before rekeying (default: 1 hour)

None
Source code in spindlex/transport/transport.py
def __init__(
    self,
    sock: socket.socket,
    rekey_bytes_limit: Optional[int] = None,
    rekey_time_limit: Optional[float] = None,
) -> None:
    """
    Initialize transport with socket connection.

    Args:
        sock: Connected socket for SSH communication
        rekey_bytes_limit: Number of bytes before rekeying (default: 1GB)
        rekey_time_limit: Seconds before rekeying (default: 1 hour)
    """
    self._socket = sock
    self._active = False
    self._server_mode = False
    self._channels: dict[int, Channel] = {}
    self._next_channel_id = 0

    # Connection state
    self._authenticated = False
    self._session_id: Optional[bytes] = None
    self._server_version: Optional[str] = None
    self._client_version: Optional[str] = None

    # Crypto state
    self._crypto_backend = default_crypto_backend
    self._encryption_key_c2s: Optional[bytes] = None
    self._encryption_key_s2c: Optional[bytes] = None
    self._mac_key_c2s: Optional[bytes] = None
    self._mac_key_s2c: Optional[bytes] = None
    self._iv_c2s: Optional[bytes] = None
    self._iv_s2c: Optional[bytes] = None
    self._cipher_c2s: Optional[str] = None
    self._cipher_s2c: Optional[str] = None
    self._mac_c2s: Optional[str] = None
    self._mac_s2c: Optional[str] = None

    # Active crypto state (updated only on NEWKEYS)
    self._mac_key_in_active: Optional[bytes] = None
    self._mac_key_out_active: Optional[bytes] = None
    self._mac_in_active: Optional[str] = None
    self._mac_out_active: Optional[str] = None

    # Rekeying policy (configurable)
    self._rekey_bytes_limit = rekey_bytes_limit or (
        1024 * 1024 * 1024
    )  # 1GB default
    self._rekey_time_limit = rekey_time_limit or 3600  # 1 hour default

    # Cipher instances
    self._encryptor_instance: Optional[Any] = None
    self._decryptor_instance: Optional[Any] = None
    self._chacha20_key_out: Optional[bytes] = None
    self._chacha20_key_in: Optional[bytes] = None

    self._sequence_number_in = 0
    self._sequence_number_out = 0
    self._packet_buffer = bytearray()
    # Lock ordering contract - to prevent ABBA deadlock, always acquire in this order:
    #   1. _read_lock  (socket-level serialisation; held while reading a packet)
    #   2. _lock       (state-level serialisation; held while mutating transport state)
    # Never acquire _read_lock while already holding _lock.
    self._lock = threading.RLock()
    self._read_lock = threading.RLock()
    self._kex_condition = threading.Condition(self._lock)
    self._server_host_key_blob: Optional[bytes] = None

    self._kex_in_progress = False
    self._kex = KeyExchange(self)
    self._bytes_since_rekey = 0
    self._last_rekey_time = time.time()

    # Timeouts
    self._connect_timeout: float = float(DEFAULT_CONNECT_TIMEOUT)
    self._auth_timeout: float = float(DEFAULT_AUTH_TIMEOUT)

    # Authentication state
    self._userauth_service_requested = False

    # Server interface for authentication callbacks
    self._server_interface: Optional[Any] = None

    # Port forwarding
    self._port_forwarding_manager: Optional[PortForwardingManager] = None

    # Message dispatching
    self._message_queue: deque[Message] = deque()
    self._timeout = 10.0

    self._logger = logging.getLogger(__name__)
    self._strict_kex = False
    self._stop_event = threading.Event()
    self._kex_thread: Optional[threading.Thread] = None
    self._server_key: Optional[Any] = None

    self._buffer_size = 32768
    env_buffer_size = os.environ.get("SPINDLEX_BUFFER_SIZE")
    if env_buffer_size:
        try:
            self._buffer_size = max(4096, int(env_buffer_size))
        except (ValueError, TypeError):
            pass

    self._profiler: Optional[PacketProfiler] = (
        PacketProfiler() if os.environ.get("SPINDLEX_PROFILE") == "1" else None
    )
auth_gssapi(username, gss_host=None, gss_deleg_creds=False)

Authenticate using GSSAPI method.

Parameters:

Name Type Description Default
username str

Username for authentication

required
gss_host Optional[str]

GSSAPI hostname (optional)

None
gss_deleg_creds bool

Whether to delegate credentials

False

Returns:

Type Description
bool

True if authentication successful

Raises:

Type Description
AuthenticationException

If authentication fails

Source code in spindlex/transport/transport.py
def auth_gssapi(
    self,
    username: str,
    gss_host: Optional[str] = None,
    gss_deleg_creds: bool = False,
) -> bool:
    """
    Authenticate using GSSAPI method.

    Args:
        username: Username for authentication
        gss_host: GSSAPI hostname (optional)
        gss_deleg_creds: Whether to delegate credentials

    Returns:
        True if authentication successful

    Raises:
        AuthenticationException: If authentication fails
    """
    if not self._active:
        raise AuthenticationException("Transport not active")

    if self._authenticated:
        return True

    try:
        from ..auth.gssapi import GSSAPIAuth

        # Create GSSAPI authenticator
        gssapi_auth = GSSAPIAuth(self)

        # Perform GSSAPI authentication
        result = gssapi_auth.authenticate(username, gss_host, gss_deleg_creds)

        # Clean up GSSAPI resources
        gssapi_auth.cleanup()

        return result

    except ImportError:
        raise AuthenticationException("GSSAPI authentication not available")
    except (OSError, struct.error, SSHException) as e:
        if isinstance(e, AuthenticationException):
            raise
        raise AuthenticationException(f"GSSAPI authentication failed: {e}") from e
auth_keyboard_interactive(username, handler)

Authenticate using keyboard-interactive method.

Parameters:

Name Type Description Default
username str

Username for authentication

required
handler Any

Callback function to handle prompts

required

Returns:

Type Description
bool

True if authentication successful

Raises:

Type Description
AuthenticationException

If authentication fails

Source code in spindlex/transport/transport.py
def auth_keyboard_interactive(self, username: str, handler: Any) -> bool:
    """
    Authenticate using keyboard-interactive method.

    Args:
        username: Username for authentication
        handler: Callback function to handle prompts

    Returns:
        True if authentication successful

    Raises:
        AuthenticationException: If authentication fails
    """
    if not self._active:
        raise AuthenticationException("Transport not active")

    if self._authenticated:
        return True

    try:
        # Request ssh-userauth service if not already done
        if not self._userauth_service_requested:
            self._request_userauth_service()

        from ..auth.keyboard_interactive import KeyboardInteractiveAuth

        # Send initial keyboard-interactive request
        auth_request = UserAuthRequestMessage(
            username=username,
            service=SERVICE_CONNECTION,
            method=AUTH_KEYBOARD_INTERACTIVE,
            method_data=self._build_keyboard_interactive_data(),
        )

        self._send_message(auth_request)

        # Perform interactive authentication
        ki_auth = KeyboardInteractiveAuth(self)
        result = ki_auth.authenticate(username, handler)

        if result:
            self._authenticated = True

        return result

    except (OSError, struct.error, SSHException) as e:
        if isinstance(e, AuthenticationException):
            raise
        raise AuthenticationException(
            f"Keyboard-interactive authentication failed: {e}"
        )
auth_password(username, password)

Authenticate using password.

Parameters:

Name Type Description Default
username str

Username for authentication

required
password str

Password for authentication

required

Returns:

Type Description
bool

True if authentication successful

Raises:

Type Description
AuthenticationException

If authentication fails

Source code in spindlex/transport/transport.py
def auth_password(self, username: str, password: str) -> bool:
    """
    Authenticate using password.

    Args:
        username: Username for authentication
        password: Password for authentication

    Returns:
        True if authentication successful

    Raises:
        AuthenticationException: If authentication fails
    """
    if not self._active:
        raise AuthenticationException("Transport not active")

    if self._authenticated:
        return True

    # Request ssh-userauth service if not already done
    if not self._userauth_service_requested:
        self._request_userauth_service()

    from ..auth.password import PasswordAuth

    auth = PasswordAuth(self)
    msg = auth.authenticate(username, password)
    return self._handle_auth_response_message(msg)
auth_publickey(username, key)

Authenticate using public key.

Parameters:

Name Type Description Default
username str

Username for authentication

required
key Any

Private key for authentication

required

Returns:

Type Description
bool

True if authentication successful

Raises:

Type Description
AuthenticationException

If authentication fails

Source code in spindlex/transport/transport.py
def auth_publickey(self, username: str, key: Any) -> bool:
    """
    Authenticate using public key.

    Args:
        username: Username for authentication
        key: Private key for authentication

    Returns:
        True if authentication successful

    Raises:
        AuthenticationException: If authentication fails
    """
    if not self._active:
        raise AuthenticationException("Transport not active")

    if self._authenticated:
        return True

    # Request ssh-userauth service if not already done
    if not self._userauth_service_requested:
        self._request_userauth_service()

    from ..auth.publickey import PublicKeyAuth

    auth = PublicKeyAuth(self)
    msg = auth.authenticate(username, key)
    return self._handle_auth_response_message(msg)
close()

Close transport and cleanup resources.

Source code in spindlex/transport/transport.py
def close(self) -> None:
    """Close transport and cleanup resources."""
    kex_thread: Optional[threading.Thread] = None
    # Snapshot the channel list under the transport lock, then release it
    # before calling Channel.close(). Channel.close() takes the channel
    # lock and then re-enters _close_channel which takes the transport
    # lock - holding the transport lock here while calling channel.close()
    # would invert the order taken by any concurrent caller of
    # Channel.close() and deadlock the two threads.
    with self._lock:
        self._active = False
        self._stop_event.set()
        kex_thread = self._kex_thread
        channels_snapshot = list(self._channels.values())
        sock = self._socket

    for channel in channels_snapshot:
        try:
            channel.close()
        except (OSError, SSHException):
            pass

    with self._lock:
        self._channels.clear()

    if sock is not None:
        try:
            sock.shutdown(socket.SHUT_RDWR)
        except OSError:
            pass
        try:
            sock.close()
        except OSError:
            pass

    # Join kex thread outside lock to avoid deadlock.
    # Socket closure above ensures the thread unblocks promptly.
    if (
        kex_thread is not None
        and kex_thread.is_alive()
        and kex_thread != threading.current_thread()
    ):
        kex_thread.join(timeout=5.0)
get_port_forwarding_manager()

Get port forwarding manager.

Returns:

Type Description
PortForwardingManager

Port forwarding manager instance

Source code in spindlex/transport/transport.py
def get_port_forwarding_manager(self) -> "PortForwardingManager":
    """
    Get port forwarding manager.

    Returns:
        Port forwarding manager instance
    """
    if self._port_forwarding_manager is None:
        from .forwarding import PortForwardingManager

        self._port_forwarding_manager = PortForwardingManager(self)

    return self._port_forwarding_manager
get_server_host_key()

Get server's public host key.

Returns:

Type Description
Optional[Any]

PKey object or None if not available

Source code in spindlex/transport/transport.py
def get_server_host_key(self) -> Optional[Any]:
    """
    Get server's public host key.

    Returns:
        PKey object or None if not available
    """
    if not self._server_host_key_blob:
        return None

    from ..crypto.pkey import PKey

    try:
        return PKey.from_string(self._server_host_key_blob)
    except (ValueError, struct.error, SSHException):
        return None
get_server_interface()

Get server interface.

Returns:

Type Description
Optional[Any]

Server interface or None if not set

Source code in spindlex/transport/transport.py
def get_server_interface(self) -> Optional[Any]:
    """
    Get server interface.

    Returns:
        Server interface or None if not set
    """
    return self._server_interface
get_timeout()

Get transport timeout.

Returns:

Type Description
Optional[float]

Current timeout in seconds, or None if no timeout

Source code in spindlex/transport/transport.py
def get_timeout(self) -> Optional[float]:
    """
    Get transport timeout.

    Returns:
        Current timeout in seconds, or None if no timeout
    """
    return self._socket.gettimeout()
open_channel(kind, dest_addr=None)

Open new SSH channel.

Parameters:

Name Type Description Default
kind str

Channel type (session, direct-tcpip, etc.)

required
dest_addr Optional[tuple[str, int]]

Destination address for forwarding channels

None

Returns:

Type Description
Channel

New Channel instance

Raises:

Type Description
TransportException

If channel creation fails

Source code in spindlex/transport/transport.py
def open_channel(
    self, kind: str, dest_addr: Optional[tuple[str, int]] = None
) -> Channel:
    """
    Open new SSH channel.

    Args:
        kind: Channel type (session, direct-tcpip, etc.)
        dest_addr: Destination address for forwarding channels

    Returns:
        New Channel instance

    Raises:
        TransportException: If channel creation fails
    """
    if not self._active:
        raise TransportException("Transport not active")

    if not self._authenticated:
        raise TransportException("Transport not authenticated")

    with self._lock:
        # Check channel limit
        if len(self._channels) >= MAX_CHANNELS:
            raise TransportException("Maximum number of channels reached")

        # Find next available channel ID (recycling IDs)
        channel_id = self._next_channel_id
        while channel_id in self._channels:
            channel_id = (channel_id + 1) % MAX_CHANNELS

        self._next_channel_id = (channel_id + 1) % MAX_CHANNELS

        # Create channel instance
        channel = Channel(self, channel_id)

        # Register channel BEFORE sending open request to avoid race
        self._channels[channel_id] = channel

    try:
        # Build channel open message
        type_specific_data = b""
        if kind == CHANNEL_DIRECT_TCPIP and dest_addr:
            type_specific_data = self._build_direct_tcpip_data(dest_addr)

        open_msg = ChannelOpenMessage(
            channel_type=kind,
            sender_channel=channel_id,
            initial_window_size=DEFAULT_WINDOW_SIZE,
            maximum_packet_size=DEFAULT_MAX_PACKET_SIZE,
            type_specific_data=type_specific_data,
        )

        # Send channel open request
        self._send_message(open_msg)

        # Wait for response (CRITICAL: release lock before calling _expect_message)
        response = self._expect_message(
            MSG_CHANNEL_OPEN_CONFIRMATION,
            MSG_CHANNEL_OPEN_FAILURE,
            channel_id=channel_id,
        )

        if isinstance(response, ChannelOpenConfirmationMessage):
            # Channel opened successfully
            with self._lock:
                channel._remote_channel_id = response.sender_channel
                channel._remote_window_size = response.initial_window_size
                channel._remote_max_packet_size = response.maximum_packet_size
                channel._local_window_size = DEFAULT_WINDOW_SIZE
                channel._local_max_packet_size = DEFAULT_MAX_PACKET_SIZE

            return channel

        elif isinstance(response, ChannelOpenFailureMessage):
            # Channel open failed - remove from channels
            with self._lock:
                if channel_id in self._channels:
                    del self._channels[channel_id]
            raise TransportException(
                f"Channel open failed: {response.description} (code: {response.reason_code})"
            )

        else:
            # Unexpected response - remove from channels
            with self._lock:
                if channel_id in self._channels:
                    del self._channels[channel_id]
            raise TransportException(
                f"Unexpected response to channel open: {type(response).__name__}"
            )

    except (OSError, struct.error, SSHException) as e:
        # Cleanup on error
        with self._lock:
            if channel_id in self._channels:
                del self._channels[channel_id]
        if isinstance(e, SSHException):
            raise
        raise TransportException(f"Failed to open channel: {e}") from e
set_rekey_policy(bytes_limit=None, time_limit=None)

Configure rekeying thresholds.

Parameters:

Name Type Description Default
bytes_limit Optional[int]

Number of bytes before rekeying (default: 1GB)

None
time_limit Optional[float]

Seconds before rekeying (default: 1 hour)

None
Source code in spindlex/transport/transport.py
def set_rekey_policy(
    self, bytes_limit: Optional[int] = None, time_limit: Optional[float] = None
) -> None:
    """
    Configure rekeying thresholds.

    Args:
        bytes_limit: Number of bytes before rekeying (default: 1GB)
        time_limit: Seconds before rekeying (default: 1 hour)
    """
    if bytes_limit is not None:
        self._rekey_bytes_limit = bytes_limit
    if time_limit is not None:
        self._rekey_time_limit = time_limit
set_server_interface(server_interface)

Set server interface for authentication callbacks.

Parameters:

Name Type Description Default
server_interface Any

Server interface implementing authentication methods

required
Source code in spindlex/transport/transport.py
def set_server_interface(self, server_interface: Any) -> None:
    """
    Set server interface for authentication callbacks.

    Args:
        server_interface: Server interface implementing authentication methods
    """
    self._server_interface = server_interface
set_timeout(timeout)

Set default timeout for transport operations.

Source code in spindlex/transport/transport.py
def set_timeout(self, timeout: float) -> None:
    """Set default timeout for transport operations."""
    self._timeout = timeout
    if self._socket:
        self._socket.settimeout(timeout)
start_client(timeout=None)

Start SSH client transport.

Parameters:

Name Type Description Default
timeout Optional[float]

Handshake timeout in seconds

None

Raises:

Type Description
TransportException

If client start fails

Source code in spindlex/transport/transport.py
def start_client(self, timeout: Optional[float] = None) -> None:
    """
    Start SSH client transport.

    Args:
        timeout: Handshake timeout in seconds

    Raises:
        TransportException: If client start fails
    """
    if timeout is not None:
        self._connect_timeout = timeout

    try:
        with self._read_lock:
            with self._lock:
                if self._active:
                    raise TransportException("Transport already active")

                self._server_mode = False
                self._client_version = create_version_string()

                # Set socket timeout for handshake
                old_timeout = self._socket.gettimeout()
                self._socket.settimeout(self._connect_timeout)

                try:
                    # Perform SSH handshake
                    self._logger.debug("Starting handshake...")
                    self._do_handshake()
                    self._logger.debug("Handshake complete.")

                finally:
                    # Restore original socket timeout
                    try:
                        if self._socket and self._socket.fileno() != -1:
                            self._socket.settimeout(old_timeout)
                    except (OSError, AttributeError):
                        pass

            # Start key exchange (WITHOUT holding _lock)
            self._logger.debug("Starting KEX...")
            self._start_kex()
            self._logger.debug("KEX complete.")

            with self._lock:
                self._active = True

    except (OSError, struct.error, SSHException) as e:
        self.close()
        if isinstance(e, SSHException):
            raise
        raise TransportException(f"Client start failed: {e}") from e
start_server(server_key, timeout=None)

Start SSH server transport.

Parameters:

Name Type Description Default
server_key Any

Server's private key

required
timeout Optional[float]

Handshake timeout in seconds

None

Raises:

Type Description
TransportException

If server start fails

Source code in spindlex/transport/transport.py
def start_server(self, server_key: Any, timeout: Optional[float] = None) -> None:
    """
    Start SSH server transport.

    Args:
        server_key: Server's private key
        timeout: Handshake timeout in seconds

    Raises:
        TransportException: If server start fails
    """
    if timeout is not None:
        self._connect_timeout = timeout

    try:
        with self._read_lock:
            with self._lock:
                if self._active:
                    raise TransportException("Transport already active")

                self._server_mode = True
                self._server_key = server_key
                self._server_version = create_version_string()

                # Set socket timeout for handshake
                old_timeout = self._socket.gettimeout()
                self._socket.settimeout(self._connect_timeout)

                try:
                    # Perform SSH handshake
                    self._logger.debug("Starting handshake...")
                    self._do_handshake()
                    self._logger.debug("Handshake complete.")

                finally:
                    # Restore original socket timeout
                    try:
                        if self._socket and self._socket.fileno() != -1:
                            self._socket.settimeout(old_timeout)
                    except (OSError, AttributeError):
                        pass

            # Start key exchange (WITHOUT holding _lock)
            self._logger.debug("Starting KEX...")
            self._start_kex()
            self._logger.debug("KEX complete.")

            with self._lock:
                self._active = True

    except (OSError, struct.error, SSHException) as e:
        self.close()
        if isinstance(e, SSHException):
            raise
        raise TransportException(f"Server start failed: {e}") from e

Functions:

spindlex.transport.async_transport

Async SSH Transport Layer Implementation

Provides asynchronous SSH transport functionality for high-concurrency applications.

Classes

AsyncTransport

Bases: Transport

Async SSH transport layer implementation.

This implementation bridges the synchronous Transport logic with asyncio by overriding the low-level I/O methods.

Source code in spindlex/transport/async_transport.py
 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
class AsyncTransport(Transport):
    """
    Async SSH transport layer implementation.

    This implementation bridges the synchronous Transport logic with
    asyncio by overriding the low-level I/O methods.
    """

    def __init__(
        self,
        sock: socket.socket,
        rekey_bytes_limit: int | None = None,
        rekey_time_limit: int | None = None,
    ) -> None:
        super().__init__(
            sock,
            rekey_bytes_limit=rekey_bytes_limit,
            rekey_time_limit=rekey_time_limit,
        )
        self._reader: asyncio.StreamReader | None = None
        self._writer: asyncio.StreamWriter | None = None
        self._loop: asyncio.AbstractEventLoop | None = None
        self._port_forwarding_manager: AsyncPortForwardingManager | None = None  # type: ignore[assignment]

        # Locks for async safety
        self._send_lock = asyncio.Lock()
        self._recv_lock = asyncio.Lock()
        self._state_lock = asyncio.Lock()
        self._is_async = True

    async def connect_existing(
        self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
    ) -> None:
        """Initialize with existing asyncio streams."""
        self._loop = asyncio.get_running_loop()
        async with self._state_lock:
            self._reader = reader
            self._writer = writer

    async def start_client(self, timeout: float | None = None) -> None:  # type: ignore[override]
        self._loop = asyncio.get_running_loop()
        if timeout is not None:
            self._connect_timeout = timeout

        async with self._state_lock:
            if self._active:
                raise TransportException("Transport already active")
            self._server_mode = False

        try:
            # Handshake: send our version first, then receive peer's (RFC 4253 §4.2)
            await self._send_version_async()
            await self._recv_version_async()

            # Key Exchange
            await self._start_kex_async()

            async with self._state_lock:
                self._active = True

        except Exception as e:
            await self.close()
            if isinstance(e, SSHException):
                raise
            raise TransportException(f"Client start failed: {e}") from e

    async def _start_kex_async(self) -> None:
        """Performs KEX by bridging sync KEX logic into a thread."""
        async with self._state_lock:
            if self._kex_in_progress:
                raise TransportException("Key exchange already in progress")
            self._kex_in_progress = True

        try:
            # We run the entire KEX initiation in a thread to avoid deadlocking the loop
            # with sync-to-async bridge calls (.result() calls).
            await asyncio.to_thread(self._run_kex_threadsafe)
        except Exception:
            async with self._state_lock:
                self._kex_in_progress = False
            raise

    def _run_kex_threadsafe(self) -> None:
        """Thread-safe KEX execution bridging."""
        # Record thread so _read_message receives packets
        self._kex_thread = threading.current_thread()
        try:
            # This runs in a separate thread, safe to block on .result()
            self._send_kexinit()
            self._recv_kexinit()
            self._kex.start_kex()
        finally:
            self._kex_thread = None
            # Reset progress flag
            self._kex_in_progress = False

    def get_port_forwarding_manager(self) -> AsyncPortForwardingManager:  # type: ignore[override]
        """Get port forwarding manager."""
        if self._port_forwarding_manager is None:
            from .async_forwarding import AsyncPortForwardingManager

            self._port_forwarding_manager = AsyncPortForwardingManager(self)

        return self._port_forwarding_manager

    # --- Bridge Methods for Sync Logic ---

    def _send_message(self, message: Message) -> None:
        """Bridge sync calls to async send."""
        if not self._loop or not self._loop.is_running():
            return super()._send_message(message)

        # Use run_coroutine_threadsafe to schedule and wait for the result
        # This ensures we have backpressure and catch exceptions.
        # This must be called from a thread OTHER than the event loop thread.
        try:
            fut = asyncio.run_coroutine_threadsafe(
                self._send_message_async(message), self._loop
            )
            fut.result()
        except Exception as e:
            if isinstance(e, TransportException):
                raise
            raise TransportException(
                f"Failed to send message via async bridge: {e}"
            ) from e

    def _recv_message(self, allowed_types: list[int] | None = None) -> Message:
        """Bridge sync calls to async recv."""
        if not self._loop:
            return super()._recv_message()

        try:
            asyncio.get_running_loop()
            raise TransportException("Synchronous receive called on event loop thread")
        except RuntimeError:
            # For debugging the 'bytes' error
            fut = asyncio.run_coroutine_threadsafe(
                self._recv_message_async(), self._loop
            )
            return fut.result()

    def _expect_message(
        self, *allowed_types: int, channel_id: Optional[int] = None
    ) -> Message:
        """Bridge sync expect_message."""
        if not self._loop:
            return super()._expect_message(*allowed_types, channel_id=channel_id)

        try:
            asyncio.get_running_loop()
            raise TransportException(
                "Synchronous expect_message called on event loop thread"
            )
        except RuntimeError:
            if not self._loop:
                raise TransportException("Event loop not available")
            fut = asyncio.run_coroutine_threadsafe(
                self._expect_message_async(*allowed_types, channel_id=channel_id),
                self._loop,
            )
            return fut.result()

    # --- Async Implementation of Packet I/O ---

    def _recv_bytes(self, length: int) -> bytes:
        """Bridge sync recv_bytes to async reader."""
        if not self._reader or not self._loop:
            raise TransportException("Transport not initialized with async streams")

        try:
            fut = asyncio.run_coroutine_threadsafe(
                self._reader.readexactly(length), self._loop
            )
            return fut.result()
        except Exception:
            raise

    async def _send_message_async(self, message: Message) -> None:
        """Async version of _send_message."""
        async with self._send_lock:
            if not self._writer:
                raise TransportException("Transport not initialized with async streams")

            payload = message.pack()
            packet = self._build_packet(payload)
            packet = self._encrypt_packet(packet)

            self._writer.write(packet)
            # Only drain when the write buffer is large; avoids a per-packet
            # event-loop yield that kills throughput on pipelined SFTP transfers.
            try:
                if self._writer.transport.get_write_buffer_size() > _DRAIN_THRESHOLD:
                    await self._writer.drain()
            except (AttributeError, TypeError):
                pass  # Buffer size unavailable; no drain needed (e.g. test mocks).

            # Track bytes sent for rekeying
            if message.msg_type not in [
                MSG_KEXINIT,
                MSG_NEWKEYS,
            ] and not (MSG_KEXDH_INIT <= message.msg_type <= MSG_KEXDH_REPLY):
                self._bytes_since_rekey += len(packet)
                self._check_rekey()

            # If this was a NEWKEYS message, activate encryption AFTER sending it
            if message.msg_type == MSG_NEWKEYS:
                self._activate_outbound_encryption()

            self._sequence_number_out = (self._sequence_number_out + 1) & 0xFFFFFFFF

            # Strict-KEX (Terrapin defense, RFC): after sending NEWKEYS,
            # reset outbound sequence to 0 so the next packet uses nonce 0.
            if message.msg_type == MSG_NEWKEYS and self._strict_kex:
                self._sequence_number_out = 0
                self._logger.debug("Sequence number (out) reset for strict KEX")

    async def _recv_packet_async(self) -> bytes:
        """
        Read one complete SSH packet directly from the asyncio StreamReader.

        This is the hot-path replacement for the thread-bridge that previously
        called asyncio.to_thread(super()._read_message), eliminating two context
        switches (event-loop → thread → event-loop) per received packet.
        """
        if not self._reader:
            raise TransportException("Transport not initialised with async streams")

        try:
            if (
                getattr(self, "_cipher_in_active", None)
                == "chacha20-poly1305@openssh.com"
            ):
                enc_length = await self._reader.readexactly(PACKET_LENGTH_SIZE)
                assert self._chacha20_key_in is not None
                plain_length = self._crypto_backend.chacha20_poly1305_decrypt_length(
                    self._chacha20_key_in, self._sequence_number_in, enc_length
                )
                packet_length = struct.unpack(">I", plain_length)[0]
                if packet_length < 8 or packet_length > MAX_PACKET_SIZE:
                    raise ProtocolException(f"Invalid packet length: {packet_length}")
                enc_body = await self._reader.readexactly(packet_length)
                tag = await self._reader.readexactly(16)
                plain_body = self._crypto_backend.chacha20_poly1305_decrypt_body(
                    self._chacha20_key_in,
                    self._sequence_number_in,
                    enc_length,
                    enc_body,
                    tag,
                )
                return bytes(plain_length + plain_body)

            if self._decryptor_instance:
                # Read the encrypted packet-length field
                enc_len = await self._reader.readexactly(PACKET_LENGTH_SIZE)
                length_data = self._decryptor_instance.update(enc_len)
                packet_length = struct.unpack(">I", length_data)[0]

                if (
                    packet_length < MIN_PACKET_SIZE - PACKET_LENGTH_SIZE
                    or packet_length > MAX_PACKET_SIZE
                ):
                    raise ProtocolException(f"Invalid packet length: {packet_length}")

                enc_payload = await self._reader.readexactly(packet_length)
                packet_payload = self._decryptor_instance.update(enc_payload)

                if self._mac_in_active and self._mac_key_in_active:
                    mac_info = self._kex._cipher_suite.get_mac_info(self._mac_in_active)
                    mac_len = mac_info["digest_len"]
                    received_mac = await self._reader.readexactly(mac_len)
                    mac_data = (
                        struct.pack(">I", self._sequence_number_in & 0xFFFFFFFF)
                        + length_data
                        + packet_payload
                    )
                    expected_mac = self._crypto_backend.compute_mac(
                        self._mac_in_active, self._mac_key_in_active, mac_data
                    )
                    if not hmac.compare_digest(received_mac, expected_mac):
                        raise TransportException("MAC verification failed")

                return bytes(length_data + packet_payload)

            # Unencrypted path
            length_data = await self._reader.readexactly(PACKET_LENGTH_SIZE)
            packet_length = struct.unpack(">I", length_data)[0]

            if packet_length < MIN_PACKET_SIZE - PACKET_LENGTH_SIZE:
                raise ProtocolException(f"Invalid packet length: {packet_length}")
            if packet_length > MAX_PACKET_SIZE - PACKET_LENGTH_SIZE:
                raise ProtocolException(f"Packet too large: {packet_length}")

            packet_data = await self._reader.readexactly(packet_length)

            if self._mac_in_active and self._mac_key_in_active:
                mac_info = self._kex._cipher_suite.get_mac_info(self._mac_in_active)
                mac_len = mac_info["digest_len"]
                received_mac = await self._reader.readexactly(mac_len)
                mac_data = (
                    struct.pack(">I", self._sequence_number_in & 0xFFFFFFFF)
                    + length_data
                    + packet_data
                )
                expected_mac = self._crypto_backend.compute_mac(
                    self._mac_in_active, self._mac_key_in_active, mac_data
                )
                if not hmac.compare_digest(received_mac, expected_mac):
                    raise TransportException("MAC verification failed")

            return length_data + packet_data

        except asyncio.IncompleteReadError as e:
            if not self._active:
                raise TransportException("Transport closed")
            raise TransportException(
                f"Connection closed while reading packet: {e}"
            ) from e

    async def _recv_message_async(self, check_queue: bool = True) -> Message:
        """Async version of _recv_message - reads natively from StreamReader."""
        if check_queue:
            async with self._state_lock:
                if self._message_queue:
                    return self._message_queue.popleft()

        async with self._recv_lock:
            while True:
                packet = await self._recv_packet_async()
                if not packet:
                    if not self._active:
                        raise TransportException("Transport closed")
                    raise TransportException("Empty packet received")

                msg = self._dispatch_packet(packet, single_pump=False)
                if msg is not None and msg.msg_type != 0:
                    return msg
                # None / msg_type==0 sentinel → handled internally; loop for next packet.

    async def _pump_async(self) -> None:
        """
        Pump the transport once to read and dispatch exactly one SSH packet.
        Used by channels to wait for data/window adjustments.
        """
        async with self._recv_lock:
            packet = await self._recv_packet_async()
            msg = self._dispatch_packet(packet, single_pump=True)

        # Queue protocol messages for _expect_message_async; skip msg_type==0 sentinels.
        if msg is not None and msg.msg_type != 0:
            async with self._state_lock:
                self._message_queue.append(msg)

    async def _expect_message_async(
        self, *allowed_types: int, channel_id: int | None = None
    ) -> Message:
        """Async version of expect_message."""
        while True:
            # 1. Check queue
            async with self._state_lock:
                for i, msg in enumerate(self._message_queue):
                    if msg.msg_type in allowed_types:
                        # If channel_id is specified, check if it matches
                        if channel_id is not None:
                            msg_channel_id = getattr(msg, "recipient_channel", None)
                            if msg_channel_id is None and len(msg._data) >= 4:
                                msg_channel_id = struct.unpack(">I", msg._data[:4])[0]

                            if msg_channel_id != channel_id:
                                continue

                        del self._message_queue[i]
                        return msg

            # 2. Read next
            msg = await self._recv_message_async(check_queue=False)
            if msg.msg_type in allowed_types:
                # If channel_id is specified, check if it matches
                if channel_id is not None:
                    msg_channel_id = getattr(msg, "recipient_channel", None)
                    if msg_channel_id is None and len(msg._data) >= 4:
                        msg_channel_id = struct.unpack(">I", msg._data[:4])[0]

                    if msg_channel_id == channel_id:
                        return msg
                else:
                    return msg

            # 3. Queue it
            async with self._state_lock:
                self._message_queue.append(msg)

    # --- Handshake Helpers ---

    async def _send_version_async(self) -> None:
        version_string = create_version_string()
        if self._server_mode:
            self._server_version = version_string
        else:
            self._client_version = version_string
        if not self._writer:
            raise TransportException("Transport not initialized with async streams")
        self._writer.write((version_string + "\r\n").encode(SSH_STRING_ENCODING))
        await self._writer.drain()

    async def _recv_version_async(self) -> None:
        if not self._reader:
            raise TransportException("Transport not initialized with async streams")
        while True:
            line = await self._reader.readline()
            if not line:
                raise TransportException("Connection closed")
            line = line.strip()
            if line.startswith(b"SSH-"):
                version_string = line.decode(SSH_STRING_ENCODING)
                if self._server_mode:
                    self._client_version = version_string
                else:
                    self._server_version = version_string
                break

    async def _send_kexinit_async(self) -> None:
        self._send_kexinit()

    async def _recv_kexinit_async(self) -> None:
        msg = await self._recv_message_async()
        if not isinstance(msg, KexInitMessage):
            raise ProtocolException("Expected KEXINIT")
        self._peer_kexinit = msg

    # --- Common Async Methods ---

    async def auth_password(self, username: str, password: str) -> bool:  # type: ignore[override]
        if not self._userauth_service_requested:
            await self._send_message_async(ServiceRequestMessage(SERVICE_USERAUTH))
            await self._expect_message_async(MSG_SERVICE_ACCEPT)
            self._userauth_service_requested = True

        from ..auth.password import PasswordAuth

        auth = PasswordAuth(self)
        msg = await auth.authenticate_async(username, password)
        return self._handle_auth_response_message(msg)

    async def auth_publickey(self, username: str, key: Any) -> bool:  # type: ignore[override]
        """Authenticate using public key method asynchronously."""
        if not self._userauth_service_requested:
            await self._send_message_async(ServiceRequestMessage(SERVICE_USERAUTH))
            await self._expect_message_async(MSG_SERVICE_ACCEPT)
            self._userauth_service_requested = True

        from ..auth.publickey import PublicKeyAuth

        auth = PublicKeyAuth(self)
        msg = await auth.authenticate_async(username, key)
        return self._handle_auth_response_message(msg)

    async def auth_gssapi(  # type: ignore[override]
        self,
        username: str,
        gss_host: str | None = None,
        gss_deleg_creds: bool = False,
    ) -> bool:
        """Authenticate using GSSAPI method asynchronously."""
        if not self._userauth_service_requested:
            await self._send_message_async(ServiceRequestMessage(SERVICE_USERAUTH))
            await self._expect_message_async(MSG_SERVICE_ACCEPT)
            self._userauth_service_requested = True

        from ..auth.gssapi import GSSAPIAuth

        gssapi_auth = GSSAPIAuth(self)

        try:
            # Note: The GSSAPI exchange uses internal bridge calls (_send_message, _recv_message)
            # which we've already bridged to async. However, for a fully async experience
            # we should really have an AsyncGSSAPIAuth. For now, since it runs in it's own
            # logic flow, we use to_thread to keep the loop free.
            result = await asyncio.to_thread(
                gssapi_auth.authenticate, username, gss_host, gss_deleg_creds
            )
            if result:
                self._authenticated = True
            return result
        finally:
            gssapi_auth.cleanup()

    async def auth_keyboard_interactive(  # type: ignore[override]
        self, username: str, handler: Any
    ) -> bool:
        """Authenticate using keyboard-interactive method asynchronously."""
        if not self._userauth_service_requested:
            await self._send_message_async(ServiceRequestMessage(SERVICE_USERAUTH))
            await self._expect_message_async(MSG_SERVICE_ACCEPT)
            self._userauth_service_requested = True

        from ..auth.keyboard_interactive import AsyncKeyboardInteractiveAuth

        # Send initial keyboard-interactive request
        auth_request = UserAuthRequestMessage(
            username=username,
            service=SERVICE_CONNECTION,
            method=AUTH_KEYBOARD_INTERACTIVE,
            method_data=self._build_keyboard_interactive_data(),
        )
        await self._send_message_async(auth_request)

        # Perform interactive authentication
        ki_auth = AsyncKeyboardInteractiveAuth(self)
        result = await ki_auth.authenticate_async(username, handler)

        if result:
            self._authenticated = True
        return result

    async def _send_global_request_async(
        self, request_name: str, want_reply: bool, request_data: bytes = b""
    ) -> Message | None:
        """Send global request asynchronously."""
        msg = GlobalRequestMessage(request_name, want_reply, request_data)
        await self._send_message_async(msg)

        if want_reply:
            return await self._expect_message_async(
                MSG_REQUEST_SUCCESS, MSG_REQUEST_FAILURE
            )
        return None

    def _handle_forwarded_tcpip_open(
        self,
        sender_channel: int,
        initial_window_size: int,
        maximum_packet_size: int,
        type_specific_data: bytes,
    ) -> None:
        """Bridge sync forwarded-tcpip open to async manager."""
        if self._port_forwarding_manager:
            # We must schedule this in the event loop as it involves async operations
            asyncio.run_coroutine_threadsafe(
                self._port_forwarding_manager.handle_forwarded_connection_async(
                    sender_channel,
                    initial_window_size,
                    maximum_packet_size,
                    type_specific_data,
                ),
                self._loop,  # type: ignore
            )
        else:
            # No manager, reject the channel
            failure_msg = ChannelOpenFailureMessage(
                recipient_channel=sender_channel,
                reason_code=SSH_OPEN_CONNECT_FAILED,
                description="Port forwarding not enabled",
                language="",
            )
            self._send_message(failure_msg)

    def _build_keyboard_interactive_data(self) -> bytes:
        """Build keyboard-interactive authentication method data."""
        data = bytearray()
        data.extend(write_string(""))  # language tag
        data.extend(write_string(""))  # submethods
        return bytes(data)

    async def open_channel(self, kind: str, dest_addr: tuple | None = None) -> Any:  # type: ignore[override]
        async with self._state_lock:
            cid = self._next_channel_id
            self._next_channel_id += 1

        from .async_channel import AsyncChannel

        chan = AsyncChannel(self, cid)

        async with self._state_lock:
            self._channels[cid] = chan

        # Build open message
        msg = ChannelOpenMessage(
            channel_type=kind,
            sender_channel=cid,
            initial_window_size=DEFAULT_WINDOW_SIZE,
            maximum_packet_size=DEFAULT_MAX_PACKET_SIZE,
        )
        await self._send_message_async(msg)

        # Wait for confirmation
        try:
            res = await self._expect_message_async(
                MSG_CHANNEL_OPEN_CONFIRMATION, MSG_CHANNEL_OPEN_FAILURE, channel_id=cid
            )
        except Exception:
            async with self._state_lock:
                if cid in self._channels:
                    del self._channels[cid]
            raise

        if isinstance(res, ChannelOpenConfirmationMessage):
            chan._remote_channel_id = res.sender_channel
            chan._remote_window_size = res.initial_window_size
            chan._remote_max_packet_size = res.maximum_packet_size
            return chan

        async with self._state_lock:
            if cid in self._channels:
                del self._channels[cid]
        raise TransportException("Failed to open channel")

    async def _send_channel_request_async(
        self, channel_id: int, request_type: str, want_reply: bool, data: bytes
    ) -> None:
        """Send channel request message asynchronously."""
        remote_id = self._channels[channel_id]._remote_channel_id
        if remote_id is None:
            raise TransportException(f"Channel {channel_id} remote ID not set")

        msg = ChannelRequestMessage(
            recipient_channel=remote_id,
            request_type=request_type,
            want_reply=want_reply,
            request_data=data,
        )
        await self._send_message_async(msg)

    async def _send_channel_data_async(self, channel_id: int, data: bytes) -> None:
        """Send channel data message asynchronously."""
        remote_id = self._channels[channel_id]._remote_channel_id
        if remote_id is None:
            raise TransportException(f"Channel {channel_id} remote ID not set")

        msg = ChannelDataMessage(recipient_channel=remote_id, data=data)
        await self._send_message_async(msg)

    async def _send_channel_eof_async(self, channel_id: int) -> None:
        """Send channel EOF message asynchronously."""
        remote_id = self._channels[channel_id]._remote_channel_id
        if remote_id is None:
            return

        msg = ChannelEOFMessage(recipient_channel=remote_id)
        await self._send_message_async(msg)

    async def _send_channel_close_async(self, channel_id: int) -> None:
        """Send channel close message asynchronously."""
        remote_id = self._channels[channel_id]._remote_channel_id
        if remote_id is None:
            return

        msg = ChannelCloseMessage(recipient_channel=remote_id)
        await self._send_message_async(msg)

    async def _send_channel_window_adjust_async(
        self, channel_id: int, bytes_to_add: int
    ) -> None:
        """Send channel window adjust message asynchronously."""
        chan = self._channels.get(channel_id)
        if chan is None:
            return
        remote_id = chan._remote_channel_id
        if remote_id is None:
            return

        msg = ChannelWindowAdjustMessage(
            recipient_channel=remote_id,
            bytes_to_add=bytes_to_add,
        )
        await self._send_message_async(msg)

    async def close(self) -> None:  # type: ignore[override]
        # Snapshot channels and mark inactive before releasing lock
        async with self._state_lock:
            self._active = False
            channels_to_close = list(self._channels.values())
            self._channels.clear()

        # Close channels outside the state lock to avoid deadlock
        # (AsyncChannel.close() also acquires _state_lock)
        from .async_channel import AsyncChannel

        for c in channels_to_close:
            try:
                if isinstance(c, AsyncChannel):
                    await c.close()
                else:
                    c.close()
            except Exception as e:
                self._logger.debug(f"Channel close error in transport: {e}")
        async with self._state_lock:
            if self._writer:
                try:
                    self._writer.close()
                    await asyncio.wait_for(self._writer.wait_closed(), timeout=2.0)
                except Exception as e:
                    self._logger.debug(f"Error closing channel in transport: {e}")
                self._writer = None
            self._reader = None
            if self._socket:
                try:
                    self._socket.close()
                except Exception as e:
                    self._logger.debug(f"Error closing channel in transport: {e}")
Methods:
auth_gssapi(username, gss_host=None, gss_deleg_creds=False) async

Authenticate using GSSAPI method asynchronously.

Source code in spindlex/transport/async_transport.py
async def auth_gssapi(  # type: ignore[override]
    self,
    username: str,
    gss_host: str | None = None,
    gss_deleg_creds: bool = False,
) -> bool:
    """Authenticate using GSSAPI method asynchronously."""
    if not self._userauth_service_requested:
        await self._send_message_async(ServiceRequestMessage(SERVICE_USERAUTH))
        await self._expect_message_async(MSG_SERVICE_ACCEPT)
        self._userauth_service_requested = True

    from ..auth.gssapi import GSSAPIAuth

    gssapi_auth = GSSAPIAuth(self)

    try:
        # Note: The GSSAPI exchange uses internal bridge calls (_send_message, _recv_message)
        # which we've already bridged to async. However, for a fully async experience
        # we should really have an AsyncGSSAPIAuth. For now, since it runs in it's own
        # logic flow, we use to_thread to keep the loop free.
        result = await asyncio.to_thread(
            gssapi_auth.authenticate, username, gss_host, gss_deleg_creds
        )
        if result:
            self._authenticated = True
        return result
    finally:
        gssapi_auth.cleanup()
auth_keyboard_interactive(username, handler) async

Authenticate using keyboard-interactive method asynchronously.

Source code in spindlex/transport/async_transport.py
async def auth_keyboard_interactive(  # type: ignore[override]
    self, username: str, handler: Any
) -> bool:
    """Authenticate using keyboard-interactive method asynchronously."""
    if not self._userauth_service_requested:
        await self._send_message_async(ServiceRequestMessage(SERVICE_USERAUTH))
        await self._expect_message_async(MSG_SERVICE_ACCEPT)
        self._userauth_service_requested = True

    from ..auth.keyboard_interactive import AsyncKeyboardInteractiveAuth

    # Send initial keyboard-interactive request
    auth_request = UserAuthRequestMessage(
        username=username,
        service=SERVICE_CONNECTION,
        method=AUTH_KEYBOARD_INTERACTIVE,
        method_data=self._build_keyboard_interactive_data(),
    )
    await self._send_message_async(auth_request)

    # Perform interactive authentication
    ki_auth = AsyncKeyboardInteractiveAuth(self)
    result = await ki_auth.authenticate_async(username, handler)

    if result:
        self._authenticated = True
    return result
auth_publickey(username, key) async

Authenticate using public key method asynchronously.

Source code in spindlex/transport/async_transport.py
async def auth_publickey(self, username: str, key: Any) -> bool:  # type: ignore[override]
    """Authenticate using public key method asynchronously."""
    if not self._userauth_service_requested:
        await self._send_message_async(ServiceRequestMessage(SERVICE_USERAUTH))
        await self._expect_message_async(MSG_SERVICE_ACCEPT)
        self._userauth_service_requested = True

    from ..auth.publickey import PublicKeyAuth

    auth = PublicKeyAuth(self)
    msg = await auth.authenticate_async(username, key)
    return self._handle_auth_response_message(msg)
connect_existing(reader, writer) async

Initialize with existing asyncio streams.

Source code in spindlex/transport/async_transport.py
async def connect_existing(
    self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
    """Initialize with existing asyncio streams."""
    self._loop = asyncio.get_running_loop()
    async with self._state_lock:
        self._reader = reader
        self._writer = writer
get_port_forwarding_manager()

Get port forwarding manager.

Source code in spindlex/transport/async_transport.py
def get_port_forwarding_manager(self) -> AsyncPortForwardingManager:  # type: ignore[override]
    """Get port forwarding manager."""
    if self._port_forwarding_manager is None:
        from .async_forwarding import AsyncPortForwardingManager

        self._port_forwarding_manager = AsyncPortForwardingManager(self)

    return self._port_forwarding_manager

Functions:

Channels

spindlex.transport.channel

SSH Channel Implementation

Represents individual communication channels within SSH connections with support for different channel types and operations.

Classes

Channel

SSH channel for communication within SSH connection.

Handles data transmission, flow control, and channel-specific operations like command execution and shell access.

Source code in spindlex/transport/channel.py
 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
class Channel:
    """
    SSH channel for communication within SSH connection.

    Handles data transmission, flow control, and channel-specific
    operations like command execution and shell access.
    """

    def __init__(self, transport: Any, channel_id: int) -> None:
        """
        Initialize channel with transport and ID.

        Args:
            transport: SSH transport instance
            channel_id: Unique channel identifier
        """
        self._transport = transport
        self._channel_id = channel_id
        self._closed = False
        self._exit_status: Optional[int] = None

        # Remote channel info (set by transport after channel open)
        self._remote_channel_id: Optional[int] = None
        self._remote_window_size = 0
        self._remote_max_packet_size = 0

        # Local channel info
        self._local_window_size = 0
        self._local_max_packet_size = 0

        # Data buffers — declared as Any because AsyncChannel overrides these with
        # plain bytes (flat buffer, sliceable) vs the deque[bytes] used here.
        self._recv_buffer: Any = deque()
        self._stderr_buffer: Any = deque()

        # Flow control
        self._eof_received = False
        self._eof_sent = False

        # Request handling
        self._request_success: Optional[bool] = None

        # Threading
        self._lock = threading.RLock()
        self._data_event = threading.Event()
        self._window_event = threading.Event()
        self._request_event = threading.Event()
        self._exit_status_event = threading.Event()
        self._timeout: Optional[float] = None
        self._logger = logging.getLogger(__name__)

    def settimeout(self, timeout: Optional[float]) -> None:
        """
        Set timeout for channel operations.

        Args:
            timeout: Timeout in seconds, or None for no timeout
        """
        self._timeout = timeout

    def gettimeout(self) -> Optional[float]:
        """
        Get channel timeout.

        Returns:
            Current timeout in seconds
        """
        return self._timeout

    def send(self, data: Union[bytes, str], timeout: Optional[float] = None) -> int:
        """
        Send data through channel.

        Args:
            data: Data to send (bytes or string)
            timeout: Optional timeout for this operation (overrides channel timeout)

        Returns:
            Number of bytes sent

        Raises:
            ChannelException: If send operation fails
        """
        if not data:
            return 0

        # Convert string to bytes if needed
        if isinstance(data, str):
            data = data.encode(SSH_STRING_ENCODING)

        start_time = time.time()

        # Use effective timeout
        effective_timeout = timeout if timeout is not None else self._timeout

        with self._lock:
            if self._closed:
                raise ChannelException("Channel is closed")

            if self._eof_sent:
                raise ChannelException("EOF already sent on channel")

            if self._remote_channel_id is None:
                raise ChannelException("Channel not properly opened")

            # Wait for window space if it's empty
            while self._remote_window_size <= 0:
                # Check timeout
                if effective_timeout is not None:
                    elapsed = time.time() - start_time
                    if elapsed >= effective_timeout:
                        raise ChannelException("Timeout waiting for window space")
                # Release lock and wait for window adjust or close
                self._window_event.clear()
                self._lock.release()
                try:
                    # If we're waiting for window space, we MUST pump the transport
                    # to process any incoming WINDOW_ADJUST messages from the server.
                    # Otherwise, we will wait forever in a single-threaded environment.
                    if not self._window_event.wait(timeout=0.1):
                        self._transport._pump()
                except socket.timeout:
                    pass  # Retry after window adjust
                except Exception as e:
                    raise ChannelException(f"Transport error during send: {e}") from e
                finally:
                    self._lock.acquire()

                # Re-check channel state after waking up
                if self._closed:
                    raise ChannelException("Channel is closed while waiting")

            # We have some window space
            # Send at most one packet (to match standard send() behavior)
            can_send = min(
                len(data), self._remote_window_size, self._remote_max_packet_size
            )

            if can_send <= 0:
                return 0

            chunk = data[:can_send]

            try:
                # Send data through transport
                self._transport._send_channel_data(self._channel_id, chunk)

                # Update remote window size
                self._remote_window_size -= len(chunk)

                return len(chunk)

            except Exception as e:
                raise ChannelException(f"Failed to send data: {e}") from e

    def sendall(self, data: Union[bytes, str], timeout: Optional[float] = None) -> None:
        """
        Send all data through channel, retrying until all sent.

        Args:
            data: Data to send
            timeout: Optional timeout
        """
        if isinstance(data, str):
            data = data.encode(SSH_STRING_ENCODING)

        total_sent = 0
        while total_sent < len(data):
            sent = self.send(data[total_sent:], timeout=timeout)
            if sent <= 0:
                raise ChannelException("Failed to send data")
            total_sent += sent

    def recv(self, nbytes: int) -> bytes:
        """
        Receive data from channel.

        Args:
            nbytes: Maximum bytes to receive

        Returns:
            Received data

        Raises:
            ChannelException: If receive operation fails
        """
        if nbytes <= 0:
            return b""

        start_time = time.time()
        while True:
            with self._lock:
                # Check if we have data in buffer
                if self._recv_buffer:
                    # Get data from buffer
                    data_chunk = self._recv_buffer.popleft()

                    if len(data_chunk) <= nbytes:
                        # Return entire chunk
                        result = bytes(data_chunk)
                        self._adjust_window(len(result))
                        return result
                    else:
                        # Split chunk and put remainder back
                        result = data_chunk[:nbytes]
                        remainder = data_chunk[nbytes:]
                        self._recv_buffer.appendleft(remainder)
                        self._adjust_window(len(result))
                        return bytes(result)

                # No data available in buffer
                if self._eof_received or not self._transport.active:
                    return b""  # EOF reached or transport inactive

                # Check total timeout
                if self._timeout is not None:
                    elapsed = time.time() - start_time
                    if elapsed >= self._timeout:
                        raise ChannelException("Timeout receiving data")

                # Clear event before we start waiting
                self._data_event.clear()

            # If a background thread is pumping the transport (e.g. during
            # rekey or in async mode), wait for it to deliver data via the
            # event.  Otherwise drive _pump() directly - without this,
            # sync-mode recv() pays 100ms of dead wait time per packet.
            has_bg_thread = getattr(self._transport, "_kex_thread", None) is not None

            if has_bg_thread:
                wait_timeout = 0.1
                if self._timeout is not None:
                    elapsed = time.time() - start_time
                    wait_timeout = max(0, min(0.1, self._timeout - elapsed))
                self._data_event.wait(timeout=wait_timeout)
                continue

            try:
                # When a channel timeout is active, bound the socket wait via
                # select() so the deadline is honoured.  When there is no
                # channel timeout, _pump() blocks on socket.recv() until a
                # packet arrives - which is what we want.
                if self._timeout is not None:
                    elapsed = time.time() - start_time
                    remaining = self._timeout - elapsed
                    if remaining <= 0:
                        raise ChannelException("Timeout receiving data")

                    has_buffered = bool(getattr(self._transport, "_packet_buffer", b""))
                    if not has_buffered:
                        import select as _select

                        sock = getattr(self._transport, "_socket", None)
                        if sock is not None:
                            try:
                                r, _, _ = _select.select(
                                    [sock], [], [], min(1.0, remaining)
                                )
                                if not r:
                                    continue  # no data yet, loop back
                            except Exception as e:
                                self._logger.debug(
                                    f"Pump error (expected on close): {e}"
                                )  # fall through to _pump()
                self._transport._pump()
            except socket.timeout:
                pass  # Loop back so the channel-timeout check at the top fires.

    def recv_exactly(self, nbytes: int) -> bytes:
        """
        Receive exactly nbytes from channel.

        Args:
            nbytes: Number of bytes to receive

        Returns:
            Received data

        Raises:
            ChannelException: If receive fails or channel closed
        """
        data = b""
        while len(data) < nbytes:
            chunk = self.recv(nbytes - len(data))
            if not chunk:
                raise ChannelException("Connection closed while waiting for data")
            data += chunk
        return data

    def exec_command(self, command: str) -> None:
        """
        Execute command on channel.

        Args:
            command: Command to execute

        Raises:
            ChannelException: If command execution fails
        """
        if not command:
            raise ChannelException("Command cannot be empty")

        # Build exec request data using SSH string format
        from ..protocol.utils import write_string

        request_data = write_string(command)

        # Send exec request
        success = self.send_channel_request("exec", want_reply=True, data=request_data)

        if not success:
            raise ChannelException(f"Failed to execute command: {command}")

    def invoke_shell(self) -> None:
        """
        Start interactive shell on channel.

        Raises:
            ChannelException: If shell invocation fails
        """

        # Send shell request (no additional data needed)
        success = self.send_channel_request("shell", want_reply=True)

        if not success:
            raise ChannelException("Failed to invoke shell")

    def invoke_subsystem(self, subsystem: str) -> None:
        """
        Invoke subsystem on channel.

        Args:
            subsystem: Name of subsystem to invoke (e.g., "sftp")

        Raises:
            ChannelException: If subsystem invocation fails
        """
        if not subsystem:
            raise ChannelException("Subsystem name cannot be empty")

        # Build subsystem request data using SSH string format
        from ..protocol.utils import write_string

        request_data = write_string(subsystem)

        # Send subsystem request
        success = self.send_channel_request(
            "subsystem", want_reply=True, data=request_data
        )

        if not success:
            raise ChannelException(f"Failed to invoke subsystem: {subsystem}")

    def request_pty(
        self,
        term: str = "xterm",
        width: int = 80,
        height: int = 24,
        width_pixels: int = 0,
        height_pixels: int = 0,
        modes: bytes = b"",
    ) -> None:
        """
        Request pseudo-terminal for channel.

        Args:
            term: Terminal type (e.g., "xterm", "vt100")
            width: Terminal width in characters
            height: Terminal height in characters
            width_pixels: Terminal width in pixels (0 if unknown)
            height_pixels: Terminal height in pixels (0 if unknown)
            modes: Terminal modes (encoded as per RFC 4254)

        Raises:
            ChannelException: If PTY request fails
        """
        # Build pty-req request data using SSH protocol format
        from ..protocol.utils import write_string, write_uint32

        request_data = bytearray()

        # Terminal type
        request_data.extend(write_string(term))

        # Terminal dimensions
        request_data.extend(write_uint32(width))
        request_data.extend(write_uint32(height))
        request_data.extend(write_uint32(width_pixels))
        request_data.extend(write_uint32(height_pixels))

        # Terminal modes
        request_data.extend(write_string(modes))

        # Send pty-req request
        success = self.send_channel_request(
            "pty-req", want_reply=True, data=bytes(request_data)
        )

        if not success:
            raise ChannelException("Failed to request PTY")

    def get_exit_status(self) -> int:
        """
        Get command exit status.

        Returns:
            Exit status code, or -1 if not available
        """
        return self._exit_status if self._exit_status is not None else -1

    def recv_exit_status(self, timeout: Optional[float] = None) -> int:
        """
        Wait for and return command exit status.

        Args:
            timeout: Optional timeout in seconds. If None, uses channel timeout.

        Returns:
            Exit status code

        Raises:
            ChannelException: If timeout reached
        """
        effective_timeout = timeout if timeout is not None else self._timeout

        if self._exit_status is not None:
            return self.get_exit_status()

        has_bg_thread = getattr(self._transport, "_kex_thread", None) is not None

        if has_bg_thread:
            signaled = self._exit_status_event.wait(timeout=effective_timeout)
            if not signaled and self._exit_status is None:
                raise ChannelException("Timeout waiting for exit status")
            return self.get_exit_status()

        # No background receive thread - pump until exit status arrives.
        start_time = time.time()
        while self._exit_status is None and not self._closed:
            if effective_timeout is not None:
                elapsed = time.time() - start_time
                if elapsed >= effective_timeout:
                    raise ChannelException("Timeout waiting for exit status")
            try:
                self._transport._pump()
            except Exception:
                break

        return self.get_exit_status()

    def send_exit_status(self, status: int) -> None:
        """
        Send command exit status to remote side.

        Args:
            status: Exit status code (typically 0 for success)

        Raises:
            ChannelException: If send fails
        """
        from ..protocol.utils import write_uint32

        # Build exit-status request data (4-byte unsigned integer)
        request_data = write_uint32(status)

        # Send exit-status request (no reply wanted for this type)
        self.send_channel_request("exit-status", want_reply=False, data=request_data)

    def send_channel_request(
        self, request_type: str, want_reply: bool = True, data: bytes = b""
    ) -> bool:
        """
        Send channel request.

        Args:
            request_type: Type of request (exec, shell, subsystem, etc.)
            want_reply: Whether to wait for reply
            data: Request-specific data

        Returns:
            True if request succeeded (when want_reply=True)

        Raises:
            ChannelException: If request fails
        """
        with self._lock:
            if self._closed:
                raise ChannelException("Channel is closed")

            if self._remote_channel_id is None:
                raise ChannelException("Channel not properly opened")

            try:
                if want_reply:
                    self._request_success = None
                    self._request_event.clear()

                self._transport._send_channel_request(
                    self._channel_id, request_type, want_reply, data
                )
            except Exception as e:
                raise ChannelException(f"Failed to send channel request: {e}") from e

        if not want_reply:
            return True

        start_time = time.time()
        while True:
            with self._lock:
                if self._request_success is not None:
                    return self._request_success
                if self._closed:
                    raise ChannelException(
                        "Channel closed while waiting for request response"
                    )

            if self._timeout is not None:
                elapsed = time.time() - start_time
                if elapsed >= self._timeout:
                    raise ChannelException(
                        "Timeout waiting for channel request response"
                    )

            has_bg_thread = getattr(self._transport, "_kex_thread", None) is not None

            if has_bg_thread:
                wait_timeout = 0.1
                if self._timeout is not None:
                    elapsed = time.time() - start_time
                    wait_timeout = max(0, min(0.1, self._timeout - elapsed))
                self._request_event.wait(timeout=wait_timeout)
                continue

            try:
                self._transport._pump()
            except Exception as e:
                if "timeout" not in str(e).lower():
                    raise ChannelException(
                        f"Transport error during request: {e}"
                    ) from e

    def send_eof(self) -> None:
        """
        Send EOF to remote side.

        Raises:
            ChannelException: If EOF send fails
        """
        with self._lock:
            if self._closed:
                raise ChannelException("Channel is closed")

            if self._eof_sent:
                return  # Already sent

            if self._remote_channel_id is None:
                raise ChannelException("Channel not properly opened")

            try:
                self._transport._send_channel_eof(self._channel_id)
                self._eof_sent = True
            except Exception as e:
                raise ChannelException(f"Failed to send EOF: {e}") from e

    def recv_stderr(self, nbytes: int) -> bytes:
        """
        Receive stderr data from channel.

        Args:
            nbytes: Maximum bytes to receive

        Returns:
            Received stderr data

        Raises:
            ChannelException: If receive operation fails
        """
        if nbytes <= 0:
            return b""

        start_time = time.time()
        while True:
            with self._lock:
                if self._closed:
                    raise ChannelException("Channel is closed")

                # Check if we have stderr data in buffer
                if self._stderr_buffer:
                    # Get data from buffer
                    data_chunk = self._stderr_buffer.popleft()

                    if len(data_chunk) <= nbytes:
                        # Return entire chunk
                        result = bytes(data_chunk)
                        self._adjust_window(len(result))
                        return result
                    else:
                        # Split chunk and put remainder back
                        result = data_chunk[:nbytes]
                        remainder = data_chunk[nbytes:]
                        self._stderr_buffer.appendleft(remainder)
                        self._adjust_window(len(result))
                        return bytes(result)

                # No stderr data available in buffer
                if self._eof_received:
                    return b""  # EOF reached and buffer is empty

                # Check total timeout
                if self._timeout is not None:
                    elapsed = time.time() - start_time
                    if elapsed >= self._timeout:
                        raise ChannelException("Timeout receiving stderr data")

                # Clear event before we start waiting
                self._data_event.clear()

            # Same fast path as recv(): in sync mode, drive _pump() directly
            # rather than waiting on _data_event (which nothing else sets).
            has_bg_thread = getattr(self._transport, "_kex_thread", None) is not None

            if has_bg_thread:
                wait_timeout = 0.1
                if self._timeout is not None:
                    elapsed = time.time() - start_time
                    wait_timeout = max(0, min(0.1, self._timeout - elapsed))
                self._data_event.wait(timeout=wait_timeout)
                continue

            try:
                if self._timeout is not None:
                    elapsed = time.time() - start_time
                    remaining = self._timeout - elapsed
                    if remaining <= 0:
                        raise ChannelException("Timeout receiving stderr data")

                    has_buffered = bool(getattr(self._transport, "_packet_buffer", b""))
                    if not has_buffered:
                        import select as _select

                        sock = getattr(self._transport, "_socket", None)
                        if sock is not None:
                            try:
                                r, _, _ = _select.select(
                                    [sock], [], [], min(1.0, remaining)
                                )
                                if not r:
                                    continue
                            except Exception as e:
                                self._logger.debug(f"Select error: {e}")

                self._transport._pump()
            except Exception as e:
                if "timeout" not in str(e).lower():
                    raise

    def close(self) -> None:
        """Close channel and cleanup resources."""
        with self._lock:
            if not self._closed:
                self._closed = True
                # Notify transport to close channel
                self._transport._close_channel(self._channel_id)

    def shutdown(self, how: int) -> None:
        """
        Shutdown channel (for socket compatibility).

        Args:
            how: Shutdown type (ignored)
        """
        self.close()

    def __enter__(self) -> "Channel":
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        self.close()

    def _adjust_window(self, bytes_consumed: int) -> None:
        """
        Adjust local window size.

        Args:
            bytes_consumed: Number of bytes consumed from buffer
        """
        with self._lock:
            self._local_window_size -= bytes_consumed

            # Send window adjust if needed.  _send_channel_window_adjust
            # increments _local_window_size itself - do not double-count here.
            if self._local_window_size < DEFAULT_WINDOW_SIZE // 2:
                bytes_to_add = DEFAULT_WINDOW_SIZE - self._local_window_size
                self._transport._send_channel_window_adjust(
                    self._channel_id, bytes_to_add
                )

    def _handle_data(self, data: bytes) -> None:
        """
        Handle incoming data from transport.

        Args:
            data: Received data
        """
        with self._lock:
            if not self._closed:
                self._recv_buffer.append(data)
                self._data_event.set()

    def _handle_extended_data(self, data_type: int, data: bytes) -> None:
        """
        Handle incoming extended data (stderr) from transport.

        Args:
            data_type: Extended data type
            data: Received data
        """
        with self._lock:
            if not self._closed and data_type == 1:  # SSH_EXTENDED_DATA_STDERR
                self._stderr_buffer.append(data)
                self._data_event.set()

    def _handle_eof(self) -> None:
        """Handle EOF from remote side."""
        with self._lock:
            self._eof_received = True
            self._data_event.set()

    def _handle_close(self) -> None:
        """Handle close from remote side."""
        with self._lock:
            self._closed = True
            self._data_event.set()
            self._window_event.set()

    def _handle_window_adjust(self, bytes_to_add: int) -> None:
        """
        Handle window adjust from remote side.

        Args:
            bytes_to_add: Bytes to add to remote window
        """
        with self._lock:
            self._remote_window_size += bytes_to_add
            self._window_event.set()

    def _handle_request_success(self) -> None:
        """Handle request success from remote side."""
        with self._lock:
            self._request_success = True
            self._request_event.set()

    def _handle_request_failure(self) -> None:
        """Handle request failure from remote side."""
        with self._lock:
            self._request_success = False
            self._request_event.set()

    def _handle_exit_status(self, exit_status: int) -> None:
        """
        Handle exit status from remote side.

        Args:
            exit_status: Command exit status
        """
        with self._lock:
            self._exit_status = exit_status
        self._exit_status_event.set()

    def _handle_request(self, request_type: str, data: bytes) -> bool:
        """
        Handle incoming channel request from remote side.

        Args:
            request_type: Type of request (e.g., "shell", "exec")
            data: Request-specific data

        Returns:
            True if request was accepted, False otherwise
        """
        if request_type == "exit-status":
            if len(data) >= 4:
                status, _ = read_uint32(data, 0)
                self._handle_exit_status(status)
            return True

        if request_type == "exit-signal":
            if len(data) >= 4:
                try:
                    offset = 0
                    signal_name_bytes, offset = read_string(data, offset)
                    core_dumped, offset = read_boolean(data, offset)
                    error_msg_bytes, offset = read_string(data, offset)
                    lang_tag_bytes, offset = read_string(data, offset)

                    signal_name = signal_name_bytes.decode(SSH_STRING_ENCODING)
                    error_msg = error_msg_bytes.decode(SSH_STRING_ENCODING)
                    lang_tag = lang_tag_bytes.decode(SSH_STRING_ENCODING)

                    self._handle_exit_signal(
                        signal_name, core_dumped, error_msg, lang_tag
                    )
                except (ProtocolException, UnicodeDecodeError):
                    pass
            return True

        if not self._transport._server_mode or not self._transport._server_interface:
            return False

        server = self._transport._server_interface

        try:
            if request_type == "shell":
                return bool(server.check_channel_shell_request(self))

            elif request_type == "exec":
                command_bytes, _ = read_string(data, 0)
                return bool(server.check_channel_exec_request(self, command_bytes))

            elif request_type == "subsystem":
                subsystem_bytes, _ = read_string(data, 0)
                subsystem = subsystem_bytes.decode(SSH_STRING_ENCODING)
                return bool(server.check_channel_subsystem_request(self, subsystem))

            elif request_type == "pty-req":
                offset = 0
                term_bytes, offset = read_string(data, offset)
                term = term_bytes.decode(SSH_STRING_ENCODING)
                width, offset = read_uint32(data, offset)
                height, offset = read_uint32(data, offset)
                pixelwidth, offset = read_uint32(data, offset)
                pixelheight, offset = read_uint32(data, offset)
                modes, offset = read_string(data, offset)

                return bool(
                    server.check_channel_pty_request(
                        self, term, width, height, pixelwidth, pixelheight, modes
                    )
                )

            elif request_type == "window-change":
                offset = 0
                width, offset = read_uint32(data, offset)
                height, offset = read_uint32(data, offset)
                pixelwidth, offset = read_uint32(data, offset)
                pixelheight, offset = read_uint32(data, offset)

                return bool(
                    server.check_channel_window_change_request(
                        self, width, height, pixelwidth, pixelheight
                    )
                )

            elif request_type == "env":
                offset = 0
                variable_name_bytes, offset = read_string(data, offset)
                variable_value_bytes, offset = read_string(data, offset)
                name = variable_name_bytes.decode(SSH_STRING_ENCODING)
                value = variable_value_bytes.decode(SSH_STRING_ENCODING)

                return bool(server.check_channel_env_request(self, name, value))

            elif request_type == "x11-req":
                offset = 0
                single_connection, offset = read_boolean(data, offset)
                auth_protocol_bytes, offset = read_string(data, offset)
                auth_cookie_bytes, offset = read_string(data, offset)
                screen_number, offset = read_uint32(data, offset)

                auth_protocol = auth_protocol_bytes.decode(SSH_STRING_ENCODING)

                return bool(
                    server.check_channel_x11_request(
                        self,
                        single_connection,
                        auth_protocol,
                        auth_cookie_bytes,
                        screen_number,
                    )
                )

            # Unknown request type
            return False

        except Exception:
            return False

    def _handle_exit_signal(
        self, signal_name: str, core_dumped: bool, error_message: str, language_tag: str
    ) -> None:
        """
        Handle exit signal from remote side.

        Args:
            signal_name: Signal name that caused termination
            core_dumped: Whether core was dumped
            error_message: Error message
            language_tag: Language tag for error message
        """
        with self._lock:
            # Map signal names to numbers (standard SSH signals as per RFC 4254)
            signals = {
                "ABRT": 6,
                "ALRM": 14,
                "FPE": 8,
                "HUP": 1,
                "ILL": 4,
                "INT": 2,
                "KILL": 9,
                "PIPE": 13,
                "QUIT": 3,
                "SEGV": 11,
                "TERM": 15,
                "USR1": 10,
                "USR2": 12,
            }
            # Remove SIG prefix if present (some implementations add it)
            clean_name = signal_name.upper()
            if clean_name.startswith("SIG"):
                clean_name = clean_name[3:]

            signum = signals.get(clean_name, 0)

            # Set exit status to indicate signal termination
            # Convention: 128 + signal number
            self._exit_status = 128 + signum
            self._exit_status_event.set()
            # Store signal info for debugging
            self._exit_signal = {
                "signal_name": signal_name,
                "core_dumped": core_dumped,
                "error_message": error_message,
                "language_tag": language_tag,
            }

    def get_exit_signal(self) -> Optional[dict[str, Any]]:
        """
        Get exit signal information if command was terminated by signal.

        Returns:
            Dictionary with signal info or None
        """
        with self._lock:
            return getattr(self, "_exit_signal", None)

    @property
    def closed(self) -> bool:
        """Check if channel is closed."""
        return self._closed

    @property
    def channel_id(self) -> int:
        """Get channel ID."""
        return self._channel_id

    @property
    def eof_received(self) -> bool:
        """Check if EOF was received."""
        return self._eof_received
Attributes
channel_id property

Get channel ID.

closed property

Check if channel is closed.

eof_received property

Check if EOF was received.

Methods:
__init__(transport, channel_id)

Initialize channel with transport and ID.

Parameters:

Name Type Description Default
transport Any

SSH transport instance

required
channel_id int

Unique channel identifier

required
Source code in spindlex/transport/channel.py
def __init__(self, transport: Any, channel_id: int) -> None:
    """
    Initialize channel with transport and ID.

    Args:
        transport: SSH transport instance
        channel_id: Unique channel identifier
    """
    self._transport = transport
    self._channel_id = channel_id
    self._closed = False
    self._exit_status: Optional[int] = None

    # Remote channel info (set by transport after channel open)
    self._remote_channel_id: Optional[int] = None
    self._remote_window_size = 0
    self._remote_max_packet_size = 0

    # Local channel info
    self._local_window_size = 0
    self._local_max_packet_size = 0

    # Data buffers — declared as Any because AsyncChannel overrides these with
    # plain bytes (flat buffer, sliceable) vs the deque[bytes] used here.
    self._recv_buffer: Any = deque()
    self._stderr_buffer: Any = deque()

    # Flow control
    self._eof_received = False
    self._eof_sent = False

    # Request handling
    self._request_success: Optional[bool] = None

    # Threading
    self._lock = threading.RLock()
    self._data_event = threading.Event()
    self._window_event = threading.Event()
    self._request_event = threading.Event()
    self._exit_status_event = threading.Event()
    self._timeout: Optional[float] = None
    self._logger = logging.getLogger(__name__)
close()

Close channel and cleanup resources.

Source code in spindlex/transport/channel.py
def close(self) -> None:
    """Close channel and cleanup resources."""
    with self._lock:
        if not self._closed:
            self._closed = True
            # Notify transport to close channel
            self._transport._close_channel(self._channel_id)
exec_command(command)

Execute command on channel.

Parameters:

Name Type Description Default
command str

Command to execute

required

Raises:

Type Description
ChannelException

If command execution fails

Source code in spindlex/transport/channel.py
def exec_command(self, command: str) -> None:
    """
    Execute command on channel.

    Args:
        command: Command to execute

    Raises:
        ChannelException: If command execution fails
    """
    if not command:
        raise ChannelException("Command cannot be empty")

    # Build exec request data using SSH string format
    from ..protocol.utils import write_string

    request_data = write_string(command)

    # Send exec request
    success = self.send_channel_request("exec", want_reply=True, data=request_data)

    if not success:
        raise ChannelException(f"Failed to execute command: {command}")
get_exit_signal()

Get exit signal information if command was terminated by signal.

Returns:

Type Description
Optional[dict[str, Any]]

Dictionary with signal info or None

Source code in spindlex/transport/channel.py
def get_exit_signal(self) -> Optional[dict[str, Any]]:
    """
    Get exit signal information if command was terminated by signal.

    Returns:
        Dictionary with signal info or None
    """
    with self._lock:
        return getattr(self, "_exit_signal", None)
get_exit_status()

Get command exit status.

Returns:

Type Description
int

Exit status code, or -1 if not available

Source code in spindlex/transport/channel.py
def get_exit_status(self) -> int:
    """
    Get command exit status.

    Returns:
        Exit status code, or -1 if not available
    """
    return self._exit_status if self._exit_status is not None else -1
gettimeout()

Get channel timeout.

Returns:

Type Description
Optional[float]

Current timeout in seconds

Source code in spindlex/transport/channel.py
def gettimeout(self) -> Optional[float]:
    """
    Get channel timeout.

    Returns:
        Current timeout in seconds
    """
    return self._timeout
invoke_shell()

Start interactive shell on channel.

Raises:

Type Description
ChannelException

If shell invocation fails

Source code in spindlex/transport/channel.py
def invoke_shell(self) -> None:
    """
    Start interactive shell on channel.

    Raises:
        ChannelException: If shell invocation fails
    """

    # Send shell request (no additional data needed)
    success = self.send_channel_request("shell", want_reply=True)

    if not success:
        raise ChannelException("Failed to invoke shell")
invoke_subsystem(subsystem)

Invoke subsystem on channel.

Parameters:

Name Type Description Default
subsystem str

Name of subsystem to invoke (e.g., "sftp")

required

Raises:

Type Description
ChannelException

If subsystem invocation fails

Source code in spindlex/transport/channel.py
def invoke_subsystem(self, subsystem: str) -> None:
    """
    Invoke subsystem on channel.

    Args:
        subsystem: Name of subsystem to invoke (e.g., "sftp")

    Raises:
        ChannelException: If subsystem invocation fails
    """
    if not subsystem:
        raise ChannelException("Subsystem name cannot be empty")

    # Build subsystem request data using SSH string format
    from ..protocol.utils import write_string

    request_data = write_string(subsystem)

    # Send subsystem request
    success = self.send_channel_request(
        "subsystem", want_reply=True, data=request_data
    )

    if not success:
        raise ChannelException(f"Failed to invoke subsystem: {subsystem}")
recv(nbytes)

Receive data from channel.

Parameters:

Name Type Description Default
nbytes int

Maximum bytes to receive

required

Returns:

Type Description
bytes

Received data

Raises:

Type Description
ChannelException

If receive operation fails

Source code in spindlex/transport/channel.py
def recv(self, nbytes: int) -> bytes:
    """
    Receive data from channel.

    Args:
        nbytes: Maximum bytes to receive

    Returns:
        Received data

    Raises:
        ChannelException: If receive operation fails
    """
    if nbytes <= 0:
        return b""

    start_time = time.time()
    while True:
        with self._lock:
            # Check if we have data in buffer
            if self._recv_buffer:
                # Get data from buffer
                data_chunk = self._recv_buffer.popleft()

                if len(data_chunk) <= nbytes:
                    # Return entire chunk
                    result = bytes(data_chunk)
                    self._adjust_window(len(result))
                    return result
                else:
                    # Split chunk and put remainder back
                    result = data_chunk[:nbytes]
                    remainder = data_chunk[nbytes:]
                    self._recv_buffer.appendleft(remainder)
                    self._adjust_window(len(result))
                    return bytes(result)

            # No data available in buffer
            if self._eof_received or not self._transport.active:
                return b""  # EOF reached or transport inactive

            # Check total timeout
            if self._timeout is not None:
                elapsed = time.time() - start_time
                if elapsed >= self._timeout:
                    raise ChannelException("Timeout receiving data")

            # Clear event before we start waiting
            self._data_event.clear()

        # If a background thread is pumping the transport (e.g. during
        # rekey or in async mode), wait for it to deliver data via the
        # event.  Otherwise drive _pump() directly - without this,
        # sync-mode recv() pays 100ms of dead wait time per packet.
        has_bg_thread = getattr(self._transport, "_kex_thread", None) is not None

        if has_bg_thread:
            wait_timeout = 0.1
            if self._timeout is not None:
                elapsed = time.time() - start_time
                wait_timeout = max(0, min(0.1, self._timeout - elapsed))
            self._data_event.wait(timeout=wait_timeout)
            continue

        try:
            # When a channel timeout is active, bound the socket wait via
            # select() so the deadline is honoured.  When there is no
            # channel timeout, _pump() blocks on socket.recv() until a
            # packet arrives - which is what we want.
            if self._timeout is not None:
                elapsed = time.time() - start_time
                remaining = self._timeout - elapsed
                if remaining <= 0:
                    raise ChannelException("Timeout receiving data")

                has_buffered = bool(getattr(self._transport, "_packet_buffer", b""))
                if not has_buffered:
                    import select as _select

                    sock = getattr(self._transport, "_socket", None)
                    if sock is not None:
                        try:
                            r, _, _ = _select.select(
                                [sock], [], [], min(1.0, remaining)
                            )
                            if not r:
                                continue  # no data yet, loop back
                        except Exception as e:
                            self._logger.debug(
                                f"Pump error (expected on close): {e}"
                            )  # fall through to _pump()
            self._transport._pump()
        except socket.timeout:
            pass  # Loop back so the channel-timeout check at the top fires.
recv_exactly(nbytes)

Receive exactly nbytes from channel.

Parameters:

Name Type Description Default
nbytes int

Number of bytes to receive

required

Returns:

Type Description
bytes

Received data

Raises:

Type Description
ChannelException

If receive fails or channel closed

Source code in spindlex/transport/channel.py
def recv_exactly(self, nbytes: int) -> bytes:
    """
    Receive exactly nbytes from channel.

    Args:
        nbytes: Number of bytes to receive

    Returns:
        Received data

    Raises:
        ChannelException: If receive fails or channel closed
    """
    data = b""
    while len(data) < nbytes:
        chunk = self.recv(nbytes - len(data))
        if not chunk:
            raise ChannelException("Connection closed while waiting for data")
        data += chunk
    return data
recv_exit_status(timeout=None)

Wait for and return command exit status.

Parameters:

Name Type Description Default
timeout Optional[float]

Optional timeout in seconds. If None, uses channel timeout.

None

Returns:

Type Description
int

Exit status code

Raises:

Type Description
ChannelException

If timeout reached

Source code in spindlex/transport/channel.py
def recv_exit_status(self, timeout: Optional[float] = None) -> int:
    """
    Wait for and return command exit status.

    Args:
        timeout: Optional timeout in seconds. If None, uses channel timeout.

    Returns:
        Exit status code

    Raises:
        ChannelException: If timeout reached
    """
    effective_timeout = timeout if timeout is not None else self._timeout

    if self._exit_status is not None:
        return self.get_exit_status()

    has_bg_thread = getattr(self._transport, "_kex_thread", None) is not None

    if has_bg_thread:
        signaled = self._exit_status_event.wait(timeout=effective_timeout)
        if not signaled and self._exit_status is None:
            raise ChannelException("Timeout waiting for exit status")
        return self.get_exit_status()

    # No background receive thread - pump until exit status arrives.
    start_time = time.time()
    while self._exit_status is None and not self._closed:
        if effective_timeout is not None:
            elapsed = time.time() - start_time
            if elapsed >= effective_timeout:
                raise ChannelException("Timeout waiting for exit status")
        try:
            self._transport._pump()
        except Exception:
            break

    return self.get_exit_status()
recv_stderr(nbytes)

Receive stderr data from channel.

Parameters:

Name Type Description Default
nbytes int

Maximum bytes to receive

required

Returns:

Type Description
bytes

Received stderr data

Raises:

Type Description
ChannelException

If receive operation fails

Source code in spindlex/transport/channel.py
def recv_stderr(self, nbytes: int) -> bytes:
    """
    Receive stderr data from channel.

    Args:
        nbytes: Maximum bytes to receive

    Returns:
        Received stderr data

    Raises:
        ChannelException: If receive operation fails
    """
    if nbytes <= 0:
        return b""

    start_time = time.time()
    while True:
        with self._lock:
            if self._closed:
                raise ChannelException("Channel is closed")

            # Check if we have stderr data in buffer
            if self._stderr_buffer:
                # Get data from buffer
                data_chunk = self._stderr_buffer.popleft()

                if len(data_chunk) <= nbytes:
                    # Return entire chunk
                    result = bytes(data_chunk)
                    self._adjust_window(len(result))
                    return result
                else:
                    # Split chunk and put remainder back
                    result = data_chunk[:nbytes]
                    remainder = data_chunk[nbytes:]
                    self._stderr_buffer.appendleft(remainder)
                    self._adjust_window(len(result))
                    return bytes(result)

            # No stderr data available in buffer
            if self._eof_received:
                return b""  # EOF reached and buffer is empty

            # Check total timeout
            if self._timeout is not None:
                elapsed = time.time() - start_time
                if elapsed >= self._timeout:
                    raise ChannelException("Timeout receiving stderr data")

            # Clear event before we start waiting
            self._data_event.clear()

        # Same fast path as recv(): in sync mode, drive _pump() directly
        # rather than waiting on _data_event (which nothing else sets).
        has_bg_thread = getattr(self._transport, "_kex_thread", None) is not None

        if has_bg_thread:
            wait_timeout = 0.1
            if self._timeout is not None:
                elapsed = time.time() - start_time
                wait_timeout = max(0, min(0.1, self._timeout - elapsed))
            self._data_event.wait(timeout=wait_timeout)
            continue

        try:
            if self._timeout is not None:
                elapsed = time.time() - start_time
                remaining = self._timeout - elapsed
                if remaining <= 0:
                    raise ChannelException("Timeout receiving stderr data")

                has_buffered = bool(getattr(self._transport, "_packet_buffer", b""))
                if not has_buffered:
                    import select as _select

                    sock = getattr(self._transport, "_socket", None)
                    if sock is not None:
                        try:
                            r, _, _ = _select.select(
                                [sock], [], [], min(1.0, remaining)
                            )
                            if not r:
                                continue
                        except Exception as e:
                            self._logger.debug(f"Select error: {e}")

            self._transport._pump()
        except Exception as e:
            if "timeout" not in str(e).lower():
                raise
request_pty(term='xterm', width=80, height=24, width_pixels=0, height_pixels=0, modes=b'')

Request pseudo-terminal for channel.

Parameters:

Name Type Description Default
term str

Terminal type (e.g., "xterm", "vt100")

'xterm'
width int

Terminal width in characters

80
height int

Terminal height in characters

24
width_pixels int

Terminal width in pixels (0 if unknown)

0
height_pixels int

Terminal height in pixels (0 if unknown)

0
modes bytes

Terminal modes (encoded as per RFC 4254)

b''

Raises:

Type Description
ChannelException

If PTY request fails

Source code in spindlex/transport/channel.py
def request_pty(
    self,
    term: str = "xterm",
    width: int = 80,
    height: int = 24,
    width_pixels: int = 0,
    height_pixels: int = 0,
    modes: bytes = b"",
) -> None:
    """
    Request pseudo-terminal for channel.

    Args:
        term: Terminal type (e.g., "xterm", "vt100")
        width: Terminal width in characters
        height: Terminal height in characters
        width_pixels: Terminal width in pixels (0 if unknown)
        height_pixels: Terminal height in pixels (0 if unknown)
        modes: Terminal modes (encoded as per RFC 4254)

    Raises:
        ChannelException: If PTY request fails
    """
    # Build pty-req request data using SSH protocol format
    from ..protocol.utils import write_string, write_uint32

    request_data = bytearray()

    # Terminal type
    request_data.extend(write_string(term))

    # Terminal dimensions
    request_data.extend(write_uint32(width))
    request_data.extend(write_uint32(height))
    request_data.extend(write_uint32(width_pixels))
    request_data.extend(write_uint32(height_pixels))

    # Terminal modes
    request_data.extend(write_string(modes))

    # Send pty-req request
    success = self.send_channel_request(
        "pty-req", want_reply=True, data=bytes(request_data)
    )

    if not success:
        raise ChannelException("Failed to request PTY")
send(data, timeout=None)

Send data through channel.

Parameters:

Name Type Description Default
data Union[bytes, str]

Data to send (bytes or string)

required
timeout Optional[float]

Optional timeout for this operation (overrides channel timeout)

None

Returns:

Type Description
int

Number of bytes sent

Raises:

Type Description
ChannelException

If send operation fails

Source code in spindlex/transport/channel.py
def send(self, data: Union[bytes, str], timeout: Optional[float] = None) -> int:
    """
    Send data through channel.

    Args:
        data: Data to send (bytes or string)
        timeout: Optional timeout for this operation (overrides channel timeout)

    Returns:
        Number of bytes sent

    Raises:
        ChannelException: If send operation fails
    """
    if not data:
        return 0

    # Convert string to bytes if needed
    if isinstance(data, str):
        data = data.encode(SSH_STRING_ENCODING)

    start_time = time.time()

    # Use effective timeout
    effective_timeout = timeout if timeout is not None else self._timeout

    with self._lock:
        if self._closed:
            raise ChannelException("Channel is closed")

        if self._eof_sent:
            raise ChannelException("EOF already sent on channel")

        if self._remote_channel_id is None:
            raise ChannelException("Channel not properly opened")

        # Wait for window space if it's empty
        while self._remote_window_size <= 0:
            # Check timeout
            if effective_timeout is not None:
                elapsed = time.time() - start_time
                if elapsed >= effective_timeout:
                    raise ChannelException("Timeout waiting for window space")
            # Release lock and wait for window adjust or close
            self._window_event.clear()
            self._lock.release()
            try:
                # If we're waiting for window space, we MUST pump the transport
                # to process any incoming WINDOW_ADJUST messages from the server.
                # Otherwise, we will wait forever in a single-threaded environment.
                if not self._window_event.wait(timeout=0.1):
                    self._transport._pump()
            except socket.timeout:
                pass  # Retry after window adjust
            except Exception as e:
                raise ChannelException(f"Transport error during send: {e}") from e
            finally:
                self._lock.acquire()

            # Re-check channel state after waking up
            if self._closed:
                raise ChannelException("Channel is closed while waiting")

        # We have some window space
        # Send at most one packet (to match standard send() behavior)
        can_send = min(
            len(data), self._remote_window_size, self._remote_max_packet_size
        )

        if can_send <= 0:
            return 0

        chunk = data[:can_send]

        try:
            # Send data through transport
            self._transport._send_channel_data(self._channel_id, chunk)

            # Update remote window size
            self._remote_window_size -= len(chunk)

            return len(chunk)

        except Exception as e:
            raise ChannelException(f"Failed to send data: {e}") from e
send_channel_request(request_type, want_reply=True, data=b'')

Send channel request.

Parameters:

Name Type Description Default
request_type str

Type of request (exec, shell, subsystem, etc.)

required
want_reply bool

Whether to wait for reply

True
data bytes

Request-specific data

b''

Returns:

Type Description
bool

True if request succeeded (when want_reply=True)

Raises:

Type Description
ChannelException

If request fails

Source code in spindlex/transport/channel.py
def send_channel_request(
    self, request_type: str, want_reply: bool = True, data: bytes = b""
) -> bool:
    """
    Send channel request.

    Args:
        request_type: Type of request (exec, shell, subsystem, etc.)
        want_reply: Whether to wait for reply
        data: Request-specific data

    Returns:
        True if request succeeded (when want_reply=True)

    Raises:
        ChannelException: If request fails
    """
    with self._lock:
        if self._closed:
            raise ChannelException("Channel is closed")

        if self._remote_channel_id is None:
            raise ChannelException("Channel not properly opened")

        try:
            if want_reply:
                self._request_success = None
                self._request_event.clear()

            self._transport._send_channel_request(
                self._channel_id, request_type, want_reply, data
            )
        except Exception as e:
            raise ChannelException(f"Failed to send channel request: {e}") from e

    if not want_reply:
        return True

    start_time = time.time()
    while True:
        with self._lock:
            if self._request_success is not None:
                return self._request_success
            if self._closed:
                raise ChannelException(
                    "Channel closed while waiting for request response"
                )

        if self._timeout is not None:
            elapsed = time.time() - start_time
            if elapsed >= self._timeout:
                raise ChannelException(
                    "Timeout waiting for channel request response"
                )

        has_bg_thread = getattr(self._transport, "_kex_thread", None) is not None

        if has_bg_thread:
            wait_timeout = 0.1
            if self._timeout is not None:
                elapsed = time.time() - start_time
                wait_timeout = max(0, min(0.1, self._timeout - elapsed))
            self._request_event.wait(timeout=wait_timeout)
            continue

        try:
            self._transport._pump()
        except Exception as e:
            if "timeout" not in str(e).lower():
                raise ChannelException(
                    f"Transport error during request: {e}"
                ) from e
send_eof()

Send EOF to remote side.

Raises:

Type Description
ChannelException

If EOF send fails

Source code in spindlex/transport/channel.py
def send_eof(self) -> None:
    """
    Send EOF to remote side.

    Raises:
        ChannelException: If EOF send fails
    """
    with self._lock:
        if self._closed:
            raise ChannelException("Channel is closed")

        if self._eof_sent:
            return  # Already sent

        if self._remote_channel_id is None:
            raise ChannelException("Channel not properly opened")

        try:
            self._transport._send_channel_eof(self._channel_id)
            self._eof_sent = True
        except Exception as e:
            raise ChannelException(f"Failed to send EOF: {e}") from e
send_exit_status(status)

Send command exit status to remote side.

Parameters:

Name Type Description Default
status int

Exit status code (typically 0 for success)

required

Raises:

Type Description
ChannelException

If send fails

Source code in spindlex/transport/channel.py
def send_exit_status(self, status: int) -> None:
    """
    Send command exit status to remote side.

    Args:
        status: Exit status code (typically 0 for success)

    Raises:
        ChannelException: If send fails
    """
    from ..protocol.utils import write_uint32

    # Build exit-status request data (4-byte unsigned integer)
    request_data = write_uint32(status)

    # Send exit-status request (no reply wanted for this type)
    self.send_channel_request("exit-status", want_reply=False, data=request_data)
sendall(data, timeout=None)

Send all data through channel, retrying until all sent.

Parameters:

Name Type Description Default
data Union[bytes, str]

Data to send

required
timeout Optional[float]

Optional timeout

None
Source code in spindlex/transport/channel.py
def sendall(self, data: Union[bytes, str], timeout: Optional[float] = None) -> None:
    """
    Send all data through channel, retrying until all sent.

    Args:
        data: Data to send
        timeout: Optional timeout
    """
    if isinstance(data, str):
        data = data.encode(SSH_STRING_ENCODING)

    total_sent = 0
    while total_sent < len(data):
        sent = self.send(data[total_sent:], timeout=timeout)
        if sent <= 0:
            raise ChannelException("Failed to send data")
        total_sent += sent
settimeout(timeout)

Set timeout for channel operations.

Parameters:

Name Type Description Default
timeout Optional[float]

Timeout in seconds, or None for no timeout

required
Source code in spindlex/transport/channel.py
def settimeout(self, timeout: Optional[float]) -> None:
    """
    Set timeout for channel operations.

    Args:
        timeout: Timeout in seconds, or None for no timeout
    """
    self._timeout = timeout
shutdown(how)

Shutdown channel (for socket compatibility).

Parameters:

Name Type Description Default
how int

Shutdown type (ignored)

required
Source code in spindlex/transport/channel.py
def shutdown(self, how: int) -> None:
    """
    Shutdown channel (for socket compatibility).

    Args:
        how: Shutdown type (ignored)
    """
    self.close()

Functions:

spindlex.transport.async_channel

Async SSH Channel Implementation

Provides asynchronous SSH channel functionality for command execution and data transfer.

Classes

AsyncChannel

Bases: Channel

Async SSH channel for command execution and data transfer.

Extends the base Channel class to provide asynchronous operations for use in async/await applications and high-concurrency scenarios.

Source code in spindlex/transport/async_channel.py
 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
class AsyncChannel(Channel):
    """
    Async SSH channel for command execution and data transfer.

    Extends the base Channel class to provide asynchronous operations
    for use in async/await applications and high-concurrency scenarios.
    """

    def __init__(self, transport: Any, channel_id: int) -> None:
        """
        Initialize async channel.

        Args:
            transport: Async transport instance
            channel_id: Local channel ID
        """
        super().__init__(transport, channel_id)
        self._send_queue: asyncio.Queue[Any] = asyncio.Queue()
        self._recv_queue: asyncio.Queue[Any] = asyncio.Queue()
        self._closed_event = asyncio.Event()

        # Override parent's deque buffers with flat bytes for async I/O path
        self._recv_buffer = b""
        self._stderr_buffer = b""
        self._buffer_lock = threading.Lock()

    def _handle_close(self) -> None:
        """Handle incoming channel-close message.

        Overrides the base-class implementation to avoid calling _send_message()
        synchronously when this method is invoked from the event-loop thread
        (via the native async receive path).  Sends the EOF+CLOSE handshake as
        a scheduled coroutine instead.
        """
        self._closed = True
        self._closed_event.set()
        loop = getattr(self._transport, "_loop", None)
        if loop:
            try:
                asyncio.get_running_loop()
                # On the event-loop thread - schedule as a fire-and-forget task.
                asyncio.ensure_future(self._async_close_handshake())
            except RuntimeError:
                # Called from a worker thread - safe to use run_coroutine_threadsafe.
                asyncio.run_coroutine_threadsafe(self._async_close_handshake(), loop)
        else:
            # Sync transport fallback.
            if not self._eof_sent:
                self.send_eof()
            self._transport._close_channel(self._channel_id)

    async def _async_close_handshake(self) -> None:
        """Send EOF + CLOSE responses for an incoming channel-close message."""
        try:
            if not self._eof_sent:
                await self._transport._send_channel_eof_async(self._channel_id)
            await self._transport._send_channel_close_async(self._channel_id)
        except Exception:  # nosec B110
            pass  # Best-effort; connection may already be gone.

    def _handle_data(self, data: bytes) -> None:
        """Handle incoming channel data."""
        with self._buffer_lock:
            self._recv_buffer += data

    def _handle_extended_data(self, data_type: int, data: bytes) -> None:
        """Handle incoming channel extended data."""
        if data_type == SSH_EXTENDED_DATA_STDERR:
            with self._buffer_lock:
                self._stderr_buffer += data

    def _handle_eof(self) -> None:
        """Handle incoming channel EOF."""
        self._eof_received = True

    async def send(self, data: Union[bytes, str]) -> int:  # type: ignore[override]
        """
        Send data through channel asynchronously.

        Args:
            data: Data to send (bytes or string)

        Returns:
            Number of bytes sent

        Raises:
            ChannelException: If send fails
        """
        if self.closed:
            raise ChannelException("Channel is closed")

        if not data:
            return 0

        # Convert string to bytes if needed
        if isinstance(data, str):
            from ..protocol.constants import SSH_STRING_ENCODING

            data = data.encode(SSH_STRING_ENCODING)

        total_sent = 0

        try:
            # Check if we have enough window space
            while len(data) > 0:
                if self._remote_window_size == 0:
                    # Wait for window adjustment by pumping the transport
                    await self._transport._pump_async()
                    continue

                # Send what we can fit in the window and max packet size
                chunk_size = min(
                    len(data), self._remote_window_size, self._remote_max_packet_size
                )
                if (
                    chunk_size == 0
                ):  # Should be handled by self._remote_window_size == 0 check above but just in case
                    await self._transport._pump_async()
                    continue

                chunk = data[:chunk_size]
                await self._transport._send_channel_data_async(self._channel_id, chunk)

                data = data[chunk_size:]
                self._remote_window_size -= chunk_size
                total_sent += chunk_size

            return total_sent

        except Exception as e:
            if isinstance(e, ChannelException):
                raise
            raise ChannelException(f"Send failed: {e}") from e

    async def sendall(self, data: Union[bytes, str]) -> None:  # type: ignore[override]
        """
        Send all data through channel asynchronously.

        Args:
            data: Data to send
        """
        await self.send(data)

    async def recv(self, nbytes: int) -> bytes:  # type: ignore[override]
        """
        Receive data from channel asynchronously.

        Args:
            nbytes: Maximum number of bytes to receive

        Returns:
            Received data

        Raises:
            ChannelException: If receive fails
        """
        try:
            # Wait for data or channel close
            while True:
                with self._buffer_lock:
                    if self._recv_buffer:
                        # Return available data
                        if nbytes <= 0:
                            data = self._recv_buffer
                            self._recv_buffer = b""
                        else:
                            data = self._recv_buffer[:nbytes]
                            self._recv_buffer = self._recv_buffer[nbytes:]

                        bytes_read = bytes(data)

                    else:
                        bytes_read = None

                if bytes_read is not None:
                    if len(bytes_read) > 0:
                        await self._adjust_window_async(len(bytes_read))
                    return bytes_read

                if self.eof_received:
                    return b""

                if self.closed:
                    raise ChannelException("Channel is closed")

                # Wait for more data by pumping the transport
                await self._transport._pump_async()

        except Exception as e:
            if isinstance(e, ChannelException):
                raise
            raise ChannelException(f"Receive failed: {e}") from e

    async def recv_exactly(self, nbytes: int) -> bytes:  # type: ignore[override]
        """
        Receive exactly nbytes from channel asynchronously.

        Args:
            nbytes: Number of bytes to receive

        Returns:
            Received data

        Raises:
            ChannelException: If receive fails or channel closed
        """
        data = b""
        while len(data) < nbytes:
            chunk = await self.recv(nbytes - len(data))
            if not chunk:
                raise ChannelException("Connection closed while waiting for data")
            data += chunk
        return data

    async def recv_stderr(self, nbytes: int) -> bytes:  # type: ignore[override]
        """
        Receive stderr data from channel asynchronously.

        Args:
            nbytes: Maximum number of bytes to receive

        Returns:
            Received data

        Raises:
            ChannelException: If receive fails
        """
        if self.closed and not self._stderr_buffer:
            raise ChannelException("Channel is closed")

        try:
            # Wait for data or channel close
            while True:
                with self._buffer_lock:
                    if self._stderr_buffer:
                        # Return available data
                        if nbytes <= 0:
                            data = self._stderr_buffer
                            self._stderr_buffer = b""
                        else:
                            data = self._stderr_buffer[:nbytes]
                            self._stderr_buffer = self._stderr_buffer[nbytes:]

                        bytes_read = bytes(data)
                    else:
                        bytes_read = None

                if bytes_read is not None:
                    if len(bytes_read) > 0:
                        await self._adjust_window_async(len(bytes_read))
                    return bytes_read

                if not self._stderr_buffer and self.eof_received:
                    return b""

                # Wait for more data by pumping the transport
                await self._transport._pump_async()

        except Exception as e:
            if isinstance(e, ChannelException):
                raise
            raise ChannelException(f"Receive failed: {e}") from e

    async def _wait_for_channel_request_result(self) -> bool:
        """Pump until MSG_CHANNEL_SUCCESS/FAILURE is dispatched to this channel.
        Returns True on success, False on failure."""
        self._request_event.clear()
        while not self._request_event.is_set():
            await self._transport._pump_async()
        return bool(self._request_success)

    async def exec_command(self, command: str) -> None:  # type: ignore[override]
        """
        Execute command on channel asynchronously.

        Args:
            command: Command to execute

        Raises:
            ChannelException: If command execution fails
        """
        if self.closed:
            raise ChannelException("Channel is closed")

        try:
            request_data = bytearray()
            request_data.extend(write_string(command))
            await self._transport._send_channel_request_async(
                self._channel_id, "exec", True, bytes(request_data)
            )
            if not await self._wait_for_channel_request_result():
                raise ChannelException(f"Command execution failed: {command}")

        except Exception as e:
            if isinstance(e, ChannelException):
                raise
            raise ChannelException(f"Command execution failed: {e}") from e

    async def invoke_shell(self) -> None:  # type: ignore[override]
        """
        Invoke shell on channel asynchronously.

        Raises:
            ChannelException: If shell invocation fails
        """
        if self.closed:
            raise ChannelException("Channel is closed")

        try:
            await self._transport._send_channel_request_async(
                self._channel_id, "shell", True, b""
            )
            if not await self._wait_for_channel_request_result():
                raise ChannelException("Shell invocation failed")

        except Exception as e:
            if isinstance(e, ChannelException):
                raise
            raise ChannelException(f"Shell invocation failed: {e}") from e

    async def invoke_subsystem(self, subsystem: str) -> None:  # type: ignore[override]
        """
        Invoke subsystem on channel asynchronously.

        Args:
            subsystem: Subsystem name (e.g., "sftp")

        Raises:
            ChannelException: If subsystem invocation fails
        """
        if self.closed:
            raise ChannelException("Channel is closed")

        try:
            request_data = bytearray()
            request_data.extend(write_string(subsystem))
            await self._transport._send_channel_request_async(
                self._channel_id, "subsystem", True, bytes(request_data)
            )
            if not await self._wait_for_channel_request_result():
                raise ChannelException(f"Subsystem invocation failed: {subsystem}")

        except Exception as e:
            if isinstance(e, ChannelException):
                raise
            raise ChannelException(f"Subsystem invocation failed: {e}") from e

    async def send_exit_status(self, status: int) -> None:  # type: ignore[override]
        """
        Send command exit status to remote side asynchronously.

        Args:
            status: Exit status code (typically 0 for success)

        Raises:
            ChannelException: If send fails
        """
        from ..protocol.utils import write_uint32

        try:
            # Build exit-status request data (4-byte unsigned integer)
            request_data = write_uint32(status)

            # Send exit-status request (no reply wanted for this type)
            await self._transport._send_channel_request_async(
                self._channel_id, "exit-status", False, request_data
            )
        except Exception as e:
            raise ChannelException(f"Failed to send exit status: {e}") from e

    async def recv_exit_status(self) -> int:  # type: ignore[override]
        """
        Wait for and return command exit status asynchronously.

        Returns:
            Exit status code
        """
        while self._exit_status is None and not self._closed:
            try:
                await self._transport._pump_async()
            except Exception:
                break
        return self.get_exit_status()

    async def close(self) -> None:  # type: ignore[override]
        """Close channel asynchronously."""
        if not self._closed:
            try:
                # Send EOF first
                await self._transport._send_channel_eof_async(self._channel_id)

                # Send close
                await self._transport._send_channel_close_async(self._channel_id)

            except Exception as e:
                self._logger.debug(f"Error during async channel close: {e}")
            finally:  # Remove from transport
                if (
                    hasattr(self._transport, "_channels")
                    and self._channel_id in self._transport._channels
                ):
                    async with getattr(self._transport, "_state_lock", asyncio.Lock()):
                        if self._channel_id in self._transport._channels:
                            del self._transport._channels[self._channel_id]

                self._closed = True
                self._closed_event.set()

    async def wait_closed(self) -> None:
        """Wait for channel to be closed."""
        await self._closed_event.wait()

    async def _adjust_window_async(self, bytes_consumed: int) -> None:
        """
        Adjust local window size asynchronously.

        Args:
            bytes_consumed: Number of bytes consumed from buffer
        """
        self._local_window_size -= bytes_consumed

        # Send window adjust if needed
        if self._local_window_size < DEFAULT_WINDOW_SIZE // 2:
            bytes_to_add = DEFAULT_WINDOW_SIZE - self._local_window_size
            await self._transport._send_channel_window_adjust_async(
                self._channel_id, bytes_to_add
            )
            self._local_window_size += bytes_to_add

    def makefile(self, mode: str = "r", bufsize: int = -1) -> Any:
        """
        Create file-like object for channel.

        Args:
            mode: File mode
            bufsize: Buffer size

        Returns:
            File-like object for channel
        """
        return AsyncChannelFile(self, mode, bufsize)

    def makefile_stderr(self, mode: str = "r", bufsize: int = -1) -> Any:
        """
        Create file-like object for channel stderr.

        Args:
            mode: File mode
            bufsize: Buffer size

        Returns:
            File-like object for channel stderr
        """
        return AsyncChannelFile(self, mode, bufsize, is_stderr=True)
Methods:
__init__(transport, channel_id)

Initialize async channel.

Parameters:

Name Type Description Default
transport Any

Async transport instance

required
channel_id int

Local channel ID

required
Source code in spindlex/transport/async_channel.py
def __init__(self, transport: Any, channel_id: int) -> None:
    """
    Initialize async channel.

    Args:
        transport: Async transport instance
        channel_id: Local channel ID
    """
    super().__init__(transport, channel_id)
    self._send_queue: asyncio.Queue[Any] = asyncio.Queue()
    self._recv_queue: asyncio.Queue[Any] = asyncio.Queue()
    self._closed_event = asyncio.Event()

    # Override parent's deque buffers with flat bytes for async I/O path
    self._recv_buffer = b""
    self._stderr_buffer = b""
    self._buffer_lock = threading.Lock()
close() async

Close channel asynchronously.

Source code in spindlex/transport/async_channel.py
async def close(self) -> None:  # type: ignore[override]
    """Close channel asynchronously."""
    if not self._closed:
        try:
            # Send EOF first
            await self._transport._send_channel_eof_async(self._channel_id)

            # Send close
            await self._transport._send_channel_close_async(self._channel_id)

        except Exception as e:
            self._logger.debug(f"Error during async channel close: {e}")
        finally:  # Remove from transport
            if (
                hasattr(self._transport, "_channels")
                and self._channel_id in self._transport._channels
            ):
                async with getattr(self._transport, "_state_lock", asyncio.Lock()):
                    if self._channel_id in self._transport._channels:
                        del self._transport._channels[self._channel_id]

            self._closed = True
            self._closed_event.set()
exec_command(command) async

Execute command on channel asynchronously.

Parameters:

Name Type Description Default
command str

Command to execute

required

Raises:

Type Description
ChannelException

If command execution fails

Source code in spindlex/transport/async_channel.py
async def exec_command(self, command: str) -> None:  # type: ignore[override]
    """
    Execute command on channel asynchronously.

    Args:
        command: Command to execute

    Raises:
        ChannelException: If command execution fails
    """
    if self.closed:
        raise ChannelException("Channel is closed")

    try:
        request_data = bytearray()
        request_data.extend(write_string(command))
        await self._transport._send_channel_request_async(
            self._channel_id, "exec", True, bytes(request_data)
        )
        if not await self._wait_for_channel_request_result():
            raise ChannelException(f"Command execution failed: {command}")

    except Exception as e:
        if isinstance(e, ChannelException):
            raise
        raise ChannelException(f"Command execution failed: {e}") from e
invoke_shell() async

Invoke shell on channel asynchronously.

Raises:

Type Description
ChannelException

If shell invocation fails

Source code in spindlex/transport/async_channel.py
async def invoke_shell(self) -> None:  # type: ignore[override]
    """
    Invoke shell on channel asynchronously.

    Raises:
        ChannelException: If shell invocation fails
    """
    if self.closed:
        raise ChannelException("Channel is closed")

    try:
        await self._transport._send_channel_request_async(
            self._channel_id, "shell", True, b""
        )
        if not await self._wait_for_channel_request_result():
            raise ChannelException("Shell invocation failed")

    except Exception as e:
        if isinstance(e, ChannelException):
            raise
        raise ChannelException(f"Shell invocation failed: {e}") from e
invoke_subsystem(subsystem) async

Invoke subsystem on channel asynchronously.

Parameters:

Name Type Description Default
subsystem str

Subsystem name (e.g., "sftp")

required

Raises:

Type Description
ChannelException

If subsystem invocation fails

Source code in spindlex/transport/async_channel.py
async def invoke_subsystem(self, subsystem: str) -> None:  # type: ignore[override]
    """
    Invoke subsystem on channel asynchronously.

    Args:
        subsystem: Subsystem name (e.g., "sftp")

    Raises:
        ChannelException: If subsystem invocation fails
    """
    if self.closed:
        raise ChannelException("Channel is closed")

    try:
        request_data = bytearray()
        request_data.extend(write_string(subsystem))
        await self._transport._send_channel_request_async(
            self._channel_id, "subsystem", True, bytes(request_data)
        )
        if not await self._wait_for_channel_request_result():
            raise ChannelException(f"Subsystem invocation failed: {subsystem}")

    except Exception as e:
        if isinstance(e, ChannelException):
            raise
        raise ChannelException(f"Subsystem invocation failed: {e}") from e
makefile(mode='r', bufsize=-1)

Create file-like object for channel.

Parameters:

Name Type Description Default
mode str

File mode

'r'
bufsize int

Buffer size

-1

Returns:

Type Description
Any

File-like object for channel

Source code in spindlex/transport/async_channel.py
def makefile(self, mode: str = "r", bufsize: int = -1) -> Any:
    """
    Create file-like object for channel.

    Args:
        mode: File mode
        bufsize: Buffer size

    Returns:
        File-like object for channel
    """
    return AsyncChannelFile(self, mode, bufsize)
makefile_stderr(mode='r', bufsize=-1)

Create file-like object for channel stderr.

Parameters:

Name Type Description Default
mode str

File mode

'r'
bufsize int

Buffer size

-1

Returns:

Type Description
Any

File-like object for channel stderr

Source code in spindlex/transport/async_channel.py
def makefile_stderr(self, mode: str = "r", bufsize: int = -1) -> Any:
    """
    Create file-like object for channel stderr.

    Args:
        mode: File mode
        bufsize: Buffer size

    Returns:
        File-like object for channel stderr
    """
    return AsyncChannelFile(self, mode, bufsize, is_stderr=True)
recv(nbytes) async

Receive data from channel asynchronously.

Parameters:

Name Type Description Default
nbytes int

Maximum number of bytes to receive

required

Returns:

Type Description
bytes

Received data

Raises:

Type Description
ChannelException

If receive fails

Source code in spindlex/transport/async_channel.py
async def recv(self, nbytes: int) -> bytes:  # type: ignore[override]
    """
    Receive data from channel asynchronously.

    Args:
        nbytes: Maximum number of bytes to receive

    Returns:
        Received data

    Raises:
        ChannelException: If receive fails
    """
    try:
        # Wait for data or channel close
        while True:
            with self._buffer_lock:
                if self._recv_buffer:
                    # Return available data
                    if nbytes <= 0:
                        data = self._recv_buffer
                        self._recv_buffer = b""
                    else:
                        data = self._recv_buffer[:nbytes]
                        self._recv_buffer = self._recv_buffer[nbytes:]

                    bytes_read = bytes(data)

                else:
                    bytes_read = None

            if bytes_read is not None:
                if len(bytes_read) > 0:
                    await self._adjust_window_async(len(bytes_read))
                return bytes_read

            if self.eof_received:
                return b""

            if self.closed:
                raise ChannelException("Channel is closed")

            # Wait for more data by pumping the transport
            await self._transport._pump_async()

    except Exception as e:
        if isinstance(e, ChannelException):
            raise
        raise ChannelException(f"Receive failed: {e}") from e
recv_exactly(nbytes) async

Receive exactly nbytes from channel asynchronously.

Parameters:

Name Type Description Default
nbytes int

Number of bytes to receive

required

Returns:

Type Description
bytes

Received data

Raises:

Type Description
ChannelException

If receive fails or channel closed

Source code in spindlex/transport/async_channel.py
async def recv_exactly(self, nbytes: int) -> bytes:  # type: ignore[override]
    """
    Receive exactly nbytes from channel asynchronously.

    Args:
        nbytes: Number of bytes to receive

    Returns:
        Received data

    Raises:
        ChannelException: If receive fails or channel closed
    """
    data = b""
    while len(data) < nbytes:
        chunk = await self.recv(nbytes - len(data))
        if not chunk:
            raise ChannelException("Connection closed while waiting for data")
        data += chunk
    return data
recv_exit_status() async

Wait for and return command exit status asynchronously.

Returns:

Type Description
int

Exit status code

Source code in spindlex/transport/async_channel.py
async def recv_exit_status(self) -> int:  # type: ignore[override]
    """
    Wait for and return command exit status asynchronously.

    Returns:
        Exit status code
    """
    while self._exit_status is None and not self._closed:
        try:
            await self._transport._pump_async()
        except Exception:
            break
    return self.get_exit_status()
recv_stderr(nbytes) async

Receive stderr data from channel asynchronously.

Parameters:

Name Type Description Default
nbytes int

Maximum number of bytes to receive

required

Returns:

Type Description
bytes

Received data

Raises:

Type Description
ChannelException

If receive fails

Source code in spindlex/transport/async_channel.py
async def recv_stderr(self, nbytes: int) -> bytes:  # type: ignore[override]
    """
    Receive stderr data from channel asynchronously.

    Args:
        nbytes: Maximum number of bytes to receive

    Returns:
        Received data

    Raises:
        ChannelException: If receive fails
    """
    if self.closed and not self._stderr_buffer:
        raise ChannelException("Channel is closed")

    try:
        # Wait for data or channel close
        while True:
            with self._buffer_lock:
                if self._stderr_buffer:
                    # Return available data
                    if nbytes <= 0:
                        data = self._stderr_buffer
                        self._stderr_buffer = b""
                    else:
                        data = self._stderr_buffer[:nbytes]
                        self._stderr_buffer = self._stderr_buffer[nbytes:]

                    bytes_read = bytes(data)
                else:
                    bytes_read = None

            if bytes_read is not None:
                if len(bytes_read) > 0:
                    await self._adjust_window_async(len(bytes_read))
                return bytes_read

            if not self._stderr_buffer and self.eof_received:
                return b""

            # Wait for more data by pumping the transport
            await self._transport._pump_async()

    except Exception as e:
        if isinstance(e, ChannelException):
            raise
        raise ChannelException(f"Receive failed: {e}") from e
send(data) async

Send data through channel asynchronously.

Parameters:

Name Type Description Default
data Union[bytes, str]

Data to send (bytes or string)

required

Returns:

Type Description
int

Number of bytes sent

Raises:

Type Description
ChannelException

If send fails

Source code in spindlex/transport/async_channel.py
async def send(self, data: Union[bytes, str]) -> int:  # type: ignore[override]
    """
    Send data through channel asynchronously.

    Args:
        data: Data to send (bytes or string)

    Returns:
        Number of bytes sent

    Raises:
        ChannelException: If send fails
    """
    if self.closed:
        raise ChannelException("Channel is closed")

    if not data:
        return 0

    # Convert string to bytes if needed
    if isinstance(data, str):
        from ..protocol.constants import SSH_STRING_ENCODING

        data = data.encode(SSH_STRING_ENCODING)

    total_sent = 0

    try:
        # Check if we have enough window space
        while len(data) > 0:
            if self._remote_window_size == 0:
                # Wait for window adjustment by pumping the transport
                await self._transport._pump_async()
                continue

            # Send what we can fit in the window and max packet size
            chunk_size = min(
                len(data), self._remote_window_size, self._remote_max_packet_size
            )
            if (
                chunk_size == 0
            ):  # Should be handled by self._remote_window_size == 0 check above but just in case
                await self._transport._pump_async()
                continue

            chunk = data[:chunk_size]
            await self._transport._send_channel_data_async(self._channel_id, chunk)

            data = data[chunk_size:]
            self._remote_window_size -= chunk_size
            total_sent += chunk_size

        return total_sent

    except Exception as e:
        if isinstance(e, ChannelException):
            raise
        raise ChannelException(f"Send failed: {e}") from e
send_exit_status(status) async

Send command exit status to remote side asynchronously.

Parameters:

Name Type Description Default
status int

Exit status code (typically 0 for success)

required

Raises:

Type Description
ChannelException

If send fails

Source code in spindlex/transport/async_channel.py
async def send_exit_status(self, status: int) -> None:  # type: ignore[override]
    """
    Send command exit status to remote side asynchronously.

    Args:
        status: Exit status code (typically 0 for success)

    Raises:
        ChannelException: If send fails
    """
    from ..protocol.utils import write_uint32

    try:
        # Build exit-status request data (4-byte unsigned integer)
        request_data = write_uint32(status)

        # Send exit-status request (no reply wanted for this type)
        await self._transport._send_channel_request_async(
            self._channel_id, "exit-status", False, request_data
        )
    except Exception as e:
        raise ChannelException(f"Failed to send exit status: {e}") from e
sendall(data) async

Send all data through channel asynchronously.

Parameters:

Name Type Description Default
data Union[bytes, str]

Data to send

required
Source code in spindlex/transport/async_channel.py
async def sendall(self, data: Union[bytes, str]) -> None:  # type: ignore[override]
    """
    Send all data through channel asynchronously.

    Args:
        data: Data to send
    """
    await self.send(data)
wait_closed() async

Wait for channel to be closed.

Source code in spindlex/transport/async_channel.py
async def wait_closed(self) -> None:
    """Wait for channel to be closed."""
    await self._closed_event.wait()

AsyncChannelFile

Async file-like object for SSH channel operations.

Provides a file-like interface for reading from and writing to SSH channels in asynchronous applications.

Source code in spindlex/transport/async_channel.py
class AsyncChannelFile:
    """
    Async file-like object for SSH channel operations.

    Provides a file-like interface for reading from and writing to
    SSH channels in asynchronous applications.
    """

    def __init__(
        self,
        channel: AsyncChannel,
        mode: str = "r",
        bufsize: int = -1,
        is_stderr: bool = False,
    ) -> None:
        """
        Initialize async channel file.

        Args:
            channel: Async channel instance
            mode: File mode
            bufsize: Buffer size
            is_stderr: Whether this file object is for stderr
        """
        self._channel = channel
        self._mode = mode
        self._bufsize = bufsize
        self._is_stderr = is_stderr
        self._closed = False

    async def read(self, size: int = -1) -> bytes:
        """
        Read data from channel asynchronously.

        Args:
            size: Number of bytes to read

        Returns:
            Read data
        """
        if self._closed:
            raise ValueError("I/O operation on closed file")

        if size == 0:
            return b""

        res = b""
        while True:
            # How many bytes to request in this iteration
            if size < 0:
                chunk_size = -1
            else:
                chunk_size = size - len(res)
                if chunk_size == 0:
                    break

            if self._is_stderr:
                chunk = await self._channel.recv_stderr(chunk_size)
            else:
                chunk = await self._channel.recv(chunk_size)

            if not chunk:
                break

            res += chunk

        return res

    def get_exit_status(self) -> int:
        """
        Get command exit status.

        Returns:
            Exit status code, or -1 if not available
        """
        return self._channel.get_exit_status()

    async def recv_exit_status(self) -> int:
        """
        Wait for and return command exit status asynchronously.

        Returns:
            Exit status code
        """
        return await self._channel.recv_exit_status()

    def __aiter__(self) -> "AsyncChannelFile":
        """
        Make object async iterable for line-by-line reading.

        Returns:
            Self as async iterator
        """
        return self

    async def __anext__(self) -> str:
        """
        Read next line from channel asynchronously.

        Returns:
            Next line of data

        Raises:
            StopAsyncIteration: If EOF reached
        """
        line = await self.readline()
        if not line:
            raise StopAsyncIteration
        return line

    async def readline(self) -> str:
        """
        Read a single line from the channel asynchronously.

        Returns:
            Read line
        """
        result = bytearray()
        while True:
            char = await self.read(1)
            if not char:
                break
            result.extend(char)
            if char == b"\n":
                break
        return result.decode("utf-8", errors="replace")

    async def write(self, data: bytes) -> int:
        """
        Write data to channel asynchronously.

        Args:
            data: Data to write

        Returns:
            Number of bytes written
        """
        if self._closed:
            raise ValueError("I/O operation on closed file")

        return await self._channel.send(data)

    @property
    def channel(self) -> "AsyncChannel":
        """Get underlying SSH channel."""
        return self._channel

    async def close(self) -> None:
        """Close file object."""
        if not self._closed:
            self._closed = True

    def closed(self) -> bool:
        """Check if file is closed."""
        return self._closed
Attributes
channel property

Get underlying SSH channel.

Methods:
__aiter__()

Make object async iterable for line-by-line reading.

Returns:

Type Description
AsyncChannelFile

Self as async iterator

Source code in spindlex/transport/async_channel.py
def __aiter__(self) -> "AsyncChannelFile":
    """
    Make object async iterable for line-by-line reading.

    Returns:
        Self as async iterator
    """
    return self
__anext__() async

Read next line from channel asynchronously.

Returns:

Type Description
str

Next line of data

Raises:

Type Description
StopAsyncIteration

If EOF reached

Source code in spindlex/transport/async_channel.py
async def __anext__(self) -> str:
    """
    Read next line from channel asynchronously.

    Returns:
        Next line of data

    Raises:
        StopAsyncIteration: If EOF reached
    """
    line = await self.readline()
    if not line:
        raise StopAsyncIteration
    return line
__init__(channel, mode='r', bufsize=-1, is_stderr=False)

Initialize async channel file.

Parameters:

Name Type Description Default
channel AsyncChannel

Async channel instance

required
mode str

File mode

'r'
bufsize int

Buffer size

-1
is_stderr bool

Whether this file object is for stderr

False
Source code in spindlex/transport/async_channel.py
def __init__(
    self,
    channel: AsyncChannel,
    mode: str = "r",
    bufsize: int = -1,
    is_stderr: bool = False,
) -> None:
    """
    Initialize async channel file.

    Args:
        channel: Async channel instance
        mode: File mode
        bufsize: Buffer size
        is_stderr: Whether this file object is for stderr
    """
    self._channel = channel
    self._mode = mode
    self._bufsize = bufsize
    self._is_stderr = is_stderr
    self._closed = False
close() async

Close file object.

Source code in spindlex/transport/async_channel.py
async def close(self) -> None:
    """Close file object."""
    if not self._closed:
        self._closed = True
closed()

Check if file is closed.

Source code in spindlex/transport/async_channel.py
def closed(self) -> bool:
    """Check if file is closed."""
    return self._closed
get_exit_status()

Get command exit status.

Returns:

Type Description
int

Exit status code, or -1 if not available

Source code in spindlex/transport/async_channel.py
def get_exit_status(self) -> int:
    """
    Get command exit status.

    Returns:
        Exit status code, or -1 if not available
    """
    return self._channel.get_exit_status()
read(size=-1) async

Read data from channel asynchronously.

Parameters:

Name Type Description Default
size int

Number of bytes to read

-1

Returns:

Type Description
bytes

Read data

Source code in spindlex/transport/async_channel.py
async def read(self, size: int = -1) -> bytes:
    """
    Read data from channel asynchronously.

    Args:
        size: Number of bytes to read

    Returns:
        Read data
    """
    if self._closed:
        raise ValueError("I/O operation on closed file")

    if size == 0:
        return b""

    res = b""
    while True:
        # How many bytes to request in this iteration
        if size < 0:
            chunk_size = -1
        else:
            chunk_size = size - len(res)
            if chunk_size == 0:
                break

        if self._is_stderr:
            chunk = await self._channel.recv_stderr(chunk_size)
        else:
            chunk = await self._channel.recv(chunk_size)

        if not chunk:
            break

        res += chunk

    return res
readline() async

Read a single line from the channel asynchronously.

Returns:

Type Description
str

Read line

Source code in spindlex/transport/async_channel.py
async def readline(self) -> str:
    """
    Read a single line from the channel asynchronously.

    Returns:
        Read line
    """
    result = bytearray()
    while True:
        char = await self.read(1)
        if not char:
            break
        result.extend(char)
        if char == b"\n":
            break
    return result.decode("utf-8", errors="replace")
recv_exit_status() async

Wait for and return command exit status asynchronously.

Returns:

Type Description
int

Exit status code

Source code in spindlex/transport/async_channel.py
async def recv_exit_status(self) -> int:
    """
    Wait for and return command exit status asynchronously.

    Returns:
        Exit status code
    """
    return await self._channel.recv_exit_status()
write(data) async

Write data to channel asynchronously.

Parameters:

Name Type Description Default
data bytes

Data to write

required

Returns:

Type Description
int

Number of bytes written

Source code in spindlex/transport/async_channel.py
async def write(self, data: bytes) -> int:
    """
    Write data to channel asynchronously.

    Args:
        data: Data to write

    Returns:
        Number of bytes written
    """
    if self._closed:
        raise ValueError("I/O operation on closed file")

    return await self._channel.send(data)

Functions:

Port Forwarding

spindlex.transport.forwarding

SSH Port Forwarding Implementation

Provides local and remote port forwarding functionality for SSH connections. Handles tunnel creation, data relay, and connection management.

Classes

ForwardingTunnel

Represents a port forwarding tunnel.

Manages the lifecycle of a forwarding tunnel including connection handling and cleanup.

Source code in spindlex/transport/forwarding.py
class ForwardingTunnel:
    """
    Represents a port forwarding tunnel.

    Manages the lifecycle of a forwarding tunnel including
    connection handling and cleanup.
    """

    def __init__(
        self,
        tunnel_id: str,
        local_addr: tuple[str, int],
        remote_addr: tuple[str, int],
        tunnel_type: str,
    ) -> None:
        """
        Initialize forwarding tunnel.

        Args:
            tunnel_id: Unique identifier for the tunnel
            local_addr: Local address (host, port)
            remote_addr: Remote address (host, port)
            tunnel_type: Type of tunnel ('local' or 'remote')
        """
        self.tunnel_id = tunnel_id
        self.local_addr = local_addr
        self.remote_addr = remote_addr
        self.tunnel_type = tunnel_type
        self.active = False
        self.connections: dict[
            str, dict[str, Union[socket.socket, Channel, tuple[Any, ...]]]
        ] = {}
        self._lock = threading.RLock()
        self._logger = logging.getLogger(__name__)

    def close(self) -> None:
        """Close tunnel and all active connections."""
        with self._lock:
            self.active = False

            # Close all active connections
            for conn_id, connection in list(self.connections.items()):
                try:
                    # connection is a dict containing 'client_socket' or 'local_socket' and 'channel'
                    for item in connection.values():
                        if isinstance(item, (socket.socket, Channel)):
                            try:
                                item.close()
                            except Exception as e:
                                self._logger.debug(f"Forwarding close error: {e}")
                except Exception as e:
                    self._logger.debug(f"Error closing connection {conn_id}: {e}")

            self.connections.clear()
            self._logger.debug(f"Tunnel {self.tunnel_id} closed")
Methods:
__init__(tunnel_id, local_addr, remote_addr, tunnel_type)

Initialize forwarding tunnel.

Parameters:

Name Type Description Default
tunnel_id str

Unique identifier for the tunnel

required
local_addr tuple[str, int]

Local address (host, port)

required
remote_addr tuple[str, int]

Remote address (host, port)

required
tunnel_type str

Type of tunnel ('local' or 'remote')

required
Source code in spindlex/transport/forwarding.py
def __init__(
    self,
    tunnel_id: str,
    local_addr: tuple[str, int],
    remote_addr: tuple[str, int],
    tunnel_type: str,
) -> None:
    """
    Initialize forwarding tunnel.

    Args:
        tunnel_id: Unique identifier for the tunnel
        local_addr: Local address (host, port)
        remote_addr: Remote address (host, port)
        tunnel_type: Type of tunnel ('local' or 'remote')
    """
    self.tunnel_id = tunnel_id
    self.local_addr = local_addr
    self.remote_addr = remote_addr
    self.tunnel_type = tunnel_type
    self.active = False
    self.connections: dict[
        str, dict[str, Union[socket.socket, Channel, tuple[Any, ...]]]
    ] = {}
    self._lock = threading.RLock()
    self._logger = logging.getLogger(__name__)
close()

Close tunnel and all active connections.

Source code in spindlex/transport/forwarding.py
def close(self) -> None:
    """Close tunnel and all active connections."""
    with self._lock:
        self.active = False

        # Close all active connections
        for conn_id, connection in list(self.connections.items()):
            try:
                # connection is a dict containing 'client_socket' or 'local_socket' and 'channel'
                for item in connection.values():
                    if isinstance(item, (socket.socket, Channel)):
                        try:
                            item.close()
                        except Exception as e:
                            self._logger.debug(f"Forwarding close error: {e}")
            except Exception as e:
                self._logger.debug(f"Error closing connection {conn_id}: {e}")

        self.connections.clear()
        self._logger.debug(f"Tunnel {self.tunnel_id} closed")

LocalPortForwarder

Handles local port forwarding (SSH -L option).

Listens on local port and forwards connections through SSH to remote destination.

Source code in spindlex/transport/forwarding.py
class LocalPortForwarder:
    """
    Handles local port forwarding (SSH -L option).

    Listens on local port and forwards connections through SSH
    to remote destination.
    """

    def __init__(self, transport: "Transport") -> None:
        """
        Initialize local port forwarder.

        Args:
            transport: SSH transport instance
        """
        self._transport: Transport = transport
        self._tunnels: dict[str, ForwardingTunnel] = {}
        self._servers: dict[str, socket.socket] = {}
        self._lock = threading.RLock()
        self._logger = logging.getLogger(__name__)

    def create_tunnel(
        self,
        local_port: int,
        remote_host: str,
        remote_port: int,
        local_host: str = "127.0.0.1",
    ) -> str:
        """
        Create local port forwarding tunnel.

        Args:
            local_port: Local port to listen on
            remote_host: Remote host to connect to
            remote_port: Remote port to connect to
            local_host: Local interface to bind to

        Returns:
            Tunnel ID for management

        Raises:
            SSHException: If tunnel creation fails
        """
        tunnel_id = f"local_{local_host}_{local_port}_{remote_host}_{remote_port}"
        local_addr = (local_host, local_port)
        remote_addr = (remote_host, remote_port)

        with self._lock:
            if not (0 <= local_port <= 65535):
                raise SSHException(f"Invalid local port: {local_port}")

            if tunnel_id in self._tunnels:
                raise SSHException(f"Tunnel already exists: {tunnel_id}")

            server_socket: Union[socket.socket, None] = None
            try:
                addr_info = socket.getaddrinfo(
                    local_host,
                    local_port,
                    socket.AF_UNSPEC,
                    socket.SOCK_STREAM,
                    0,
                    socket.AI_PASSIVE,
                )
                if not addr_info:
                    raise SSHException(f"Could not resolve bind address: {local_host}")

                # Use the first available address info
                af, socktype, proto, canonname, sa = addr_info[0]

                # Create listening socket
                server_socket = socket.socket(af, socktype, proto)
                server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

                # For IPv6, try to enable dual-stack if local_host is empty or ::
                if af == socket.AF_INET6:
                    try:
                        server_socket.setsockopt(
                            socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0
                        )
                    except (AttributeError, OSError):
                        pass

                server_socket.bind(sa)
                server_socket.listen(socket.SOMAXCONN)

                # Create tunnel object
                tunnel = ForwardingTunnel(tunnel_id, local_addr, remote_addr, "local")
                tunnel.active = True

                # Store tunnel and server socket
                self._tunnels[tunnel_id] = tunnel
                self._servers[tunnel_id] = server_socket

                # Start accepting connections in background thread
                accept_thread = threading.Thread(
                    target=self._accept_connections,
                    args=(tunnel_id, server_socket),
                    daemon=True,
                )
                accept_thread.start()

                self._logger.info(
                    f"Local port forwarding started: {local_host}:{local_port} -> {remote_host}:{remote_port}"
                )

                return tunnel_id

            except Exception as e:
                # Cleanup on failure
                if tunnel_id in self._tunnels:
                    del self._tunnels[tunnel_id]
                if tunnel_id in self._servers:
                    try:
                        self._servers[tunnel_id].close()
                    except Exception as cleanup_err:
                        self._logger.debug(f"Forwarding close error: {cleanup_err}")
                    del self._servers[tunnel_id]
                # If bind/listen failed before the socket was stored in
                # _servers, close it here so the fd isn't leaked.
                elif server_socket is not None:
                    try:
                        server_socket.close()
                    except Exception as cleanup_err:
                        self._logger.debug(f"Forwarding close error: {cleanup_err}")
                raise SSHException(
                    f"Failed to create local port forwarding: {e}"
                ) from e

    def _accept_connections(self, tunnel_id: str, server_socket: socket.socket) -> None:
        """
        Accept incoming connections for local port forwarding.

        Args:
            tunnel_id: Tunnel identifier
            server_socket: Server socket to accept connections on
        """
        tunnel = self._tunnels.get(tunnel_id)
        if not tunnel:
            return

        self._logger.debug(f"Accepting connections for tunnel {tunnel_id}")

        while tunnel.active:
            try:
                # Accepted incoming connection
                client_socket, client_addr = server_socket.accept()

                self._logger.debug(
                    f"Accepted connection from {client_addr} for tunnel {tunnel_id}"
                )

                # Handle connection in separate thread
                conn_thread = threading.Thread(
                    target=self._handle_local_connection,
                    args=(tunnel_id, client_socket, client_addr),
                    daemon=True,
                )
                conn_thread.start()

            except OSError as e:
                if tunnel.active:
                    self._logger.error(
                        f"Error accepting connection for tunnel {tunnel_id}: {e}"
                    )
                break
            except Exception as e:
                self._logger.error(
                    f"Unexpected error in accept loop for tunnel {tunnel_id}: {e}"
                )
                break

        self._logger.debug(f"Accept loop ended for tunnel {tunnel_id}")

    def _handle_local_connection(
        self, tunnel_id: str, client_socket: socket.socket, client_addr: tuple[str, int]
    ) -> None:
        """
        Handle individual local port forwarding connection.

        Args:
            tunnel_id: Tunnel identifier
            client_socket: Client socket
            client_addr: Client address
        """
        tunnel = self._tunnels.get(tunnel_id)
        if not tunnel:
            client_socket.close()
            return

        conn_id = f"{tunnel_id}_{client_addr[0]}_{client_addr[1]}_{time.time()}"

        try:
            # Open SSH channel for forwarding
            channel = self._transport.open_channel(
                CHANNEL_DIRECT_TCPIP, dest_addr=tunnel.remote_addr
            )

            # Store connection
            with tunnel._lock:
                tunnel.connections[conn_id] = {
                    "client_socket": client_socket,
                    "channel": channel,
                    "client_addr": client_addr,
                }

            # Start data relay threads
            client_to_channel_thread = threading.Thread(
                target=self._relay_data,
                args=(client_socket, channel, f"{conn_id}_c2s"),
                daemon=True,
            )

            channel_to_client_thread = threading.Thread(
                target=self._relay_data,
                args=(channel, client_socket, f"{conn_id}_s2c"),
                daemon=True,
            )

            client_to_channel_thread.start()
            channel_to_client_thread.start()

            # Wait for threads to complete
            client_to_channel_thread.join()
            channel_to_client_thread.join()

        except Exception as e:
            self._logger.error(f"Error handling local connection {conn_id}: {e}")
        finally:
            # Cleanup connection
            try:
                client_socket.close()
            except Exception as e:
                self._logger.debug(f"Forwarding close error: {e}")
            with tunnel._lock:
                if conn_id in tunnel.connections:
                    chan = tunnel.connections[conn_id].get("channel")
                    if isinstance(chan, Channel):
                        try:
                            chan.close()
                        except Exception as e:
                            self._logger.debug(f"Forwarding close error: {e}")
                    del tunnel.connections[conn_id]

            self._logger.debug(f"Local connection {conn_id} closed")

    def _relay_data(
        self,
        source: Union[socket.socket, "Channel"],
        destination: Union[socket.socket, "Channel"],
        relay_id: str,
    ) -> None:
        """
        Relay data between source and destination.

        Args:
            source: Source to read from (socket or channel)
            destination: Destination to write to (socket or channel)
            relay_id: Identifier for logging
        """
        try:
            while True:
                # Read data from source
                data = source.recv(8192)

                if not data:
                    break

                # Write data to destination
                if isinstance(destination, socket.socket):
                    destination.sendall(data)
                else:
                    destination.send(data)

        except (OSError, EOFError, SSHException) as e:
            self._logger.info(f"Data relay {relay_id} closed: {e}")
        except Exception as e:
            self._logger.error(f"Unexpected error in data relay {relay_id}: {e}")

    def close_tunnel(self, tunnel_id: str) -> None:
        """
        Close local port forwarding tunnel.

        Args:
            tunnel_id: Tunnel identifier
        """
        with self._lock:
            if tunnel_id not in self._tunnels:
                return

            tunnel = self._tunnels[tunnel_id]
            tunnel.close()

            # Close server socket
            if tunnel_id in self._servers:
                try:
                    self._servers[tunnel_id].close()
                except Exception as e:
                    self._logger.debug(f"Forwarding close error: {e}")
                del self._servers[tunnel_id]

            del self._tunnels[tunnel_id]

            self._logger.info(f"Local port forwarding tunnel closed: {tunnel_id}")

    def get_tunnels(self) -> dict[str, ForwardingTunnel]:
        """
        Get all active tunnels.

        Returns:
            Dictionary of tunnel ID to tunnel objects
        """
        with self._lock:
            return self._tunnels.copy()

    def close_all(self) -> None:
        """Close all local port forwarding tunnels."""
        with self._lock:
            for tunnel_id in list(self._tunnels.keys()):
                self.close_tunnel(tunnel_id)
Methods:
__init__(transport)

Initialize local port forwarder.

Parameters:

Name Type Description Default
transport Transport

SSH transport instance

required
Source code in spindlex/transport/forwarding.py
def __init__(self, transport: "Transport") -> None:
    """
    Initialize local port forwarder.

    Args:
        transport: SSH transport instance
    """
    self._transport: Transport = transport
    self._tunnels: dict[str, ForwardingTunnel] = {}
    self._servers: dict[str, socket.socket] = {}
    self._lock = threading.RLock()
    self._logger = logging.getLogger(__name__)
close_all()

Close all local port forwarding tunnels.

Source code in spindlex/transport/forwarding.py
def close_all(self) -> None:
    """Close all local port forwarding tunnels."""
    with self._lock:
        for tunnel_id in list(self._tunnels.keys()):
            self.close_tunnel(tunnel_id)
close_tunnel(tunnel_id)

Close local port forwarding tunnel.

Parameters:

Name Type Description Default
tunnel_id str

Tunnel identifier

required
Source code in spindlex/transport/forwarding.py
def close_tunnel(self, tunnel_id: str) -> None:
    """
    Close local port forwarding tunnel.

    Args:
        tunnel_id: Tunnel identifier
    """
    with self._lock:
        if tunnel_id not in self._tunnels:
            return

        tunnel = self._tunnels[tunnel_id]
        tunnel.close()

        # Close server socket
        if tunnel_id in self._servers:
            try:
                self._servers[tunnel_id].close()
            except Exception as e:
                self._logger.debug(f"Forwarding close error: {e}")
            del self._servers[tunnel_id]

        del self._tunnels[tunnel_id]

        self._logger.info(f"Local port forwarding tunnel closed: {tunnel_id}")
create_tunnel(local_port, remote_host, remote_port, local_host='127.0.0.1')

Create local port forwarding tunnel.

Parameters:

Name Type Description Default
local_port int

Local port to listen on

required
remote_host str

Remote host to connect to

required
remote_port int

Remote port to connect to

required
local_host str

Local interface to bind to

'127.0.0.1'

Returns:

Type Description
str

Tunnel ID for management

Raises:

Type Description
SSHException

If tunnel creation fails

Source code in spindlex/transport/forwarding.py
def create_tunnel(
    self,
    local_port: int,
    remote_host: str,
    remote_port: int,
    local_host: str = "127.0.0.1",
) -> str:
    """
    Create local port forwarding tunnel.

    Args:
        local_port: Local port to listen on
        remote_host: Remote host to connect to
        remote_port: Remote port to connect to
        local_host: Local interface to bind to

    Returns:
        Tunnel ID for management

    Raises:
        SSHException: If tunnel creation fails
    """
    tunnel_id = f"local_{local_host}_{local_port}_{remote_host}_{remote_port}"
    local_addr = (local_host, local_port)
    remote_addr = (remote_host, remote_port)

    with self._lock:
        if not (0 <= local_port <= 65535):
            raise SSHException(f"Invalid local port: {local_port}")

        if tunnel_id in self._tunnels:
            raise SSHException(f"Tunnel already exists: {tunnel_id}")

        server_socket: Union[socket.socket, None] = None
        try:
            addr_info = socket.getaddrinfo(
                local_host,
                local_port,
                socket.AF_UNSPEC,
                socket.SOCK_STREAM,
                0,
                socket.AI_PASSIVE,
            )
            if not addr_info:
                raise SSHException(f"Could not resolve bind address: {local_host}")

            # Use the first available address info
            af, socktype, proto, canonname, sa = addr_info[0]

            # Create listening socket
            server_socket = socket.socket(af, socktype, proto)
            server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

            # For IPv6, try to enable dual-stack if local_host is empty or ::
            if af == socket.AF_INET6:
                try:
                    server_socket.setsockopt(
                        socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0
                    )
                except (AttributeError, OSError):
                    pass

            server_socket.bind(sa)
            server_socket.listen(socket.SOMAXCONN)

            # Create tunnel object
            tunnel = ForwardingTunnel(tunnel_id, local_addr, remote_addr, "local")
            tunnel.active = True

            # Store tunnel and server socket
            self._tunnels[tunnel_id] = tunnel
            self._servers[tunnel_id] = server_socket

            # Start accepting connections in background thread
            accept_thread = threading.Thread(
                target=self._accept_connections,
                args=(tunnel_id, server_socket),
                daemon=True,
            )
            accept_thread.start()

            self._logger.info(
                f"Local port forwarding started: {local_host}:{local_port} -> {remote_host}:{remote_port}"
            )

            return tunnel_id

        except Exception as e:
            # Cleanup on failure
            if tunnel_id in self._tunnels:
                del self._tunnels[tunnel_id]
            if tunnel_id in self._servers:
                try:
                    self._servers[tunnel_id].close()
                except Exception as cleanup_err:
                    self._logger.debug(f"Forwarding close error: {cleanup_err}")
                del self._servers[tunnel_id]
            # If bind/listen failed before the socket was stored in
            # _servers, close it here so the fd isn't leaked.
            elif server_socket is not None:
                try:
                    server_socket.close()
                except Exception as cleanup_err:
                    self._logger.debug(f"Forwarding close error: {cleanup_err}")
            raise SSHException(
                f"Failed to create local port forwarding: {e}"
            ) from e
get_tunnels()

Get all active tunnels.

Returns:

Type Description
dict[str, ForwardingTunnel]

Dictionary of tunnel ID to tunnel objects

Source code in spindlex/transport/forwarding.py
def get_tunnels(self) -> dict[str, ForwardingTunnel]:
    """
    Get all active tunnels.

    Returns:
        Dictionary of tunnel ID to tunnel objects
    """
    with self._lock:
        return self._tunnels.copy()

PortForwardingManager

Manages both local and remote port forwarding.

Provides unified interface for creating and managing port forwarding tunnels.

Source code in spindlex/transport/forwarding.py
class PortForwardingManager:
    """
    Manages both local and remote port forwarding.

    Provides unified interface for creating and managing
    port forwarding tunnels.
    """

    def __init__(self, transport: "Transport") -> None:
        """
        Initialize port forwarding manager.

        Args:
            transport: SSH transport instance
        """
        self._transport: Transport = transport
        self.local_forwarder = LocalPortForwarder(transport)
        self.remote_forwarder = RemotePortForwarder(transport)
        self._logger = logging.getLogger(__name__)

    def create_local_tunnel(
        self,
        local_port: int,
        remote_host: str,
        remote_port: int,
        local_host: str = "127.0.0.1",
    ) -> str:
        """
        Create local port forwarding tunnel.

        Args:
            local_port: Local port to listen on
            remote_host: Remote host to connect to
            remote_port: Remote port to connect to
            local_host: Local interface to bind to

        Returns:
            Tunnel ID for management
        """
        return self.local_forwarder.create_tunnel(
            local_port, remote_host, remote_port, local_host
        )

    def create_remote_tunnel(
        self, remote_port: int, local_host: str, local_port: int, remote_host: str = ""
    ) -> str:
        """
        Create remote port forwarding tunnel.

        Args:
            remote_port: Remote port to listen on
            local_host: Local host to connect to
            local_port: Local port to connect to
            remote_host: Remote interface to bind to

        Returns:
            Tunnel ID for management
        """
        return self.remote_forwarder.create_tunnel(
            remote_port, local_host, local_port, remote_host
        )

    def close_tunnel(self, tunnel_id: str) -> None:
        """
        Close port forwarding tunnel.

        Args:
            tunnel_id: Tunnel identifier
        """
        # Try local forwarder first
        if tunnel_id in self.local_forwarder.get_tunnels():
            self.local_forwarder.close_tunnel(tunnel_id)
        elif tunnel_id in self.remote_forwarder.get_tunnels():
            self.remote_forwarder.close_tunnel(tunnel_id)
        else:
            self._logger.warning(f"Tunnel not found: {tunnel_id}")

    def get_all_tunnels(self) -> dict[str, ForwardingTunnel]:
        """
        Get all active tunnels (local and remote).

        Returns:
            Dictionary of tunnel ID to tunnel objects
        """
        tunnels = {}
        tunnels.update(self.local_forwarder.get_tunnels())
        tunnels.update(self.remote_forwarder.get_tunnels())
        return tunnels

    def close_all_tunnels(self) -> None:
        """Close all port forwarding tunnels."""
        self.local_forwarder.close_all()
        self.remote_forwarder.close_all()

    def handle_forwarded_connection(
        self,
        channel: "Channel",
        origin_addr: tuple[Any, ...],
        dest_addr: tuple[Any, ...],
    ) -> None:
        """
        Handle incoming forwarded connection from remote server.

        Args:
            channel: SSH channel for the forwarded connection
            origin_addr: Origin address of the connection
            dest_addr: Destination address
        """
        self.remote_forwarder.handle_forwarded_connection(
            channel, origin_addr, dest_addr
        )
Methods:
__init__(transport)

Initialize port forwarding manager.

Parameters:

Name Type Description Default
transport Transport

SSH transport instance

required
Source code in spindlex/transport/forwarding.py
def __init__(self, transport: "Transport") -> None:
    """
    Initialize port forwarding manager.

    Args:
        transport: SSH transport instance
    """
    self._transport: Transport = transport
    self.local_forwarder = LocalPortForwarder(transport)
    self.remote_forwarder = RemotePortForwarder(transport)
    self._logger = logging.getLogger(__name__)
close_all_tunnels()

Close all port forwarding tunnels.

Source code in spindlex/transport/forwarding.py
def close_all_tunnels(self) -> None:
    """Close all port forwarding tunnels."""
    self.local_forwarder.close_all()
    self.remote_forwarder.close_all()
close_tunnel(tunnel_id)

Close port forwarding tunnel.

Parameters:

Name Type Description Default
tunnel_id str

Tunnel identifier

required
Source code in spindlex/transport/forwarding.py
def close_tunnel(self, tunnel_id: str) -> None:
    """
    Close port forwarding tunnel.

    Args:
        tunnel_id: Tunnel identifier
    """
    # Try local forwarder first
    if tunnel_id in self.local_forwarder.get_tunnels():
        self.local_forwarder.close_tunnel(tunnel_id)
    elif tunnel_id in self.remote_forwarder.get_tunnels():
        self.remote_forwarder.close_tunnel(tunnel_id)
    else:
        self._logger.warning(f"Tunnel not found: {tunnel_id}")
create_local_tunnel(local_port, remote_host, remote_port, local_host='127.0.0.1')

Create local port forwarding tunnel.

Parameters:

Name Type Description Default
local_port int

Local port to listen on

required
remote_host str

Remote host to connect to

required
remote_port int

Remote port to connect to

required
local_host str

Local interface to bind to

'127.0.0.1'

Returns:

Type Description
str

Tunnel ID for management

Source code in spindlex/transport/forwarding.py
def create_local_tunnel(
    self,
    local_port: int,
    remote_host: str,
    remote_port: int,
    local_host: str = "127.0.0.1",
) -> str:
    """
    Create local port forwarding tunnel.

    Args:
        local_port: Local port to listen on
        remote_host: Remote host to connect to
        remote_port: Remote port to connect to
        local_host: Local interface to bind to

    Returns:
        Tunnel ID for management
    """
    return self.local_forwarder.create_tunnel(
        local_port, remote_host, remote_port, local_host
    )
create_remote_tunnel(remote_port, local_host, local_port, remote_host='')

Create remote port forwarding tunnel.

Parameters:

Name Type Description Default
remote_port int

Remote port to listen on

required
local_host str

Local host to connect to

required
local_port int

Local port to connect to

required
remote_host str

Remote interface to bind to

''

Returns:

Type Description
str

Tunnel ID for management

Source code in spindlex/transport/forwarding.py
def create_remote_tunnel(
    self, remote_port: int, local_host: str, local_port: int, remote_host: str = ""
) -> str:
    """
    Create remote port forwarding tunnel.

    Args:
        remote_port: Remote port to listen on
        local_host: Local host to connect to
        local_port: Local port to connect to
        remote_host: Remote interface to bind to

    Returns:
        Tunnel ID for management
    """
    return self.remote_forwarder.create_tunnel(
        remote_port, local_host, local_port, remote_host
    )
get_all_tunnels()

Get all active tunnels (local and remote).

Returns:

Type Description
dict[str, ForwardingTunnel]

Dictionary of tunnel ID to tunnel objects

Source code in spindlex/transport/forwarding.py
def get_all_tunnels(self) -> dict[str, ForwardingTunnel]:
    """
    Get all active tunnels (local and remote).

    Returns:
        Dictionary of tunnel ID to tunnel objects
    """
    tunnels = {}
    tunnels.update(self.local_forwarder.get_tunnels())
    tunnels.update(self.remote_forwarder.get_tunnels())
    return tunnels
handle_forwarded_connection(channel, origin_addr, dest_addr)

Handle incoming forwarded connection from remote server.

Parameters:

Name Type Description Default
channel Channel

SSH channel for the forwarded connection

required
origin_addr tuple[Any, ...]

Origin address of the connection

required
dest_addr tuple[Any, ...]

Destination address

required
Source code in spindlex/transport/forwarding.py
def handle_forwarded_connection(
    self,
    channel: "Channel",
    origin_addr: tuple[Any, ...],
    dest_addr: tuple[Any, ...],
) -> None:
    """
    Handle incoming forwarded connection from remote server.

    Args:
        channel: SSH channel for the forwarded connection
        origin_addr: Origin address of the connection
        dest_addr: Destination address
    """
    self.remote_forwarder.handle_forwarded_connection(
        channel, origin_addr, dest_addr
    )

RemotePortForwarder

Handles remote port forwarding (SSH -R option).

Requests remote server to listen on port and forward connections back through SSH to local destination.

Source code in spindlex/transport/forwarding.py
class RemotePortForwarder:
    """
    Handles remote port forwarding (SSH -R option).

    Requests remote server to listen on port and forward
    connections back through SSH to local destination.
    """

    def __init__(self, transport: "Transport") -> None:
        """
        Initialize remote port forwarder.

        Args:
            transport: SSH transport instance
        """
        self._transport: Transport = transport
        self._tunnels: dict[str, ForwardingTunnel] = {}
        self._lock = threading.RLock()
        self._logger = logging.getLogger(__name__)

    def create_tunnel(
        self, remote_port: int, local_host: str, local_port: int, remote_host: str = ""
    ) -> str:
        """
        Create remote port forwarding tunnel.

        Args:
            remote_port: Remote port to listen on
            local_host: Local host to connect to
            local_port: Local port to connect to
            remote_host: Remote interface to bind to (empty for all interfaces)

        Returns:
            Tunnel ID for management

        Raises:
            SSHException: If tunnel creation fails
        """
        tunnel_id = f"remote_{remote_host}_{remote_port}_{local_host}_{local_port}"
        remote_addr = (remote_host, remote_port)
        local_addr = (local_host, local_port)

        with self._lock:
            if not (0 <= remote_port <= 65535):
                raise SSHException(f"Invalid remote port: {remote_port}")

            if not (0 <= local_port <= 65535):
                raise SSHException(f"Invalid local port: {local_port}")

            if tunnel_id in self._tunnels:
                raise SSHException(f"Tunnel already exists: {tunnel_id}")

            try:
                # Send global request for remote port forwarding
                success = self._send_tcpip_forward_request(remote_host, remote_port)

                if not success:
                    raise SSHException(
                        "Remote port forwarding request denied by server"
                    )

                # Create tunnel object
                tunnel = ForwardingTunnel(tunnel_id, local_addr, remote_addr, "remote")
                tunnel.active = True

                # Store tunnel
                self._tunnels[tunnel_id] = tunnel

                self._logger.info(
                    f"Remote port forwarding started: {remote_host}:{remote_port} -> {local_host}:{local_port}"
                )

                return tunnel_id

            except Exception as e:
                # Cleanup on failure
                if tunnel_id in self._tunnels:
                    del self._tunnels[tunnel_id]

                raise SSHException(
                    f"Failed to create remote port forwarding: {e}"
                ) from e

    def _send_tcpip_forward_request(self, bind_address: str, bind_port: int) -> bool:
        """
        Send tcpip-forward global request.

        Args:
            bind_address: Address to bind on remote server
            bind_port: Port to bind on remote server

        Returns:
            True if request was accepted, False otherwise
        """
        try:
            # Build request data
            request_data = bytearray()
            request_data.extend(write_string(bind_address))
            request_data.extend(write_uint32(bind_port))

            # Send global request through transport
            return bool(
                self._transport._send_global_request(
                    "tcpip-forward", True, bytes(request_data)
                )
            )

        except Exception as e:
            self._logger.error(f"Error sending tcpip-forward request: {e}")
            return False

    def handle_forwarded_connection(
        self,
        channel: "Channel",
        origin_addr: tuple[Any, ...],
        dest_addr: tuple[Any, ...],
    ) -> None:
        """
        Handle incoming forwarded connection from remote server.

        Args:
            channel: SSH channel for the forwarded connection
            origin_addr: Origin address of the connection (may be 2-tuple or 4-tuple)
            dest_addr: Destination address (should match our tunnel)
        """
        # Find matching tunnel
        tunnel = None
        for t in self._tunnels.values():
            if t.remote_addr[1] == dest_addr[1]:  # Match by port
                tunnel = t
                break

        if not tunnel or not tunnel.active:
            self._logger.warning(
                f"No active tunnel found for forwarded connection to {dest_addr}"
            )
            channel.close()
            return

        # Handle IPv6 address tuples which can be 4 elements long
        origin_host = origin_addr[0]
        origin_port = origin_addr[1]
        conn_id = f"{tunnel.tunnel_id}_{origin_host}_{origin_port}_{time.time()}"

        local_socket = None
        try:
            local_host, local_port = tunnel.local_addr
            addr_info = socket.getaddrinfo(
                local_host, local_port, socket.AF_UNSPEC, socket.SOCK_STREAM
            )
            if not addr_info:
                raise SSHException(f"Could not resolve local destination: {local_host}")

            af, socktype, proto, canonname, sa = addr_info[0]

            # Connect to local destination
            local_socket = socket.socket(af, socktype, proto)
            local_socket.connect(sa)

            # Store connection
            with tunnel._lock:
                tunnel.connections[conn_id] = {
                    "local_socket": local_socket,
                    "channel": channel,
                    "origin_addr": origin_addr,
                }

            # Start data relay threads
            local_to_channel_thread = threading.Thread(
                target=self._relay_data,
                args=(local_socket, channel, f"{conn_id}_l2s"),
                daemon=True,
            )

            channel_to_local_thread = threading.Thread(
                target=self._relay_data,
                args=(channel, local_socket, f"{conn_id}_s2l"),
                daemon=True,
            )

            local_to_channel_thread.start()
            channel_to_local_thread.start()

            # Wait for threads to complete
            local_to_channel_thread.join()
            channel_to_local_thread.join()

        except Exception as e:
            self._logger.error(
                f"Error handling remote forwarded connection {conn_id}: {e}"
            )
        finally:
            # Cleanup connection
            if local_socket:
                try:
                    local_socket.close()
                except Exception as e:
                    self._logger.debug(f"Forwarding close error: {e}")
            with tunnel._lock:
                if conn_id in tunnel.connections:
                    del tunnel.connections[conn_id]

            self._logger.debug(f"Remote forwarded connection {conn_id} closed")

    def _relay_data(
        self,
        source: Union[socket.socket, "Channel"],
        destination: Union[socket.socket, "Channel"],
        relay_id: str,
    ) -> None:
        """
        Relay data between source and destination.

        Args:
            source: Source to read from (socket or channel)
            destination: Destination to write to (socket or channel)
            relay_id: Identifier for logging
        """
        try:
            while True:
                # Read data from source
                data = source.recv(8192)

                if not data:
                    break

                # Write data to destination
                if isinstance(destination, socket.socket):
                    destination.sendall(data)
                else:
                    destination.send(data)

        except (OSError, EOFError, SSHException) as e:
            self._logger.info(f"Data relay {relay_id} closed: {e}")
        except Exception as e:
            self._logger.error(f"Unexpected error in data relay {relay_id}: {e}")

    def close_tunnel(self, tunnel_id: str) -> None:
        """
        Close remote port forwarding tunnel.

        Args:
            tunnel_id: Tunnel identifier
        """
        with self._lock:
            if tunnel_id not in self._tunnels:
                return

            tunnel = self._tunnels[tunnel_id]

            # Send cancel request to server
            try:
                self._send_cancel_tcpip_forward_request(
                    tunnel.remote_addr[0], tunnel.remote_addr[1]
                )
            except Exception as e:
                self._logger.warning(
                    f"Error sending cancel request for tunnel {tunnel_id}: {e}"
                )

            tunnel.close()
            del self._tunnels[tunnel_id]

            self._logger.info(f"Remote port forwarding tunnel closed: {tunnel_id}")

    def _send_cancel_tcpip_forward_request(
        self, bind_address: str, bind_port: int
    ) -> bool:
        """
        Send cancel-tcpip-forward global request.

        Args:
            bind_address: Address that was bound on remote server
            bind_port: Port that was bound on remote server

        Returns:
            True if request was accepted, False otherwise
        """
        try:
            # Build request data
            request_data = bytearray()
            request_data.extend(write_string(bind_address))
            request_data.extend(write_uint32(bind_port))

            # Send global request through transport
            return bool(
                self._transport._send_global_request(
                    "cancel-tcpip-forward", True, bytes(request_data)
                )
            )

        except Exception as e:
            self._logger.error(f"Error sending cancel-tcpip-forward request: {e}")
            return False

    def get_tunnels(self) -> dict[str, ForwardingTunnel]:
        """
        Get all active tunnels.

        Returns:
            Dictionary of tunnel ID to tunnel objects
        """
        with self._lock:
            return self._tunnels.copy()

    def close_all(self) -> None:
        """Close all remote port forwarding tunnels."""
        with self._lock:
            for tunnel_id in list(self._tunnels.keys()):
                self.close_tunnel(tunnel_id)
Methods:
__init__(transport)

Initialize remote port forwarder.

Parameters:

Name Type Description Default
transport Transport

SSH transport instance

required
Source code in spindlex/transport/forwarding.py
def __init__(self, transport: "Transport") -> None:
    """
    Initialize remote port forwarder.

    Args:
        transport: SSH transport instance
    """
    self._transport: Transport = transport
    self._tunnels: dict[str, ForwardingTunnel] = {}
    self._lock = threading.RLock()
    self._logger = logging.getLogger(__name__)
close_all()

Close all remote port forwarding tunnels.

Source code in spindlex/transport/forwarding.py
def close_all(self) -> None:
    """Close all remote port forwarding tunnels."""
    with self._lock:
        for tunnel_id in list(self._tunnels.keys()):
            self.close_tunnel(tunnel_id)
close_tunnel(tunnel_id)

Close remote port forwarding tunnel.

Parameters:

Name Type Description Default
tunnel_id str

Tunnel identifier

required
Source code in spindlex/transport/forwarding.py
def close_tunnel(self, tunnel_id: str) -> None:
    """
    Close remote port forwarding tunnel.

    Args:
        tunnel_id: Tunnel identifier
    """
    with self._lock:
        if tunnel_id not in self._tunnels:
            return

        tunnel = self._tunnels[tunnel_id]

        # Send cancel request to server
        try:
            self._send_cancel_tcpip_forward_request(
                tunnel.remote_addr[0], tunnel.remote_addr[1]
            )
        except Exception as e:
            self._logger.warning(
                f"Error sending cancel request for tunnel {tunnel_id}: {e}"
            )

        tunnel.close()
        del self._tunnels[tunnel_id]

        self._logger.info(f"Remote port forwarding tunnel closed: {tunnel_id}")
create_tunnel(remote_port, local_host, local_port, remote_host='')

Create remote port forwarding tunnel.

Parameters:

Name Type Description Default
remote_port int

Remote port to listen on

required
local_host str

Local host to connect to

required
local_port int

Local port to connect to

required
remote_host str

Remote interface to bind to (empty for all interfaces)

''

Returns:

Type Description
str

Tunnel ID for management

Raises:

Type Description
SSHException

If tunnel creation fails

Source code in spindlex/transport/forwarding.py
def create_tunnel(
    self, remote_port: int, local_host: str, local_port: int, remote_host: str = ""
) -> str:
    """
    Create remote port forwarding tunnel.

    Args:
        remote_port: Remote port to listen on
        local_host: Local host to connect to
        local_port: Local port to connect to
        remote_host: Remote interface to bind to (empty for all interfaces)

    Returns:
        Tunnel ID for management

    Raises:
        SSHException: If tunnel creation fails
    """
    tunnel_id = f"remote_{remote_host}_{remote_port}_{local_host}_{local_port}"
    remote_addr = (remote_host, remote_port)
    local_addr = (local_host, local_port)

    with self._lock:
        if not (0 <= remote_port <= 65535):
            raise SSHException(f"Invalid remote port: {remote_port}")

        if not (0 <= local_port <= 65535):
            raise SSHException(f"Invalid local port: {local_port}")

        if tunnel_id in self._tunnels:
            raise SSHException(f"Tunnel already exists: {tunnel_id}")

        try:
            # Send global request for remote port forwarding
            success = self._send_tcpip_forward_request(remote_host, remote_port)

            if not success:
                raise SSHException(
                    "Remote port forwarding request denied by server"
                )

            # Create tunnel object
            tunnel = ForwardingTunnel(tunnel_id, local_addr, remote_addr, "remote")
            tunnel.active = True

            # Store tunnel
            self._tunnels[tunnel_id] = tunnel

            self._logger.info(
                f"Remote port forwarding started: {remote_host}:{remote_port} -> {local_host}:{local_port}"
            )

            return tunnel_id

        except Exception as e:
            # Cleanup on failure
            if tunnel_id in self._tunnels:
                del self._tunnels[tunnel_id]

            raise SSHException(
                f"Failed to create remote port forwarding: {e}"
            ) from e
get_tunnels()

Get all active tunnels.

Returns:

Type Description
dict[str, ForwardingTunnel]

Dictionary of tunnel ID to tunnel objects

Source code in spindlex/transport/forwarding.py
def get_tunnels(self) -> dict[str, ForwardingTunnel]:
    """
    Get all active tunnels.

    Returns:
        Dictionary of tunnel ID to tunnel objects
    """
    with self._lock:
        return self._tunnels.copy()
handle_forwarded_connection(channel, origin_addr, dest_addr)

Handle incoming forwarded connection from remote server.

Parameters:

Name Type Description Default
channel Channel

SSH channel for the forwarded connection

required
origin_addr tuple[Any, ...]

Origin address of the connection (may be 2-tuple or 4-tuple)

required
dest_addr tuple[Any, ...]

Destination address (should match our tunnel)

required
Source code in spindlex/transport/forwarding.py
def handle_forwarded_connection(
    self,
    channel: "Channel",
    origin_addr: tuple[Any, ...],
    dest_addr: tuple[Any, ...],
) -> None:
    """
    Handle incoming forwarded connection from remote server.

    Args:
        channel: SSH channel for the forwarded connection
        origin_addr: Origin address of the connection (may be 2-tuple or 4-tuple)
        dest_addr: Destination address (should match our tunnel)
    """
    # Find matching tunnel
    tunnel = None
    for t in self._tunnels.values():
        if t.remote_addr[1] == dest_addr[1]:  # Match by port
            tunnel = t
            break

    if not tunnel or not tunnel.active:
        self._logger.warning(
            f"No active tunnel found for forwarded connection to {dest_addr}"
        )
        channel.close()
        return

    # Handle IPv6 address tuples which can be 4 elements long
    origin_host = origin_addr[0]
    origin_port = origin_addr[1]
    conn_id = f"{tunnel.tunnel_id}_{origin_host}_{origin_port}_{time.time()}"

    local_socket = None
    try:
        local_host, local_port = tunnel.local_addr
        addr_info = socket.getaddrinfo(
            local_host, local_port, socket.AF_UNSPEC, socket.SOCK_STREAM
        )
        if not addr_info:
            raise SSHException(f"Could not resolve local destination: {local_host}")

        af, socktype, proto, canonname, sa = addr_info[0]

        # Connect to local destination
        local_socket = socket.socket(af, socktype, proto)
        local_socket.connect(sa)

        # Store connection
        with tunnel._lock:
            tunnel.connections[conn_id] = {
                "local_socket": local_socket,
                "channel": channel,
                "origin_addr": origin_addr,
            }

        # Start data relay threads
        local_to_channel_thread = threading.Thread(
            target=self._relay_data,
            args=(local_socket, channel, f"{conn_id}_l2s"),
            daemon=True,
        )

        channel_to_local_thread = threading.Thread(
            target=self._relay_data,
            args=(channel, local_socket, f"{conn_id}_s2l"),
            daemon=True,
        )

        local_to_channel_thread.start()
        channel_to_local_thread.start()

        # Wait for threads to complete
        local_to_channel_thread.join()
        channel_to_local_thread.join()

    except Exception as e:
        self._logger.error(
            f"Error handling remote forwarded connection {conn_id}: {e}"
        )
    finally:
        # Cleanup connection
        if local_socket:
            try:
                local_socket.close()
            except Exception as e:
                self._logger.debug(f"Forwarding close error: {e}")
        with tunnel._lock:
            if conn_id in tunnel.connections:
                del tunnel.connections[conn_id]

        self._logger.debug(f"Remote forwarded connection {conn_id} closed")

Functions:

spindlex.transport.async_forwarding

Asynchronous SSH Port Forwarding Implementation

Provides local and remote port forwarding functionality for AsyncSSHClient. Handles tunnel creation, data relay, and connection management using asyncio.

Classes

AsyncForwardingTunnel

Represents an asynchronous port forwarding tunnel.

Source code in spindlex/transport/async_forwarding.py
class AsyncForwardingTunnel:
    """
    Represents an asynchronous port forwarding tunnel.
    """

    def __init__(
        self,
        tunnel_id: str,
        local_addr: tuple[str, int],
        remote_addr: tuple[str, int],
        tunnel_type: str,
    ) -> None:
        self.tunnel_id = tunnel_id
        self.local_addr = local_addr
        self.remote_addr = remote_addr
        self.tunnel_type = tunnel_type
        self.active = False
        self.tasks: list[asyncio.Task] = []
        self._logger = logging.getLogger(__name__)

    async def close(self) -> None:
        """Close tunnel and cancel all relay tasks."""
        self.active = False
        for task in self.tasks:
            if not task.done():
                task.cancel()
        self.tasks.clear()
        self._logger.debug(f"Tunnel {self.tunnel_id} closed")
Methods:
close() async

Close tunnel and cancel all relay tasks.

Source code in spindlex/transport/async_forwarding.py
async def close(self) -> None:
    """Close tunnel and cancel all relay tasks."""
    self.active = False
    for task in self.tasks:
        if not task.done():
            task.cancel()
    self.tasks.clear()
    self._logger.debug(f"Tunnel {self.tunnel_id} closed")

AsyncLocalPortForwarder

Handles async local port forwarding (SSH -L).

Source code in spindlex/transport/async_forwarding.py
class AsyncLocalPortForwarder:
    """
    Handles async local port forwarding (SSH -L).
    """

    def __init__(self, transport: Any) -> None:
        self._transport = transport
        self._tunnels: dict[str, AsyncForwardingTunnel] = {}
        self._servers: dict[str, asyncio.AbstractServer] = {}
        self._logger = logging.getLogger(__name__)

    async def create_tunnel(
        self,
        local_port: int,
        remote_host: str,
        remote_port: int,
        local_host: str = "127.0.0.1",
    ) -> str:
        if not (0 <= local_port <= 65535):
            raise SSHException(f"Invalid local port: {local_port}")

        tunnel_id = f"local_{local_host}_{local_port}_{remote_host}_{remote_port}"

        if tunnel_id in self._tunnels:
            raise SSHException(f"Tunnel already exists: {tunnel_id}")

        local_addr = (local_host, local_port)
        remote_addr = (remote_host, remote_port)

        try:
            # Create tunnel object
            tunnel = AsyncForwardingTunnel(tunnel_id, local_addr, remote_addr, "local")
            tunnel.active = True

            # Start listening
            server = await asyncio.start_server(
                lambda r, w: self._handle_client(tunnel, r, w),
                local_host,
                local_port,
            )

            self._tunnels[tunnel_id] = tunnel
            self._servers[tunnel_id] = server

            self._logger.info(
                f"Async local port forwarding started: {local_host}:{local_port} -> {remote_host}:{remote_port}"
            )
            return tunnel_id

        except Exception as e:
            raise SSHException(f"Failed to create local port forwarding: {e}") from e

    async def _handle_client(
        self,
        tunnel: AsyncForwardingTunnel,
        reader: asyncio.StreamReader,
        writer: asyncio.StreamWriter,
    ) -> None:
        """Handle individual client connection for local forwarding."""
        if not tunnel.active:
            writer.close()
            await writer.wait_closed()
            return

        try:
            # Open SSH channel
            channel = await self._transport.open_channel(
                CHANNEL_DIRECT_TCPIP, dest_addr=tunnel.remote_addr
            )

            # Start bidirectional relay
            relay1 = asyncio.create_task(self._relay_stream_to_channel(reader, channel))
            relay2 = asyncio.create_task(self._relay_channel_to_stream(channel, writer))

            tunnel.tasks.extend([relay1, relay2])

            # Wait for either relay to finish
            done, pending = await asyncio.wait(
                [relay1, relay2], return_when=asyncio.FIRST_COMPLETED
            )

            # Cancel remaining relay
            for task in pending:
                task.cancel()

        except Exception as e:
            self._logger.error(
                f"Error handling local connection in tunnel {tunnel.tunnel_id}: {e}"
            )
        finally:
            writer.close()
            try:
                await writer.wait_closed()
            except Exception as e:
                self._logger.debug(f"Forwarding cleanup error: {e}")
            self._logger.debug(
                f"Local client connection closed for tunnel {tunnel.tunnel_id}"
            )

    async def _relay_stream_to_channel(
        self, reader: asyncio.StreamReader, channel: Any
    ) -> None:
        try:
            while True:
                data = await reader.read(8192)
                if not data:
                    break
                await channel.send(data)
        except Exception as e:
            self._logger.debug(f"Forwarding cleanup error: {e}")
        finally:
            try:
                await channel.close()
            except Exception as e:
                self._logger.debug(f"Forwarding cleanup error: {e}")

    async def _relay_channel_to_stream(
        self, channel: Any, writer: asyncio.StreamWriter
    ) -> None:
        try:
            while True:
                data = await channel.recv(8192)
                if not data:
                    break
                writer.write(data)
                await writer.drain()
        except Exception as e:
            self._logger.debug(f"Forwarding cleanup error: {e}")
        finally:
            writer.close()

    async def close_tunnel(self, tunnel_id: str) -> None:
        if tunnel_id in self._tunnels:
            await self._tunnels[tunnel_id].close()
            del self._tunnels[tunnel_id]

        if tunnel_id in self._servers:
            self._servers[tunnel_id].close()
            await self._servers[tunnel_id].wait_closed()
            del self._servers[tunnel_id]

    async def close_all(self) -> None:
        for tid in list(self._tunnels.keys()):
            await self.close_tunnel(tid)

AsyncPortForwardingManager

Unified manager for async port forwarding.

Source code in spindlex/transport/async_forwarding.py
class AsyncPortForwardingManager:
    """
    Unified manager for async port forwarding.
    """

    def __init__(self, transport: Any) -> None:
        self._transport = transport
        self.local_forwarder = AsyncLocalPortForwarder(transport)
        self.remote_forwarder = AsyncRemotePortForwarder(transport)

    async def create_local_tunnel(
        self,
        local_port: int,
        remote_host: str,
        remote_port: int,
        local_host: str = "127.0.0.1",
    ) -> str:
        return await self.local_forwarder.create_tunnel(
            local_port, remote_host, remote_port, local_host
        )

    async def create_remote_tunnel(
        self, remote_port: int, local_host: str, local_port: int, remote_host: str = ""
    ) -> str:
        return await self.remote_forwarder.create_tunnel(
            remote_port, local_host, local_port, remote_host
        )

    async def handle_forwarded_connection_async(
        self,
        sender_channel: int,
        initial_window_size: int,
        maximum_packet_size: int,
        type_specific_data: bytes,
    ) -> None:
        await self.remote_forwarder.handle_forwarded_connection_async(
            sender_channel, initial_window_size, maximum_packet_size, type_specific_data
        )

    async def close_tunnel(self, tunnel_id: str) -> None:
        if tunnel_id.startswith("local_"):
            await self.local_forwarder.close_tunnel(tunnel_id)
        elif tunnel_id.startswith("remote_"):
            await self.remote_forwarder.close_tunnel(tunnel_id)

    async def close_all_tunnels(self) -> None:
        await self.local_forwarder.close_all()
        await self.remote_forwarder.close_all()

    def get_all_tunnels(self) -> dict[str, Any]:
        tunnels = {}
        tunnels.update(self.local_forwarder._tunnels)
        tunnels.update(self.remote_forwarder._tunnels)
        return tunnels

AsyncRemotePortForwarder

Handles async remote port forwarding (SSH -R).

Source code in spindlex/transport/async_forwarding.py
class AsyncRemotePortForwarder:
    """
    Handles async remote port forwarding (SSH -R).
    """

    def __init__(self, transport: Any) -> None:
        self._transport = transport
        self._tunnels: dict[str, AsyncForwardingTunnel] = {}
        self._logger = logging.getLogger(__name__)

    async def create_tunnel(
        self, remote_port: int, local_host: str, local_port: int, remote_host: str = ""
    ) -> str:
        if not (0 <= remote_port <= 65535):
            raise SSHException(f"Invalid remote port: {remote_port}")

        if not (0 <= local_port <= 65535):
            raise SSHException(f"Invalid local port: {local_port}")

        tunnel_id = f"remote_{remote_host}_{remote_port}_{local_host}_{local_port}"

        if tunnel_id in self._tunnels:
            raise SSHException(f"Tunnel already exists: {tunnel_id}")

        # Send global request
        request_data = bytearray()
        request_data.extend(write_string(remote_host))
        request_data.extend(write_uint32(remote_port))

        res = await self._transport._send_global_request_async(
            "tcpip-forward", True, bytes(request_data)
        )

        if not res or res.msg_type != MSG_REQUEST_SUCCESS:
            raise SSHException("Remote port forwarding request denied by server")

        tunnel = AsyncForwardingTunnel(
            tunnel_id, (local_host, local_port), (remote_host, remote_port), "remote"
        )
        tunnel.active = True
        self._tunnels[tunnel_id] = tunnel

        self._logger.info(
            f"Async remote port forwarding started: {remote_host}:{remote_port} -> {local_host}:{local_port}"
        )
        return tunnel_id

    async def handle_forwarded_connection_async(
        self,
        sender_channel: int,
        initial_window_size: int,
        maximum_packet_size: int,
        type_specific_data: bytes,
    ) -> None:
        """Handle incoming forwarded connection from remote server."""
        try:
            # Parse data
            connected_addr_bytes, offset = read_string(type_specific_data, 0)
            connected_port, offset = read_uint32(type_specific_data, offset)

            # Find tunnel
            tunnel = None
            for t in self._tunnels.values():
                if t.remote_addr[1] == connected_port:
                    tunnel = t
                    break

            if not tunnel or not tunnel.active:
                raise SSHException(f"No active tunnel for remote port {connected_port}")

            from .async_channel import AsyncChannel

            channel = AsyncChannel(self._transport, sender_channel)
            # Need to register it in transport channels so it receives packets
            async with self._transport._state_lock:
                # We need to assign a local ID. The sender_channel from the remote is its ID.
                # We use our own next_channel_id for local mapping.
                local_id = self._transport._next_channel_id
                self._transport._next_channel_id += 1
                channel._channel_id = local_id  # Update instance ID
                channel._remote_channel_id = sender_channel
                channel._remote_window_size = initial_window_size
                channel._remote_max_packet_size = maximum_packet_size
                self._transport._channels[local_id] = channel

            # Confirm channel open
            from ..protocol.messages import ChannelOpenConfirmationMessage

            confirm = ChannelOpenConfirmationMessage(
                recipient_channel=sender_channel,
                sender_channel=local_id,
                initial_window_size=DEFAULT_WINDOW_SIZE,
                maximum_packet_size=DEFAULT_MAX_PACKET_SIZE,
            )
            await self._transport._send_message_async(confirm)

            # Connect to local destination
            reader, writer = await asyncio.open_connection(*tunnel.local_addr)

            # Start relay
            relay1 = asyncio.create_task(
                self._relay_stream_to_channel(reader, channel, writer)
            )
            relay2 = asyncio.create_task(self._relay_channel_to_stream(channel, writer))

            tunnel.tasks.extend([relay1, relay2])
            relay1.add_done_callback(
                lambda t: tunnel.tasks.remove(t) if t in tunnel.tasks else None
            )
            relay2.add_done_callback(
                lambda t: tunnel.tasks.remove(t) if t in tunnel.tasks else None
            )

        except Exception as e:
            self._logger.error(f"Failed to handle remote forwarded connection: {e}")
            # Should send ChannelOpenFailureMessage but we need the sender_channel
            try:
                from ..protocol.constants import SSH_OPEN_CONNECT_FAILED
                from ..protocol.messages import ChannelOpenFailureMessage

                fail = ChannelOpenFailureMessage(
                    recipient_channel=sender_channel,
                    reason_code=SSH_OPEN_CONNECT_FAILED,
                    description=str(e),
                )
                await self._transport._send_message_async(fail)
            except Exception as e:
                self._logger.debug(f"Forwarding cleanup error: {e}")

    async def _relay_stream_to_channel(
        self,
        reader: asyncio.StreamReader,
        channel: Any,
        writer: asyncio.StreamWriter | None = None,
    ) -> None:
        try:
            while True:
                data = await reader.read(8192)
                if not data:
                    break
                await channel.send(data)
        except Exception as e:
            self._logger.debug(f"Forwarding cleanup error: {e}")
        finally:
            try:
                await channel.close()
            except Exception as e:
                self._logger.debug(f"Forwarding cleanup error: {e}")
            if writer is not None:
                writer.close()

    async def _relay_channel_to_stream(
        self, channel: Any, writer: asyncio.StreamWriter
    ) -> None:
        try:
            while True:
                data = await channel.recv(8192)
                if not data:
                    break
                writer.write(data)
                await writer.drain()
        except Exception as e:
            self._logger.debug(f"Forwarding cleanup error: {e}")
        finally:
            writer.close()

    async def close_tunnel(self, tunnel_id: str) -> None:
        if tunnel_id in self._tunnels:
            tunnel = self._tunnels[tunnel_id]
            # Cancel remote listen
            request_data = bytearray()
            request_data.extend(write_string(tunnel.remote_addr[0]))
            request_data.extend(write_uint32(tunnel.remote_addr[1]))

            await self._transport._send_global_request_async(
                "cancel-tcpip-forward", True, bytes(request_data)
            )

            await tunnel.close()
            del self._tunnels[tunnel_id]

    async def close_all(self) -> None:
        for tid in list(self._tunnels.keys()):
            await self.close_tunnel(tid)
Methods:
handle_forwarded_connection_async(sender_channel, initial_window_size, maximum_packet_size, type_specific_data) async

Handle incoming forwarded connection from remote server.

Source code in spindlex/transport/async_forwarding.py
async def handle_forwarded_connection_async(
    self,
    sender_channel: int,
    initial_window_size: int,
    maximum_packet_size: int,
    type_specific_data: bytes,
) -> None:
    """Handle incoming forwarded connection from remote server."""
    try:
        # Parse data
        connected_addr_bytes, offset = read_string(type_specific_data, 0)
        connected_port, offset = read_uint32(type_specific_data, offset)

        # Find tunnel
        tunnel = None
        for t in self._tunnels.values():
            if t.remote_addr[1] == connected_port:
                tunnel = t
                break

        if not tunnel or not tunnel.active:
            raise SSHException(f"No active tunnel for remote port {connected_port}")

        from .async_channel import AsyncChannel

        channel = AsyncChannel(self._transport, sender_channel)
        # Need to register it in transport channels so it receives packets
        async with self._transport._state_lock:
            # We need to assign a local ID. The sender_channel from the remote is its ID.
            # We use our own next_channel_id for local mapping.
            local_id = self._transport._next_channel_id
            self._transport._next_channel_id += 1
            channel._channel_id = local_id  # Update instance ID
            channel._remote_channel_id = sender_channel
            channel._remote_window_size = initial_window_size
            channel._remote_max_packet_size = maximum_packet_size
            self._transport._channels[local_id] = channel

        # Confirm channel open
        from ..protocol.messages import ChannelOpenConfirmationMessage

        confirm = ChannelOpenConfirmationMessage(
            recipient_channel=sender_channel,
            sender_channel=local_id,
            initial_window_size=DEFAULT_WINDOW_SIZE,
            maximum_packet_size=DEFAULT_MAX_PACKET_SIZE,
        )
        await self._transport._send_message_async(confirm)

        # Connect to local destination
        reader, writer = await asyncio.open_connection(*tunnel.local_addr)

        # Start relay
        relay1 = asyncio.create_task(
            self._relay_stream_to_channel(reader, channel, writer)
        )
        relay2 = asyncio.create_task(self._relay_channel_to_stream(channel, writer))

        tunnel.tasks.extend([relay1, relay2])
        relay1.add_done_callback(
            lambda t: tunnel.tasks.remove(t) if t in tunnel.tasks else None
        )
        relay2.add_done_callback(
            lambda t: tunnel.tasks.remove(t) if t in tunnel.tasks else None
        )

    except Exception as e:
        self._logger.error(f"Failed to handle remote forwarded connection: {e}")
        # Should send ChannelOpenFailureMessage but we need the sender_channel
        try:
            from ..protocol.constants import SSH_OPEN_CONNECT_FAILED
            from ..protocol.messages import ChannelOpenFailureMessage

            fail = ChannelOpenFailureMessage(
                recipient_channel=sender_channel,
                reason_code=SSH_OPEN_CONNECT_FAILED,
                description=str(e),
            )
            await self._transport._send_message_async(fail)
        except Exception as e:
            self._logger.debug(f"Forwarding cleanup error: {e}")

Functions:

Key Exchange

spindlex.transport.kex

SSH Key Exchange Implementation

Implements SSH key exchange algorithms including Curve25519, ECDH, and Diffie-Hellman for secure session key establishment.

Classes

KeyExchange

SSH key exchange implementation.

Handles key exchange algorithms and session key derivation according to SSH protocol specifications.

Source code in spindlex/transport/kex.py
  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
class KeyExchange:
    """
    SSH key exchange implementation.

    Handles key exchange algorithms and session key derivation
    according to SSH protocol specifications.
    """

    # Diffie-Hellman Group 14 parameters (RFC 3526)
    DH_GROUP14_P = int(
        "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1"
        "29024E088A67CC74020BBEA63B139B22514A08798E3404DD"
        "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245"
        "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
        "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D"
        "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F"
        "83655D23DCA3AD961C62F356208552BB9ED529077096966D"
        "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
        "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9"
        "DE2BCBF6955817183995497CEA956AE515D2261898FA0510"
        "15728E5A8AACAA68FFFFFFFFFFFFFFFF",
        16,
    )
    DH_GROUP14_G = 2

    def __init__(self, transport: Any) -> None:
        """
        Initialize key exchange with transport.

        Args:
            transport: SSH transport instance
        """
        self._transport = transport
        self._algorithm: Optional[str] = None
        self._shared_secret: Optional[bytes] = None
        self._exchange_hash: Optional[bytes] = None
        self._session_id: Optional[bytes] = None

        # Cipher suite for negotiation and info
        self._cipher_suite = CipherSuite(default_crypto_backend)

        # Key exchange state
        self._client_kexinit: Optional[bytes] = None
        self._server_kexinit: Optional[bytes] = None
        self._dh_private_key: Optional[Any] = None
        self._dh_public_key: Optional[int] = None
        self._dh_public_key_mpint: Optional[bytes] = None
        self._server_public_key: Optional[bytes] = None

        # Negotiated algorithms
        self._kex_algorithm: Optional[str] = None
        self._server_host_key_algorithm: Optional[str] = None
        self._encryption_algorithm_c2s: Optional[str] = None
        self._encryption_algorithm_s2c: Optional[str] = None
        self._mac_algorithm_c2s: Optional[str] = None
        self._mac_algorithm_s2c: Optional[str] = None
        self._compression_algorithm_c2s: Optional[str] = None
        self._compression_algorithm_s2c: Optional[str] = None

    def start_kex(self) -> None:
        """
        Start key exchange process.

        Note: KEXINIT exchange should already be completed by transport layer.

        Raises:
            CryptoException: If key exchange fails
        """
        try:
            # Transport layer must complete KEXINIT exchange before calling start_kex().
            if not self._transport._peer_kexinit:
                raise CryptoException(
                    "Peer KEXINIT not received — transport must exchange KEXINIT "
                    "before invoking KeyExchange.start_kex()"
                )

            peer_kexinit_blob = self._transport._peer_kexinit.pack()
            our_kexinit_blob = self._transport._client_kexinit_blob

            if self._transport._server_mode:
                self._client_kexinit = peer_kexinit_blob
                self._server_kexinit = our_kexinit_blob
            else:
                self._client_kexinit = our_kexinit_blob
                self._server_kexinit = peer_kexinit_blob

            # Negotiate algorithms
            self._negotiate_algorithms()

            # Perform key exchange based on negotiated algorithm
            if self._transport._server_mode:
                self._perform_server_kex()
            else:
                self._perform_client_kex()

            # Generate session keys
            self._generate_session_keys()

            # Send NEWKEYS message
            self._send_newkeys()

            # Receive NEWKEYS message
            self._receive_newkeys()

        except Exception as e:
            if isinstance(e, (CryptoException, ProtocolException)):
                raise
            raise CryptoException(f"Key exchange failed: {e}") from e

    def _perform_client_kex(self) -> None:
        """Perform client-side key exchange."""
        if self._kex_algorithm in [
            KEX_CURVE25519_SHA256,
            "curve25519-sha256@libssh.org",
        ]:
            self._perform_curve25519_sha256()
        elif self._kex_algorithm == KEX_ECDH_SHA2_NISTP256:
            self._perform_ecdh_sha2_nistp256()
        elif self._kex_algorithm in [
            KEX_ECDH_SHA2_NISTP384,
            KEX_ECDH_SHA2_NISTP521,
        ]:
            self._perform_ecdh()
        elif self._kex_algorithm == KEX_DH_GROUP14_SHA256:
            self._perform_dh_group14_sha256()
        else:
            raise CryptoException(f"Unsupported KEX algorithm: {self._kex_algorithm}")

    def _perform_server_kex(self) -> None:
        """Perform server-side key exchange."""
        if self._kex_algorithm in [
            KEX_CURVE25519_SHA256,
            "curve25519-sha256@libssh.org",
        ]:
            self._perform_curve25519_sha256_server()
        elif self._kex_algorithm == KEX_ECDH_SHA2_NISTP256:
            self._perform_ecdh_sha2_nistp256_server()
        elif self._kex_algorithm in [
            KEX_ECDH_SHA2_NISTP384,
            KEX_ECDH_SHA2_NISTP521,
        ]:
            self._perform_ecdh_server()
        elif self._kex_algorithm == KEX_DH_GROUP14_SHA256:
            self._perform_dh_group14_sha256_server()
        else:
            raise CryptoException(f"Unsupported KEX algorithm: {self._kex_algorithm}")

    def _send_kexinit(self) -> None:
        """Send KEXINIT message with supported algorithms."""
        cookie = default_crypto_backend.generate_random(KEX_COOKIE_SIZE)

        # Client KEXINIT includes signaling tokens appended after real algorithms
        kex_algorithms = (
            self._cipher_suite.KEX_ALGORITHMS + self._cipher_suite.KEX_SIGNAL_TOKENS
        )
        kexinit_msg = KexInitMessage(
            cookie=cookie,
            kex_algorithms=kex_algorithms,
            server_host_key_algorithms=self._cipher_suite.HOST_KEY_ALGORITHMS,
            encryption_algorithms_client_to_server=self._cipher_suite.ENCRYPTION_ALGORITHMS,
            encryption_algorithms_server_to_client=self._cipher_suite.ENCRYPTION_ALGORITHMS,
            mac_algorithms_client_to_server=self._cipher_suite.MAC_ALGORITHMS,
            mac_algorithms_server_to_client=self._cipher_suite.MAC_ALGORITHMS,
            compression_algorithms_client_to_server=[COMPRESS_NONE],
            compression_algorithms_server_to_client=[COMPRESS_NONE],
            first_kex_packet_follows=False,
        )

        # Store our KEXINIT for hash calculation
        self._client_kexinit = kexinit_msg.pack()

        # Send the message
        self._transport._send_message(kexinit_msg)

    def _receive_kexinit(self) -> None:
        """Receive and process server KEXINIT message."""
        msg = self._transport._expect_message(MSG_KEXINIT)

        if not isinstance(msg, KexInitMessage):
            raise ProtocolException(f"Expected KEXINIT, got {type(msg).__name__}")

        # Store server peer info in transport if needed
        self._transport._peer_kexinit = msg

        # Store server KEXINIT blob for hash calculation
        self._server_kexinit = msg.pack()

    def _negotiate_algorithms(self) -> None:
        """Negotiate algorithms based on client and server preferences."""
        if not self._transport._peer_kexinit:
            raise CryptoException("No peer KEXINIT for negotiation")

        # Build client algorithms dict
        client_algs = {
            "kex_algorithms": self._cipher_suite.KEX_ALGORITHMS,
            "server_host_key_algorithms": self._cipher_suite.HOST_KEY_ALGORITHMS,
            "encryption_algorithms_client_to_server": self._cipher_suite.ENCRYPTION_ALGORITHMS,
            "encryption_algorithms_server_to_client": self._cipher_suite.ENCRYPTION_ALGORITHMS,
            "mac_algorithms_client_to_server": self._cipher_suite.MAC_ALGORITHMS,
            "mac_algorithms_server_to_client": self._cipher_suite.MAC_ALGORITHMS,
        }

        # Build server algorithms dict
        peer = self._transport._peer_kexinit
        server_algs = {
            "kex_algorithms": peer.kex_algorithms,
            "server_host_key_algorithms": peer.server_host_key_algorithms,
            "encryption_algorithms_client_to_server": peer.encryption_algorithms_client_to_server,
            "encryption_algorithms_server_to_client": peer.encryption_algorithms_server_to_client,
            "mac_algorithms_client_to_server": peer.mac_algorithms_client_to_server,
            "mac_algorithms_server_to_client": peer.mac_algorithms_server_to_client,
        }

        # Use CipherSuite to negotiate
        negotiated = self._cipher_suite.negotiate_algorithms(client_algs, server_algs)
        self._transport._logger.debug(f"Negotiated algorithms: {negotiated}")

        self._kex_algorithm = negotiated["kex"]
        self._server_host_key_algorithm = negotiated["server_host_key"]
        self._encryption_algorithm_c2s = negotiated["encryption_client_to_server"]
        self._encryption_algorithm_s2c = negotiated["encryption_server_to_client"]
        self._mac_algorithm_c2s = negotiated["mac_client_to_server"]
        self._mac_algorithm_s2c = negotiated["mac_server_to_client"]

        # Default compression to none
        self._compression_algorithm_c2s = COMPRESS_NONE
        self._compression_algorithm_s2c = COMPRESS_NONE

    def _perform_dh_group14_sha256(self) -> None:
        """Perform Diffie-Hellman Group 14 SHA256 key exchange."""
        # Generate DH parameters
        parameters = dh.DHParameterNumbers(
            self.DH_GROUP14_P, self.DH_GROUP14_G
        ).parameters()

        # Generate private key
        self._dh_private_key = parameters.generate_private_key()

        # Get public key
        dh_public_key = self._dh_private_key.public_key()
        public_numbers = dh_public_key.public_numbers()

        # Store public key value
        self._dh_public_key = public_numbers.y
        self._dh_public_key_mpint = write_mpint(public_numbers.y)

        # Ensure the public key is positive (SSH requirement)
        if self._dh_public_key <= 0:
            raise CryptoException("Invalid DH public key: must be positive")

        # Send KEXDH_INIT message
        kexdh_init = Message(MSG_KEXDH_INIT)
        if self._dh_public_key is None:
            raise CryptoException("DH public key not generated")
        kexdh_init.add_mpint(self._dh_public_key)
        self._transport._send_message(kexdh_init)

        # Receive KEXDH_REPLY
        reply_msg = self._transport._expect_message(MSG_KEXDH_REPLY)

        # Parse KEXDH_REPLY
        offset = 0
        server_host_key_blob, offset = read_string(reply_msg._data, offset)

        # Extract server's DH public key (f)
        server_public_int, offset = read_mpint(reply_msg._data, offset)

        # Encode f as mpint for exchange hash (RFC 4253 §8)
        server_dh_public_blob = write_mpint(server_public_int)

        signature_blob, offset = read_string(reply_msg._data, offset)

        # Store host key blob for transport
        self._transport._server_host_key_blob = server_host_key_blob

        # Validate server's public key
        if server_public_int <= 1 or server_public_int >= self.DH_GROUP14_P - 1:
            raise CryptoException("Invalid server DH public key")

        # Compute shared secret
        server_public_numbers = dh.DHPublicNumbers(
            server_public_int, parameters.parameter_numbers()
        )
        server_public_key = server_public_numbers.public_key()

        shared_secret_int = self._dh_private_key.exchange(server_public_key)
        self._shared_secret = write_mpint(int.from_bytes(shared_secret_int, "big"))

        # Compute exchange hash
        self._compute_exchange_hash(
            server_host_key_blob, server_dh_public_blob, signature_blob
        )

        # Set session ID (first exchange hash)
        if self._session_id is None:
            self._session_id = self._exchange_hash

        # Verify server signature
        self._verify_server_signature(server_host_key_blob, signature_blob)

    def _verify_server_signature(
        self, server_host_key_blob: bytes, signature_blob: bytes
    ) -> None:
        """Verify server host key signature."""
        from ..crypto.pkey import PKey

        try:
            server_key = PKey.from_string(server_host_key_blob)
            if self._exchange_hash is None:
                raise CryptoException("Exchange hash not computed")
            if not server_key.verify(signature_blob, self._exchange_hash):
                raise CryptoException("Server host key signature verification failed")
        except Exception as e:
            if isinstance(e, CryptoException):
                raise
            raise CryptoException(f"Failed to verify server signature: {e}") from e

    def _perform_ecdh(self) -> None:
        """Perform client-side ECDH key exchange (Generic)."""
        try:
            from cryptography.hazmat.primitives.asymmetric import ec

            # Select curve and hash based on algorithm
            curve: ec.EllipticCurve
            if self._kex_algorithm == KEX_ECDH_SHA2_NISTP256:
                curve = ec.SECP256R1()
                hash_algo_name = "sha256"
            elif self._kex_algorithm == KEX_ECDH_SHA2_NISTP384:
                curve = ec.SECP384R1()
                hash_algo_name = "sha384"
            elif self._kex_algorithm == KEX_ECDH_SHA2_NISTP521:
                curve = ec.SECP521R1()
                hash_algo_name = "sha512"
            else:
                raise CryptoException(
                    f"Unsupported ECDH algorithm: {self._kex_algorithm}"
                )

            # Generate ECDH key pair
            self._ecdh_private_key = ec.generate_private_key(curve)
            public_key = self._ecdh_private_key.public_key()

            # Get public key in uncompressed format
            self._ecdh_public_key_bytes = public_key.public_bytes(
                encoding=serialization.Encoding.X962,
                format=serialization.PublicFormat.UncompressedPoint,
            )

            # Send KEX_ECDH_INIT message
            kex_ecdh_init = Message(MSG_KEX_ECDH_INIT)
            kex_ecdh_init.add_string(self._ecdh_public_key_bytes)
            self._transport._send_message(kex_ecdh_init)

            # Receive KEX_ECDH_REPLY message
            reply_msg = self._transport._expect_message(MSG_KEX_ECDH_REPLY)

            # Parse KEX_ECDH_REPLY
            offset = 0
            server_host_key_blob, offset = read_string(reply_msg._data, offset)
            server_public_key_blob, offset = read_string(reply_msg._data, offset)
            signature_blob, offset = read_string(reply_msg._data, offset)

            # Store server host key
            self._transport._server_host_key_blob = server_host_key_blob

            # Perform key exchange
            server_public_key = ec.EllipticCurvePublicKey.from_encoded_point(
                curve, server_public_key_blob
            )
            shared_secret_bytes = self._ecdh_private_key.exchange(
                ec.ECDH(), server_public_key
            )
            self._shared_secret = write_mpint(
                int.from_bytes(shared_secret_bytes, "big")
            )

            # Compute exchange hash
            self._compute_ecdh_exchange_hash(
                server_host_key_blob,
                server_public_key_blob,
                signature_blob,
                hash_name=hash_algo_name,
            )

            if self._session_id is None:
                self._session_id = self._exchange_hash

            # Verify server signature
            self._verify_server_signature(server_host_key_blob, signature_blob)

        except Exception as e:
            curve_label = "P-256"
            if self._kex_algorithm == KEX_ECDH_SHA2_NISTP384:
                curve_label = "P-384"
            elif self._kex_algorithm == KEX_ECDH_SHA2_NISTP521:
                curve_label = "P-521"
            raise CryptoException(f"ECDH {curve_label} client KEX failed: {e}") from e

    def _compute_ecdh_exchange_hash(
        self,
        server_host_key: bytes,
        server_public_key: bytes,
        signature: bytes,
        client_ecdh_public_key: Optional[bytes] = None,
        hash_name: str = "sha256",
    ) -> None:
        """Compute the exchange hash H for ECDH.

        client_ecdh_public_key overrides self._ecdh_public_key_bytes, allowing
        the server-side path to pass in the client's key without mutating state.
        """
        hash_data = bytearray()

        # Client version string
        if self._transport._client_version is None:
            raise CryptoException(
                "Client version string not set for ECDH exchange hash"
            )
        hash_data.extend(write_string(self._transport._client_version))

        # Server version string
        if self._transport._server_version is None:
            raise CryptoException(
                "Server version string not set for ECDH exchange hash"
            )
        hash_data.extend(write_string(self._transport._server_version))

        # Client KEXINIT
        if self._client_kexinit is None:
            raise CryptoException("Missing client KEXINIT for ECDH exchange hash")
        hash_data.extend(write_string(self._client_kexinit))

        # Server KEXINIT
        if self._server_kexinit is None:
            raise CryptoException("Missing server KEXINIT for ECDH exchange hash")
        hash_data.extend(write_string(self._server_kexinit))

        # Server host key
        hash_data.extend(write_string(server_host_key))

        # Client public key
        client_pub = (
            client_ecdh_public_key
            if client_ecdh_public_key is not None
            else self._ecdh_public_key_bytes
        )
        if client_pub is None:
            raise CryptoException("Missing ECDH client public key for exchange hash")
        hash_data.extend(write_string(client_pub))

        # Server public key
        hash_data.extend(write_string(server_public_key))

        # Shared secret
        if self._shared_secret is None:
            raise CryptoException("Missing shared secret for ECDH exchange hash")
        hash_data.extend(self._shared_secret)

        # Compute hash
        self._exchange_hash = default_crypto_backend.hash_data(
            hash_name, bytes(hash_data)
        )

    def _perform_curve25519_sha256_server(self) -> None:
        """Perform Curve25519 SHA256 key exchange on the server side."""
        try:
            from cryptography.hazmat.primitives.asymmetric import x25519

            # 1. Receive KEX_ECDH_INIT
            init_msg = self._transport._expect_message(MSG_KEX_ECDH_INIT)
            client_public_key_blob, _ = read_string(init_msg._data, 0)

            # 2. Generate server Curve25519 key pair
            self._curve25519_private_key = x25519.X25519PrivateKey.generate()
            server_public_key = self._curve25519_private_key.public_key()
            server_public_key_bytes = server_public_key.public_bytes(
                encoding=serialization.Encoding.Raw,
                format=serialization.PublicFormat.Raw,
            )

            # 3. Compute shared secret
            client_public_key = x25519.X25519PublicKey.from_public_bytes(
                client_public_key_blob
            )
            shared_secret_bytes = self._curve25519_private_key.exchange(
                client_public_key
            )
            self._shared_secret = write_mpint(
                int.from_bytes(shared_secret_bytes, "big")
            )

            # 4. Get server host key blob
            server_host_key = self._transport._server_key
            server_host_key_blob = server_host_key.get_public_key_bytes()

            # 5. Compute exchange hash H
            self._compute_curve25519_exchange_hash(
                server_host_key_blob, client_public_key_blob, server_public_key_bytes
            )

            # 6. Sign exchange hash
            if self._exchange_hash is None:
                raise CryptoException("Exchange hash not computed")
            signature_blob = self._sign_exchange_hash(self._exchange_hash)

            # 7. Send KEX_ECDH_REPLY
            reply_msg = Message(MSG_KEX_ECDH_REPLY)
            reply_msg.add_string(server_host_key_blob)
            reply_msg.add_string(server_public_key_bytes)
            reply_msg.add_string(signature_blob)
            self._transport._send_message(reply_msg)

            # Set session ID
            if self._session_id is None:
                self._session_id = self._exchange_hash

        except Exception as e:
            raise CryptoException(f"Curve25519 server KEX failed: {e}") from e

    def _perform_dh_group14_sha256_server(self) -> None:
        """Perform server-side Diffie-Hellman Group 14 SHA256 key exchange."""
        try:
            # 1. Receive MSG_KEXDH_INIT (30)
            init_msg = self._transport._expect_message(MSG_KEXDH_INIT)
            client_public_key_int, _ = read_mpint(init_msg._data, 0)

            # 2. Generate DH parameters and private key
            parameters = dh.DHParameterNumbers(
                self.DH_GROUP14_P, self.DH_GROUP14_G
            ).parameters()
            self._dh_private_key = parameters.generate_private_key()

            # 3. Get server public key (f)
            server_public_key = self._dh_private_key.public_key()
            server_public_numbers = server_public_key.public_numbers()
            self._dh_public_key = server_public_numbers.y
            self._dh_public_key_mpint = write_mpint(server_public_numbers.y)

            # 4. Compute shared secret (K)
            client_public_numbers = dh.DHPublicNumbers(
                client_public_key_int, parameters.parameter_numbers()
            )
            client_public_key_obj = client_public_numbers.public_key()
            shared_secret_int = self._dh_private_key.exchange(client_public_key_obj)
            self._shared_secret = write_mpint(int.from_bytes(shared_secret_int, "big"))

            # 5. Get server host key blob (K_S)
            server_host_key = self._transport._server_key
            server_host_key_blob = server_host_key.get_public_key_bytes()

            # 6. Compute exchange hash H - pass client key explicitly to avoid mutating state
            self._compute_exchange_hash(
                server_host_key_blob,
                write_mpint(server_public_numbers.y),  # f as mpint (RFC 4253 §8)
                b"",  # signature not used during hash computation itself
                client_dh_public_mpint=write_mpint(client_public_key_int),
            )

            # 7. Sign exchange hash
            if self._exchange_hash is None:
                raise CryptoException("Exchange hash not computed")
            signature_blob = self._sign_exchange_hash(self._exchange_hash)

            # 8. Send MSG_KEXDH_REPLY (31)
            reply_msg = Message(MSG_KEXDH_REPLY)
            reply_msg.add_string(server_host_key_blob)
            reply_msg.add_string(write_mpint(server_public_numbers.y))
            reply_msg.add_string(signature_blob)
            self._transport._send_message(reply_msg)

            # Set session ID
            if self._session_id is None:
                self._session_id = self._exchange_hash

        except Exception as e:
            raise CryptoException(f"DH Group 14 server KEX failed: {e}") from e

    def _perform_ecdh_server(self) -> None:
        """Perform server-side ECDH key exchange (Generic)."""
        try:
            from cryptography.hazmat.primitives.asymmetric import ec

            # Select curve and hash based on algorithm
            curve: ec.EllipticCurve
            if self._kex_algorithm == KEX_ECDH_SHA2_NISTP256:
                curve = ec.SECP256R1()
                hash_algo_name = "sha256"
            elif self._kex_algorithm == KEX_ECDH_SHA2_NISTP384:
                curve = ec.SECP384R1()
                hash_algo_name = "sha384"
            elif self._kex_algorithm == KEX_ECDH_SHA2_NISTP521:
                curve = ec.SECP521R1()
                hash_algo_name = "sha512"
            else:
                raise CryptoException(
                    f"Unsupported ECDH algorithm: {self._kex_algorithm}"
                )

            # 1. Receive KEX_ECDH_INIT
            init_msg = self._transport._expect_message(MSG_KEX_ECDH_INIT)
            client_public_key_blob, _ = read_string(init_msg._data, 0)

            # 2. Generate server ECDH key pair
            self._ecdh_private_key = ec.generate_private_key(curve)
            server_public_key = self._ecdh_private_key.public_key()
            self._ecdh_public_key_bytes = server_public_key.public_bytes(
                encoding=serialization.Encoding.X962,
                format=serialization.PublicFormat.UncompressedPoint,
            )

            # 3. Compute shared secret
            client_public_key_obj = ec.EllipticCurvePublicKey.from_encoded_point(
                curve, client_public_key_blob
            )
            shared_secret_bytes = self._ecdh_private_key.exchange(
                ec.ECDH(), client_public_key_obj
            )
            self._shared_secret = write_mpint(
                int.from_bytes(shared_secret_bytes, "big")
            )

            # 4. Get server host key blob
            server_host_key = self._transport._server_key
            server_host_key_blob = server_host_key.get_public_key_bytes()

            # 5. Compute exchange hash
            self._compute_ecdh_exchange_hash(
                server_host_key_blob,
                self._ecdh_public_key_bytes,
                b"",
                client_ecdh_public_key=client_public_key_blob,
                hash_name=hash_algo_name,
            )

            # 6. Sign exchange hash
            if self._exchange_hash is None:
                raise CryptoException("Exchange hash not computed")
            signature_blob = self._sign_exchange_hash(self._exchange_hash)

            # 7. Send KEX_ECDH_REPLY
            reply_msg = Message(MSG_KEX_ECDH_REPLY)
            reply_msg.add_string(server_host_key_blob)
            reply_msg.add_string(self._ecdh_public_key_bytes)
            reply_msg.add_string(signature_blob)
            self._transport._send_message(reply_msg)

            # 8. Set session ID
            if self._session_id is None:
                self._session_id = self._exchange_hash

        except Exception as e:
            curve_label = "P-256"
            if self._kex_algorithm == KEX_ECDH_SHA2_NISTP384:
                curve_label = "P-384"
            elif self._kex_algorithm == KEX_ECDH_SHA2_NISTP521:
                curve_label = "P-521"
            raise CryptoException(f"ECDH {curve_label} server KEX failed: {e}") from e

    # Backward compatibility for tests and internal callers
    def _perform_ecdh_sha2_nistp256(self) -> None:
        """Alias for _perform_ecdh with NIST P-256."""
        self._kex_algorithm = KEX_ECDH_SHA2_NISTP256
        self._perform_ecdh()

    def _perform_ecdh_sha2_nistp256_server(self) -> None:
        """Alias for _perform_ecdh_server with NIST P-256."""
        self._kex_algorithm = KEX_ECDH_SHA2_NISTP256
        self._perform_ecdh_server()

    def _sign_exchange_hash(self, exchange_hash: bytes) -> bytes:
        """Sign exchange hash using server private key."""
        server_key = self._transport._server_key
        if server_key is None:
            raise CryptoException("Server key not set - cannot sign exchange hash")
        signature = server_key.sign(exchange_hash)
        if signature is None:
            raise CryptoException("Failed to sign exchange hash")
        return signature  # type: ignore[no-any-return]

    def _perform_curve25519_sha256(self) -> None:
        """Perform client-side Curve25519 SHA256 key exchange."""
        try:
            from cryptography.hazmat.primitives.asymmetric import x25519

            # Generate Curve25519 key pair
            self._curve25519_private_key = x25519.X25519PrivateKey.generate()
            public_key = self._curve25519_private_key.public_key()
            client_public_key_bytes = public_key.public_bytes(
                encoding=serialization.Encoding.Raw,
                format=serialization.PublicFormat.Raw,
            )

            # Send KEX_ECDH_INIT
            kex_ecdh_init = Message(MSG_KEX_ECDH_INIT)
            kex_ecdh_init.add_string(client_public_key_bytes)
            self._transport._send_message(kex_ecdh_init)

            # Receive KEX_ECDH_REPLY
            reply_msg = self._transport._expect_message(MSG_KEX_ECDH_REPLY)
            offset = 0
            server_host_key_blob, offset = read_string(reply_msg._data, offset)
            server_public_key_blob, offset = read_string(reply_msg._data, offset)
            signature_blob, offset = read_string(reply_msg._data, offset)

            # Store host key blob for transport
            self._transport._server_host_key_blob = server_host_key_blob

            # Perform DH
            server_public_key = x25519.X25519PublicKey.from_public_bytes(
                server_public_key_blob
            )
            shared_secret_bytes = self._curve25519_private_key.exchange(
                server_public_key
            )
            self._shared_secret = write_mpint(
                int.from_bytes(shared_secret_bytes, "big")
            )

            # Compute hash (Client order: host_key, client_pub, server_pub)
            self._compute_curve25519_exchange_hash(
                server_host_key_blob, client_public_key_bytes, server_public_key_blob
            )

            if self._session_id is None:
                self._session_id = self._exchange_hash

            self._verify_server_signature(server_host_key_blob, signature_blob)
        except Exception as e:
            raise CryptoException(f"Curve25519 client KEX failed: {e}") from e

    def _compute_curve25519_exchange_hash(
        self, server_host_key: bytes, client_public_key: bytes, server_public_key: bytes
    ) -> None:
        """Compute the exchange hash H for Curve25519."""
        if self._transport._client_version is None:
            raise CryptoException(
                "Client version string not set for Curve25519 exchange hash"
            )
        if self._transport._server_version is None:
            raise CryptoException(
                "Server version string not set for Curve25519 exchange hash"
            )
        if self._client_kexinit is None:
            raise CryptoException("Missing client KEXINIT for Curve25519 exchange hash")
        if self._server_kexinit is None:
            raise CryptoException("Missing server KEXINIT for Curve25519 exchange hash")
        if self._shared_secret is None:
            raise CryptoException("Missing shared secret for Curve25519 exchange hash")
        hash_data = bytearray()
        hash_data.extend(write_string(self._transport._client_version))
        hash_data.extend(write_string(self._transport._server_version))
        hash_data.extend(write_string(self._client_kexinit))
        hash_data.extend(write_string(self._server_kexinit))
        hash_data.extend(write_string(server_host_key))
        hash_data.extend(write_string(client_public_key))
        hash_data.extend(write_string(server_public_key))
        hash_data.extend(self._shared_secret)

        self._exchange_hash = default_crypto_backend.hash_data(
            "sha256", bytes(hash_data)
        )

    def _compute_exchange_hash(
        self,
        server_host_key: bytes,
        server_dh_public: bytes,
        signature: bytes,
        client_dh_public_mpint: Optional[bytes] = None,
    ) -> None:
        """Compute the exchange hash H for DH key exchange.

        client_dh_public_mpint overrides self._dh_public_key_mpint, allowing
        the server-side path to pass in the client's key without mutating state.
        """
        hash_data = bytearray()

        # Client version string
        if self._transport._client_version is None:
            raise CryptoException("Client version string not set for DH exchange hash")
        hash_data.extend(write_string(self._transport._client_version))

        # Server version string
        if self._transport._server_version is None:
            raise CryptoException("Server version string not set for DH exchange hash")
        hash_data.extend(write_string(self._transport._server_version))

        # Client KEXINIT
        if self._client_kexinit is None:
            raise CryptoException("Missing client KEXINIT")
        hash_data.extend(write_string(self._client_kexinit))

        # Server KEXINIT
        if self._server_kexinit is None:
            raise CryptoException("Missing server KEXINIT")
        hash_data.extend(write_string(self._server_kexinit))

        # Server host key
        hash_data.extend(write_string(server_host_key))

        # Client DH public key (e)
        client_mpint = (
            client_dh_public_mpint
            if client_dh_public_mpint is not None
            else self._dh_public_key_mpint
        )
        if client_mpint is None:
            raise CryptoException("Missing DH client public key")
        hash_data.extend(client_mpint)

        # Server DH public key (f) as mpint (RFC 4253 §8)
        hash_data.extend(server_dh_public)

        # Shared secret
        if self._shared_secret is None:
            raise CryptoException("Missing shared secret")
        hash_data.extend(self._shared_secret)

        # Compute SHA256 hash
        self._exchange_hash = default_crypto_backend.hash_data(
            "sha256", bytes(hash_data)
        )

    def _generate_session_keys(self) -> None:
        """Generate session keys from shared secret and exchange hash."""
        # First-time handshake: session_id is equal to first exchange_hash
        effective_session_id = self._session_id or self._exchange_hash

        if (
            not self._shared_secret
            or not self._exchange_hash
            or not effective_session_id
            or self._encryption_algorithm_c2s is None
            or self._encryption_algorithm_s2c is None
        ):
            raise CryptoException("Missing key exchange data for key generation")

        # Get key lengths from negotiated ciphers
        c2s_cipher_info = self._cipher_suite.get_cipher_info(
            self._encryption_algorithm_c2s
        )
        s2c_cipher_info = self._cipher_suite.get_cipher_info(
            self._encryption_algorithm_s2c
        )

        key_len_c2s = c2s_cipher_info["key_len"]
        iv_len_c2s = c2s_cipher_info["iv_len"]
        key_len_s2c = s2c_cipher_info["key_len"]
        iv_len_s2c = s2c_cipher_info["iv_len"]

        # Get MAC key lengths
        mac_key_len_c2s = 0
        if self._mac_algorithm_c2s != "none" and self._mac_algorithm_c2s is not None:
            mac_key_len_c2s = self._cipher_suite.get_mac_info(self._mac_algorithm_c2s)[
                "key_len"
            ]

        mac_key_len_s2c = 0
        if self._mac_algorithm_s2c != "none" and self._mac_algorithm_s2c is not None:
            mac_key_len_s2c = self._cipher_suite.get_mac_info(self._mac_algorithm_s2c)[
                "key_len"
            ]

        # Choose hash algorithm based on KEX algorithm
        hash_alg = "sha256"
        if self._kex_algorithm:
            if "nistp384" in self._kex_algorithm:
                hash_alg = "sha384"
            elif "nistp521" in self._kex_algorithm or "sha512" in self._kex_algorithm:
                hash_alg = "sha512"

        # Generate keys using SSH key derivation
        # A: IV client to server
        self._iv_c2s = default_crypto_backend.derive_key(
            hash_alg,
            self._shared_secret,
            self._exchange_hash,
            effective_session_id,
            b"A",
            iv_len_c2s,
        )

        # B: IV server to client
        self._iv_s2c = default_crypto_backend.derive_key(
            hash_alg,
            self._shared_secret,
            self._exchange_hash,
            effective_session_id,
            b"B",
            iv_len_s2c,
        )

        # C: Encryption key client to server
        self._encryption_key_c2s = default_crypto_backend.derive_key(
            hash_alg,
            self._shared_secret,
            self._exchange_hash,
            effective_session_id,
            b"C",
            key_len_c2s,
        )

        # D: Encryption key server to client
        self._encryption_key_s2c = default_crypto_backend.derive_key(
            hash_alg,
            self._shared_secret,
            self._exchange_hash,
            effective_session_id,
            b"D",
            key_len_s2c,
        )

        # E: MAC key client to server
        self._mac_key_c2s = b""
        if mac_key_len_c2s > 0:
            self._mac_key_c2s = default_crypto_backend.derive_key(
                hash_alg,
                self._shared_secret,
                self._exchange_hash,
                effective_session_id,
                b"E",
                mac_key_len_c2s,
            )

        # F: MAC key server to client
        self._mac_key_s2c = b""
        if mac_key_len_s2c > 0:
            self._mac_key_s2c = default_crypto_backend.derive_key(
                hash_alg,
                self._shared_secret,
                self._exchange_hash,
                effective_session_id,
                b"F",
                mac_key_len_s2c,
            )

        # Update transport with keys and parameters
        self._transport._encryption_key_c2s = self._encryption_key_c2s
        self._transport._encryption_key_s2c = self._encryption_key_s2c
        self._transport._mac_key_c2s = self._mac_key_c2s
        self._transport._mac_key_s2c = self._mac_key_s2c
        self._transport._iv_c2s = self._iv_c2s
        self._transport._iv_s2c = self._iv_s2c
        self._transport._cipher_c2s = self._encryption_algorithm_c2s
        self._transport._cipher_s2c = self._encryption_algorithm_s2c
        self._transport._mac_c2s = self._mac_algorithm_c2s
        self._transport._mac_s2c = self._mac_algorithm_s2c
        if self._transport._session_id is None:
            self._transport._session_id = self._session_id

    def _send_newkeys(self) -> None:
        """Send NEWKEYS message to activate new keys."""
        newkeys_msg = Message(MSG_NEWKEYS)
        self._transport._send_message(newkeys_msg)

    def _receive_newkeys(self) -> None:
        """Receive NEWKEYS message from server."""
        self._transport._expect_message(MSG_NEWKEYS)

    def generate_keys(self) -> tuple[bytes, bytes, bytes, bytes]:
        """
        Return session keys derived during key exchange.

        .. deprecated::
            Access keys via the Transport object after key exchange completes.
            This method will be removed in v1.0.

        Returns:
            Tuple of (encryption_key_c2s, encryption_key_s2c, mac_key_c2s, mac_key_s2c)

        Raises:
            CryptoException: If key exchange has not been completed yet
        """
        import warnings

        warnings.warn(
            "KeyExchange.generate_keys() is deprecated and will be removed in v1.0. "
            "Access keys via the Transport object after key exchange completes.",
            DeprecationWarning,
            stacklevel=2,
        )
        if not hasattr(self, "_encryption_key_c2s") or not all(
            [
                self._encryption_key_c2s,
                self._encryption_key_s2c,
                self._mac_key_c2s,
                self._mac_key_s2c,
            ]
        ):
            raise CryptoException("Keys not generated - run key exchange first")

        return (
            self._encryption_key_c2s,
            self._encryption_key_s2c,
            self._mac_key_c2s,
            self._mac_key_s2c,
        )
Methods:
__init__(transport)

Initialize key exchange with transport.

Parameters:

Name Type Description Default
transport Any

SSH transport instance

required
Source code in spindlex/transport/kex.py
def __init__(self, transport: Any) -> None:
    """
    Initialize key exchange with transport.

    Args:
        transport: SSH transport instance
    """
    self._transport = transport
    self._algorithm: Optional[str] = None
    self._shared_secret: Optional[bytes] = None
    self._exchange_hash: Optional[bytes] = None
    self._session_id: Optional[bytes] = None

    # Cipher suite for negotiation and info
    self._cipher_suite = CipherSuite(default_crypto_backend)

    # Key exchange state
    self._client_kexinit: Optional[bytes] = None
    self._server_kexinit: Optional[bytes] = None
    self._dh_private_key: Optional[Any] = None
    self._dh_public_key: Optional[int] = None
    self._dh_public_key_mpint: Optional[bytes] = None
    self._server_public_key: Optional[bytes] = None

    # Negotiated algorithms
    self._kex_algorithm: Optional[str] = None
    self._server_host_key_algorithm: Optional[str] = None
    self._encryption_algorithm_c2s: Optional[str] = None
    self._encryption_algorithm_s2c: Optional[str] = None
    self._mac_algorithm_c2s: Optional[str] = None
    self._mac_algorithm_s2c: Optional[str] = None
    self._compression_algorithm_c2s: Optional[str] = None
    self._compression_algorithm_s2c: Optional[str] = None
generate_keys()

Return session keys derived during key exchange.

.. deprecated:: Access keys via the Transport object after key exchange completes. This method will be removed in v1.0.

Returns:

Type Description
tuple[bytes, bytes, bytes, bytes]

Tuple of (encryption_key_c2s, encryption_key_s2c, mac_key_c2s, mac_key_s2c)

Raises:

Type Description
CryptoException

If key exchange has not been completed yet

Source code in spindlex/transport/kex.py
def generate_keys(self) -> tuple[bytes, bytes, bytes, bytes]:
    """
    Return session keys derived during key exchange.

    .. deprecated::
        Access keys via the Transport object after key exchange completes.
        This method will be removed in v1.0.

    Returns:
        Tuple of (encryption_key_c2s, encryption_key_s2c, mac_key_c2s, mac_key_s2c)

    Raises:
        CryptoException: If key exchange has not been completed yet
    """
    import warnings

    warnings.warn(
        "KeyExchange.generate_keys() is deprecated and will be removed in v1.0. "
        "Access keys via the Transport object after key exchange completes.",
        DeprecationWarning,
        stacklevel=2,
    )
    if not hasattr(self, "_encryption_key_c2s") or not all(
        [
            self._encryption_key_c2s,
            self._encryption_key_s2c,
            self._mac_key_c2s,
            self._mac_key_s2c,
        ]
    ):
        raise CryptoException("Keys not generated - run key exchange first")

    return (
        self._encryption_key_c2s,
        self._encryption_key_s2c,
        self._mac_key_c2s,
        self._mac_key_s2c,
    )
start_kex()

Start key exchange process.

Note: KEXINIT exchange should already be completed by transport layer.

Raises:

Type Description
CryptoException

If key exchange fails

Source code in spindlex/transport/kex.py
def start_kex(self) -> None:
    """
    Start key exchange process.

    Note: KEXINIT exchange should already be completed by transport layer.

    Raises:
        CryptoException: If key exchange fails
    """
    try:
        # Transport layer must complete KEXINIT exchange before calling start_kex().
        if not self._transport._peer_kexinit:
            raise CryptoException(
                "Peer KEXINIT not received — transport must exchange KEXINIT "
                "before invoking KeyExchange.start_kex()"
            )

        peer_kexinit_blob = self._transport._peer_kexinit.pack()
        our_kexinit_blob = self._transport._client_kexinit_blob

        if self._transport._server_mode:
            self._client_kexinit = peer_kexinit_blob
            self._server_kexinit = our_kexinit_blob
        else:
            self._client_kexinit = our_kexinit_blob
            self._server_kexinit = peer_kexinit_blob

        # Negotiate algorithms
        self._negotiate_algorithms()

        # Perform key exchange based on negotiated algorithm
        if self._transport._server_mode:
            self._perform_server_kex()
        else:
            self._perform_client_kex()

        # Generate session keys
        self._generate_session_keys()

        # Send NEWKEYS message
        self._send_newkeys()

        # Receive NEWKEYS message
        self._receive_newkeys()

    except Exception as e:
        if isinstance(e, (CryptoException, ProtocolException)):
            raise
        raise CryptoException(f"Key exchange failed: {e}") from e

Functions: