Skip to content

Commit 7944f61

Browse files
committed
support multiple and discrete port ranges
1 parent e4e0bbd commit 7944f61

File tree

4 files changed

+180
-58
lines changed

4 files changed

+180
-58
lines changed

README.cn.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ tssh 和 tsshd 的工作方式与 ssh 完全相同,没有计划支持本地回
3838

3939
2. 在服务端(远程机器)上安装 [tsshd](https://github.com/trzsz/tsshd?tab=readme-ov-file#installation)
4040

41-
3. 使用 `tssh --udp xxx` 登录服务器。在 `~/.ssh/config` 中如下配置可省略 `--udp` 参数
41+
3. 使用 `tssh --udp xxx` 登录服务器(对延迟敏感可指定 `--kcp` 选项)。在 `~/.ssh/config` 中如下配置可省略 `--udp` `--kcp` 选项
4242

4343
```
4444
Host xxx
45-
#!! UdpMode yes
45+
#!! UdpMode Yes/QUIC/KCP
4646
```
4747

4848
## 原理简介
@@ -51,7 +51,7 @@ tssh 和 tsshd 的工作方式与 ssh 完全相同,没有计划支持本地回
5151

5252
- `tssh` 会先作为一个 ssh 客户端正常登录到服务器上,然后在服务器上启动一个新的 `tsshd` 进程。
5353

54-
- `tsshd` 进程会随机侦听一个 61001 到 61999 之间的 UDP 端口(可通过 `UdpPort` 配置自定义),并将其端口和几个密钥通过 ssh 通道发回给 `tssh` 进程。登录的 ssh 连接会被关闭,然后 `tssh` 进程通过 UDP 与 `tsshd` 进程通讯。
54+
- `tsshd` 进程会随机侦听一个 61001 到 61999 之间的 UDP 端口(可通过 `TsshdPort` 配置自定义),并将其端口和几个密钥通过 ssh 通道发回给 `tssh` 进程。登录的 ssh 连接会被关闭,然后 `tssh` 进程通过 UDP 与 `tsshd` 进程通讯。
5555

5656
## 重连架构
5757

@@ -98,7 +98,7 @@ tssh 和 tsshd 的工作方式与 ssh 完全相同,没有计划支持本地回
9898
```
9999
Host xxx
100100
#!! UdpMode Yes
101-
#!! UdpPort 61001-61999
101+
#!! TsshdPort 61001-61999
102102
#!! TsshdPath ~/go/bin/tsshd
103103
#!! UdpAliveTimeout 86400
104104
#!! UdpHeartbeatTimeout 3
@@ -110,9 +110,9 @@ Host xxx
110110

111111
- `UdpMode`: `No` (默认为`No`: tssh 工作在 TCP 模式), `Yes` (默认协议: `QUIC`), `QUIC` ([QUIC](https://github.com/quic-go/quic-go) 协议:速度更快), `KCP` ([KCP](https://github.com/xtaci/kcp-go) 协议:延迟更低).
112112

113-
- `UdpPort`: 指定 tsshd 监听的 UDP 端口范围,默认值为 [61001, 61999]
113+
- `TsshdPort`: 指定 tsshd 监听的端口范围,默认值为 [61001, 61999]。支持指定离散的端口列表(如`6022,7022`),也支持指定离散的端口范围(如`8010-8020,9020-9030,10080`),tsshd 会随机监听其中一个空闲的端口。也可在命令行中使用 `--tsshd-port` 指定端口
114114

115-
- `TsshdPath`: 指定服务器上 tsshd 二进制程序的路径,如果未配置,则在 $PATH 中查找。
115+
- `TsshdPath`: 指定服务器上 tsshd 二进制程序的路径,如果未配置,则在 $PATH 中查找。也可在命令行中使用 `--tsshd-path` 指定路径。
116116

117117
- `UdpAliveTimeout`: 如果断开连接的时间超过 `UdpAliveTimeout` 秒,tssh 和 tsshd 都会退出,不再支持重连。默认值为 86400 秒。
118118

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ tssh and tsshd works exactly like ssh, there are no plans to support local echo
3939

4040
2. Install [tsshd](https://github.com/trzsz/tsshd?tab=readme-ov-file#installation) on the server ( the remote host ).
4141

42-
3. Use `tssh --udp xxx` to login to the server. Or configure as follows in `~/.ssh/config` to omit `--udp`:
42+
3. Use `tssh --udp xxx` to log in (latency-sensitive users can specify `--kcp` option). Or configure as follows in `~/.ssh/config` to omit `--udp` or `--kcp` option:
4343

4444
```
4545
Host xxx
46-
#!! UdpMode yes
46+
#!! UdpMode Yes/QUIC/KCP
4747
```
4848

4949
### How it works
@@ -52,7 +52,7 @@ tssh and tsshd works exactly like ssh, there are no plans to support local echo
5252

5353
- The `tssh` will first login to the server normally as an ssh client, and then run a new `tsshd` process on the server.
5454

55-
- The `tsshd` process listens on a random udp port between 61001 and 61999 (can be customized by `UdpPort`), and sends its port number and some secret keys back to the `tssh` process over the ssh channel. The ssh connection is then shut down, and the `tssh` process communicates with the `tsshd` process over udp.
55+
- The `tsshd` process listens on a random udp port between 61001 and 61999 (can be customized by `TsshdPort`), and sends its port number and some secret keys back to the `tssh` process over the ssh channel. The ssh connection is then shut down, and the `tssh` process communicates with the `tsshd` process over udp.
5656

5757
### Reconnection
5858

@@ -99,7 +99,7 @@ tssh and tsshd works exactly like ssh, there are no plans to support local echo
9999
```
100100
Host xxx
101101
#!! UdpMode Yes
102-
#!! UdpPort 61001-61999
102+
#!! TsshdPort 61001-61999
103103
#!! TsshdPath ~/go/bin/tsshd
104104
#!! UdpAliveTimeout 86400
105105
#!! UdpHeartbeatTimeout 3
@@ -111,9 +111,9 @@ Host xxx
111111

112112
- `UdpMode`: `No` (the default: tssh works in TCP mode), `Yes` (default protocol: `QUIC`), `QUIC` ([QUIC](https://github.com/quic-go/quic-go) protocol: faster speed), `KCP` ([KCP](https://github.com/xtaci/kcp-go) protocol: lower latency).
113113

114-
- `UdpPort`: Specifies the range of UDP ports that tsshd listens on, the default value is [61001, 61999].
114+
- `TsshdPort`: Specifies the port range that tsshd listens on, default is [61001, 61999]. You can specify multiple discrete ports (e.g., `6022,7022`) or multiple discrete ranges (e.g., `8010-8020,9020-9030,10080`); tsshd will randomly choose an available port. You can also specify the port on the command line using `--tsshd-port`.
115115

116-
- `TsshdPath`: Specifies the path to the tsshd binary on the server, lookup in $PATH if not configured.
116+
- `TsshdPath`: Specifies the path to the tsshd binary on the server, lookup in $PATH if not configured. You can also specify the path on the command line using `--tsshd-path`.
117117

118118
- `UdpAliveTimeout`: If the disconnection lasts longer than `UdpAliveTimeout` in seconds, tssh and tsshd will both exit, and no longer support reconnection. The default is 86400 seconds.
119119

tsshd/server.go

Lines changed: 103 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import (
4242
"strconv"
4343
"strings"
4444
"time"
45-
"unicode"
4645

4746
"github.com/quic-go/quic-go"
4847
"github.com/xtaci/kcp-go/v5"
@@ -58,10 +57,7 @@ const (
5857
kProxyModeTCP = "TCP"
5958
)
6059

61-
const (
62-
kDefaultPortRangeLow = 61001
63-
kDefaultPortRangeHigh = 61999
64-
)
60+
const kDefaultPortRange = "61001-61999"
6561

6662
const (
6763
kDefaultMTU = 1400
@@ -120,25 +116,71 @@ func initServer(args *tsshdArgs) (*kcp.Listener, *quic.Listener, error) {
120116
return kcpListener, quicListener, nil
121117
}
122118

123-
func getPortRange(args *tsshdArgs) (int, int) {
124-
if args.Port == "" {
125-
return kDefaultPortRangeLow, kDefaultPortRangeHigh
126-
}
127-
ports := strings.FieldsFunc(args.Port, func(c rune) bool {
128-
return unicode.IsSpace(c) || c == ',' || c == '-'
129-
})
130-
if len(ports) == 1 {
131-
if port, err := strconv.Atoi(ports[0]); err == nil {
132-
return port, port
119+
func parsePortRanges(tsshdPort string) [][2]uint16 {
120+
var ranges [][2]uint16
121+
122+
addPortRange := func(lowPort string, highPort *string) {
123+
low, err := strconv.ParseUint(lowPort, 10, 16)
124+
if err != nil || low == 0 {
125+
warning("tsshd port [%s] invalid: port [%s] is not a value in [1, 65535]", tsshdPort, lowPort)
126+
return
127+
}
128+
high := low
129+
if highPort != nil {
130+
high, err = strconv.ParseUint(*highPort, 10, 16)
131+
if err != nil || high == 0 {
132+
warning("tsshd port [%s] invalid: port [%s] is not a value in [1, 65535]", tsshdPort, *highPort)
133+
return
134+
}
133135
}
134-
} else if len(ports) == 2 {
135-
port0, err0 := strconv.Atoi(ports[0])
136-
port1, err1 := strconv.Atoi(ports[1])
137-
if err0 == nil && err1 == nil {
138-
return port0, port1
136+
if low > high {
137+
warning("tsshd port [%s] invalid: port range [%d-%d] is invalid (low > high)", tsshdPort, low, high)
138+
return
139+
}
140+
ranges = append(ranges, [2]uint16{uint16(low), uint16(high)})
141+
}
142+
143+
for seg := range strings.SplitSeq(tsshdPort, ",") {
144+
tokens := strings.Fields(seg)
145+
k := -1
146+
for i := 0; i < len(tokens); i++ {
147+
token := tokens[i]
148+
// Case 1: combined form like "8000-9000"
149+
if strings.Contains(token, "-") && token != "-" {
150+
parts := strings.Split(token, "-")
151+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
152+
warning("tsshd port [%s] invalid: malformed port range [%s]", tsshdPort, token)
153+
continue
154+
}
155+
addPortRange(parts[0], &parts[1])
156+
continue
157+
}
158+
// Case 2: single "-"
159+
if token == "-" {
160+
if i == 0 || i+1 >= len(tokens) || i-1 <= k {
161+
warning("tsshd port [%s] invalid: '-' must appear between two ports", tsshdPort)
162+
i++
163+
continue
164+
}
165+
addPortRange(tokens[i-1], &tokens[i+1])
166+
k = i + 1
167+
i++ // skip high
168+
continue
169+
}
170+
// Case 3: part of a range: skip (handled by '-')
171+
if i+1 < len(tokens) && tokens[i+1] == "-" {
172+
continue
173+
}
174+
// Case 4: plain number
175+
if i > 0 && tokens[i-1] == "-" {
176+
warning("tsshd port [%s] invalid: malformed port range [- %s]", tsshdPort, token)
177+
continue
178+
}
179+
addPortRange(token, nil)
139180
}
140181
}
141-
return kDefaultPortRangeLow, kDefaultPortRangeHigh
182+
183+
return ranges
142184
}
143185

144186
func canListenOnIP(args *tsshdArgs, udpAddr *net.UDPAddr) bool {
@@ -228,42 +270,57 @@ func getUdpAddrs(args *tsshdArgs) ([]*net.UDPAddr, error) {
228270
}
229271

230272
func listenOnFreePort(args *tsshdArgs) ([]io.Closer, int, error) {
231-
portRangeLow, portRangeHigh := getPortRange(args)
232-
if portRangeHigh < portRangeLow {
233-
return nil, 0, fmt.Errorf("no port in [%d,%d]", portRangeLow, portRangeHigh)
273+
tsshdPort := args.Port
274+
if tsshdPort == "" {
275+
tsshdPort = kDefaultPortRange
276+
}
277+
portRanges := parsePortRanges(tsshdPort)
278+
if len(portRanges) == 0 {
279+
return nil, 0, fmt.Errorf("no available port in [%s]", tsshdPort)
234280
}
281+
if len(portRanges) > 1 {
282+
math_rand.Shuffle(len(portRanges), func(i, j int) {
283+
portRanges[i], portRanges[j] = portRanges[j], portRanges[i]
284+
})
285+
}
286+
235287
addrs, err := getUdpAddrs(args)
236288
if err != nil {
237289
return nil, 0, fmt.Errorf("get available udp address failed: %v", err)
238290
}
291+
239292
var lastErr error
240-
size := portRangeHigh - portRangeLow + 1
241-
port := portRangeLow + math_rand.Intn(size)
242-
for range size {
243-
var connList []io.Closer
244-
for _, addr := range addrs {
245-
conn, err := listenOnPort(args, addr, port)
246-
if err != nil {
247-
lastErr = err
248-
break
293+
for _, portRange := range portRanges {
294+
portRangeLow, portRangeHigh := int(portRange[0]), int(portRange[1])
295+
size := portRangeHigh - portRangeLow + 1
296+
port := portRangeLow + math_rand.Intn(size)
297+
for range size {
298+
var connList []io.Closer
299+
for _, addr := range addrs {
300+
conn, err := listenOnPort(args, addr, port)
301+
if err != nil {
302+
lastErr = err
303+
break
304+
}
305+
connList = append(connList, conn)
306+
}
307+
if len(connList) == len(addrs) {
308+
return connList, port, nil
309+
}
310+
for _, conn := range connList {
311+
_ = conn.Close()
312+
}
313+
port++
314+
if port > portRangeHigh {
315+
port = portRangeLow
249316
}
250-
connList = append(connList, conn)
251-
}
252-
if len(connList) == len(addrs) {
253-
return connList, port, nil
254-
}
255-
for _, conn := range connList {
256-
_ = conn.Close()
257-
}
258-
port++
259-
if port > portRangeHigh {
260-
port = portRangeLow
261317
}
262318
}
319+
263320
if lastErr != nil {
264-
return nil, 0, fmt.Errorf("listen udp on [%d,%d] failed: %v", portRangeLow, portRangeHigh, lastErr)
321+
return nil, 0, fmt.Errorf("listen udp on [%s] failed: %v", tsshdPort, lastErr)
265322
}
266-
return nil, 0, fmt.Errorf("listen udp on [%d,%d] failed", portRangeLow, portRangeHigh)
323+
return nil, 0, fmt.Errorf("listen udp on [%s] failed", tsshdPort)
267324
}
268325

269326
func listenOnPort(args *tsshdArgs, udpAddr *net.UDPAddr, port int) (conn io.Closer, err error) {

tsshd/server_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
MIT License
3+
4+
Copyright (c) 2024-2026 The Trzsz SSH Authors.
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.
23+
*/
24+
25+
package tsshd
26+
27+
import (
28+
"testing"
29+
30+
"github.com/stretchr/testify/assert"
31+
)
32+
33+
func TestParsePortRanges(t *testing.T) {
34+
enableWarning := enableWarningLogging
35+
enableWarningLogging = false
36+
defer func() { enableWarningLogging = enableWarning }()
37+
38+
assert := assert.New(t)
39+
assert.Equal([][2]uint16{{22, 22}}, parsePortRanges("22"))
40+
assert.Equal([][2]uint16{{100, 102}}, parsePortRanges("100-102"))
41+
assert.Equal([][2]uint16{{200, 202}}, parsePortRanges("200 - 202"))
42+
assert.Equal([][2]uint16{{10, 10}, {20, 20}, {30, 30}}, parsePortRanges("10 20 30"))
43+
assert.Equal([][2]uint16{{1, 3}, {5, 5}, {7, 9}, {11, 11}}, parsePortRanges("1-3 5,7 - 9 11"))
44+
assert.Equal([][2]uint16{{1, 2}, {3, 4}, {5, 5}}, parsePortRanges("1-2,3-4 5"))
45+
assert.Equal([][2]uint16{{10, 12}, {15, 15}}, parsePortRanges(" 10\t-\t12 , 15 "))
46+
assert.Equal([][2]uint16{{50, 50}}, parsePortRanges("50-50"))
47+
assert.Equal([][2]uint16{{10, 10}, {20, 20}}, parsePortRanges("10,,20"))
48+
assert.Equal([][2]uint16(nil), parsePortRanges("0,70000,abc"))
49+
assert.Equal([][2]uint16(nil), parsePortRanges("100-50"))
50+
assert.Equal([][2]uint16(nil), parsePortRanges("-"))
51+
assert.Equal([][2]uint16(nil), parsePortRanges("- 10"))
52+
assert.Equal([][2]uint16(nil), parsePortRanges("10 -"))
53+
assert.Equal([][2]uint16{{1, 3}, {7, 7}}, parsePortRanges("1-3,abc,5 - 4,7"))
54+
assert.Equal([][2]uint16(nil), parsePortRanges(""))
55+
assert.Equal([][2]uint16(nil), parsePortRanges("8000-9000-10000"))
56+
assert.Equal([][2]uint16(nil), parsePortRanges("8000-"))
57+
assert.Equal([][2]uint16(nil), parsePortRanges("-9000"))
58+
assert.Equal([][2]uint16{{10, 12}}, parsePortRanges("10 - 12 - 15"))
59+
assert.Equal([][2]uint16{{1, 65535}}, parsePortRanges("1-65535"))
60+
assert.Equal([][2]uint16{{10, 10}, {10, 10}, {10, 10}}, parsePortRanges("10 10 10"))
61+
assert.Equal([][2]uint16{{20, 25}, {22, 23}}, parsePortRanges("20-25 22-23"))
62+
assert.Equal([][2]uint16(nil), parsePortRanges("10 - 0"))
63+
assert.Equal([][2]uint16(nil), parsePortRanges("10 - - 11"))
64+
assert.Equal([][2]uint16{{10, 11}}, parsePortRanges("10 - 11 -"))
65+
}

0 commit comments

Comments
 (0)