Skip to content

Commit c65fc54

Browse files
authored
Merge pull request #526 from jumpserver/dev
v4.10.19-lts
2 parents 35dc9f1 + 4982adb commit c65fc54

24 files changed

Lines changed: 904 additions & 201 deletions

‎Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM jumpserver/lion-base:20260701_083042 AS stage-build
1+
FROM jumpserver/lion-base:20260727_082931 AS stage-build
22
ARG TARGETARCH
33

44
ARG GOPROXY=https://goproxy.io
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Guacd 指令解码器
2+
3+
## 协议格式
4+
5+
Guacamole 指令由一个 opcode 和零个或多个参数组成。每个元素均使用
6+
`LENGTH.VALUE` 格式,元素之间以逗号分隔,整条指令以分号结束。
7+
8+
`LENGTH` 表示 `VALUE` 的 Unicode 码点数,不是 UTF-8 字节数。逗号和分号可以
9+
出现在元素内容中,只有读取完声明数量的码点后,后续逗号或分号才作为结构分隔符。
10+
11+
## InstructionDecoder
12+
13+
`InstructionDecoder` 从 `io.Reader` 增量读取指令。`ReadInstruction()` 每次只返回
14+
一条完整的 `Instruction`,并保留缓冲区中属于下一条指令的数据。
15+
16+
创建解码器时:
17+
18+
- 输入已经是 `*bufio.Reader` 时直接复用;
19+
- 其他 `io.Reader` 会由新的 `bufio.Reader` 包装;
20+
- 对非缓冲 reader,应持续复用同一个解码器,避免丢失预读数据;
21+
- 解码器不用于并发读取。
22+
23+
单条指令的读取过程如下:
24+
25+
1. 读取非空的 ASCII 十进制长度前缀;
26+
2. 按声明长度读取 UTF-8 编码的 Unicode 码点;
27+
3. 验证元素后的字符为逗号或分号;
28+
4. 遇到逗号时继续读取下一个元素,遇到分号时返回指令。
29+
30+
## 解析限制
31+
32+
解码器使用以下固定限制:
33+
34+
| 限制 | 值 |
35+
| --- | ---: |
36+
| 单个元素最大 Unicode 码点数 | 8192 |
37+
| 长度前缀最大位数 | 5 |
38+
| 单条指令最大元素数(包含 opcode) | 128 |
39+
| 单条指令最大累计字节数 | 32768 |
40+
41+
累计字节数包含长度前缀、元素内容和分隔符。超过任一限制都会立即终止当前指令的
42+
解析。
43+
44+
## 错误语义
45+
46+
解析错误通过包内定义的错误值分类:
47+
48+
| 错误 | 含义 |
49+
| --- | --- |
50+
| `ErrInstructionMissSemicolon` | 字符串解析输入未以分号结束 |
51+
| `ErrInstructionMissDot` | 长度前缀后缺少点号 |
52+
| `ErrInstructionBadDigit` | 长度为空或包含非数字字符 |
53+
| `ErrInstructionBadContent` | 元素内容不完整 |
54+
| `ErrInstructionBadTerminator` | 元素后的字符不是逗号或分号 |
55+
| `ErrInstructionTooLong` | 长度前缀、元素长度或累计字节数超限 |
56+
| `ErrInstructionTooManyElements` | 指令元素数量超限 |
57+
| `ErrInstructionInvalidUTF8` | 元素包含无效 UTF-8 |
58+
| `ErrInstructionTrailingData` | 单指令字符串后仍有其他数据 |
59+
60+
新指令尚未读取任何字节时遇到流结束,返回 `io.EOF`。长度、内容或终止符读取到
61+
一半时遇到 EOF,返回的错误可通过 `errors.Is(err, io.ErrUnexpectedEOF)` 判断,
62+
同时保留对应的解析错误分类。网络超时等非 EOF 的底层读取错误原样返回。
63+
64+
解析错误发生时,当前指令已经消费的输入不会回退,调用方应结束当前流或连接。
65+
66+
## 字符串解析与序列化
67+
68+
`ParseInstructionString()` 使用 `InstructionDecoder` 解析输入,并要求输入严格包含
69+
一条完整指令:
70+
71+
- 输入必须以分号结束;
72+
- 第一条指令之后必须立即到达 EOF;
73+
- 多条指令或任何尾随内容均返回 `ErrInstructionTrailingData`。
74+
75+
`Instruction.String()` 对 opcode 和所有参数统一按 Unicode 码点数生成长度前缀。
76+
生成结果缓存在 `ProtocolForm` 中,后续调用直接返回缓存内容。
77+
78+
## Tunnel 集成
79+
80+
`Tunnel` 在建立连接时基于其 `bufio.Reader` 创建一个解码器,并在连接生命周期内
81+
持续复用。`Tunnel.ReadInstruction()` 在每条指令开始读取前设置一次 15 秒读取
82+
截止时间,然后由解码器读取并返回一条指令。
83+
84+
`Tunnel.Read()` 复用 `ReadInstruction()`,并将解析后的指令重新序列化为字节。
85+
握手阶段的 `expect()` 同样通过该读取路径校验期望的 opcode。
86+
87+
## 回放文件集成
88+
89+
回放文件读取函数接收现有的 `*bufio.Reader`,并通过 `InstructionDecoder` 逐条读取
90+
指令。由于解码器直接复用该 reader,多次调用不会丢失 reader 中已缓冲的后续数据。
91+
回放时间扫描据此遍历指令并提取 `sync` 时间。

‎docs/replay-part-upload.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# 录像分片上传修改记录
2+
3+
## 上传前预检
4+
5+
上传器在生成压缩文件和开始上传之前,对当前录像的全部原始分片执行预检:
6+
7+
- 只收集名称符合 `{session_id}.{index}.part` 的当前会话分片;
8+
- 按数字分片序号排序;
9+
- 分片序号必须从 0 开始且连续;
10+
- 使用同一个 `InstructionDecoder` 完整扫描每个分片;
11+
- 通过累计读取字节数扣除缓冲区预读字节计算 `sync` 结束位置,不对每个 `sync`
12+
调用 `Seek`;
13+
- 每个分片至少包含一个时间戳有效的 `sync` 指令。
14+
15+
任意分片出现无效指令、无有效 `sync`、无效 `sync` 时间或分片序号不连续时,
16+
整段录像不会上传,原始文件保留供人工处理。
17+
18+
## 最后分片恢复
19+
20+
自动恢复只适用于最后一个分片,并且必须同时满足以下条件:
21+
22+
- 解析错误可通过 `errors.Is(err, io.ErrUnexpectedEOF)` 判断;
23+
- 错误之前至少存在一个完整且时间戳有效的 `sync`。
24+
25+
满足条件时,将最后一个分片截断到最后一个完整 `sync` 的结束位置,然后重新扫描。
26+
重新扫描仍有异常时,整段录像不会上传。非最后分片不执行自动截断。
27+
28+
## 元数据与上传
29+
30+
全部分片通过预检后:
31+
32+
1. 根据扫描结果重新生成每个分片的开始时间、结束时间、持续时间和文件大小;
33+
2. 重写对应的 `.part.meta` 文件;
34+
3. 清理并重新创建本次上传暂存目录;
35+
4. 压缩全部分片并生成回放元数据;
36+
5. 开始上传。
37+
38+
校验失败发生在压缩和上传之前,因此不会跳过异常分片继续上传后续分片。

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ require (
77
github.com/gin-gonic/gin v1.11.0
88
github.com/go-redis/redis/v8 v8.11.5
99
github.com/gorilla/websocket v1.5.3
10-
github.com/jumpserver-dev/sdk-go v0.0.0-20260520022428-47f0d4d20dc6
10+
github.com/jumpserver-dev/sdk-go v0.0.0-20260727030641-b0cef8e97370
1111
github.com/spf13/viper v1.21.0
1212
golang.org/x/crypto v0.47.0
1313
gopkg.in/natefinch/lumberjack.v2 v2.2.1

‎go.sum‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
114114
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
115115
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
116116
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
117-
github.com/jumpserver-dev/sdk-go v0.0.0-20260520022428-47f0d4d20dc6 h1:K3DJEekApUoQUuVX9x6U8pvP/g7qaHy8TcCVmsIlf64=
118-
github.com/jumpserver-dev/sdk-go v0.0.0-20260520022428-47f0d4d20dc6/go.mod h1:QmnL7BRb74/XY1nu8kWQWs6DHXt3uIaWkDCkQSRyZlM=
117+
github.com/jumpserver-dev/sdk-go v0.0.0-20260727030641-b0cef8e97370 h1:6QDjMGSy908FCCaJsr1dqSECV51ZskWK5n/Glb4dsH8=
118+
github.com/jumpserver-dev/sdk-go v0.0.0-20260727030641-b0cef8e97370/go.mod h1:QmnL7BRb74/XY1nu8kWQWs6DHXt3uIaWkDCkQSRyZlM=
119119
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
120120
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
121121
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=

‎pkg/guacd/information.go‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
package guacd
22

3+
import (
4+
"os"
5+
"strings"
6+
"time"
7+
)
8+
39
const (
410
defaultOptimalScreenWidth = 1024
511
defaultOptimalScreenHeight = 768
@@ -8,12 +14,28 @@ const (
814
defaultTimezone = "Asia/Shanghai"
915
)
1016

17+
func getDefaultTimezone() string {
18+
timezone := strings.TrimSpace(os.Getenv("TZ"))
19+
if isValidTimezone(timezone) {
20+
return timezone
21+
}
22+
return defaultTimezone
23+
}
24+
25+
func isValidTimezone(timezone string) bool {
26+
if timezone == "" {
27+
return false
28+
}
29+
_, err := time.LoadLocation(timezone)
30+
return err == nil
31+
}
32+
1133
func NewClientInformation() ClientInformation {
1234
return ClientInformation{
1335
OptimalScreenWidth: defaultOptimalScreenWidth,
1436
OptimalScreenHeight: defaultOptimalScreenHeight,
1537
OptimalResolution: defaultOptimalResolution,
16-
Timezone: defaultTimezone,
38+
Timezone: getDefaultTimezone(),
1739
AudioMimetypes: []string{"audio/L8", "audio/L16"},
1840
ImageMimetypes: []string{"image/jpeg", "image/png", "image/webp"},
1941
VideoMimetypes: []string{},
@@ -62,6 +84,13 @@ type ClientInformation struct {
6284
KeyboardLayout string
6385
}
6486

87+
func (info *ClientInformation) SetTimezone(timezone string) {
88+
timezone = strings.TrimSpace(timezone)
89+
if isValidTimezone(timezone) {
90+
info.Timezone = timezone
91+
}
92+
}
93+
6594
func (info *ClientInformation) ExtraConfig() map[string]string {
6695
ret := make(map[string]string)
6796
if layout, ok := RDPServerLayouts[info.KeyboardLayout]; ok {

‎pkg/guacd/instruction.go‎

Lines changed: 27 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ package guacd
33
import (
44
"errors"
55
"fmt"
6-
"strconv"
6+
"io"
77
"strings"
8+
"unicode/utf8"
89
)
910

1011
type Instruction struct {
@@ -24,9 +25,9 @@ func (opt *Instruction) String() string {
2425
if len(opt.ProtocolForm) > 0 {
2526
return opt.ProtocolForm
2627
}
27-
opt.ProtocolForm = fmt.Sprintf("%d.%s", len(opt.Opcode), opt.Opcode)
28+
opt.ProtocolForm = fmt.Sprintf("%d.%s", utf8.RuneCountInString(opt.Opcode), opt.Opcode)
2829
for _, value := range opt.Args {
29-
opt.ProtocolForm += fmt.Sprintf(",%d.%s", len([]rune(value)), value)
30+
opt.ProtocolForm += fmt.Sprintf(",%d.%s", utf8.RuneCountInString(value), value)
3031
}
3132
opt.ProtocolForm += semicolonDelimiter
3233
return opt.ProtocolForm
@@ -43,58 +44,37 @@ const (
4344
)
4445

4546
var (
46-
ErrInstructionMissSemicolon = errors.New("instruction without semicolon")
47-
ErrInstructionMissDot = errors.New("instruction without dot")
48-
ErrInstructionBadDigit = errors.New("instruction with bad digit")
49-
ErrInstructionBadContent = errors.New("instruction with bad Content")
47+
ErrInstructionMissSemicolon = errors.New("instruction without semicolon")
48+
ErrInstructionMissDot = errors.New("instruction without dot")
49+
ErrInstructionBadDigit = errors.New("instruction with bad digit")
50+
ErrInstructionBadContent = errors.New("instruction with bad Content")
51+
ErrInstructionBadTerminator = errors.New("instruction with bad element terminator")
52+
ErrInstructionTooLong = errors.New("instruction exceeds size limit")
53+
ErrInstructionTooManyElements = errors.New("instruction has too many elements")
54+
ErrInstructionInvalidUTF8 = errors.New("instruction contains invalid UTF-8")
55+
ErrInstructionTrailingData = errors.New("data follows complete instruction")
5056
)
5157

5258
// raw 是以 `;` 为结束符的原生字符串
5359
func ParseInstructionString(raw string) (ret Instruction, err error) {
5460
if !strings.HasSuffix(raw, semicolonDelimiter) {
5561
return Instruction{}, fmt.Errorf("%w: %s", ErrInstructionMissSemicolon, raw)
5662
}
57-
raw = trimSuffixSemicolonDelimiter(raw)
58-
rawRune := []rune(raw)
59-
args := make([]string, 0, 1024)
60-
var i = 0
61-
for len(rawRune) > 0 {
62-
switch rawRune[i] {
63-
case ByteCommaDelimiter, ByteSemicolonDelimiter:
64-
// 重置数据
65-
rawRune = rawRune[i+1:]
66-
i = 0
67-
continue
68-
case ByteDotDelimiter:
69-
// 解析 LENGTH.VALUE 格式的数据
70-
dotIndex := rawRune[:i]
71-
argContentLen, err := strconv.Atoi(string(dotIndex))
72-
if err != nil {
73-
return Instruction{}, fmt.Errorf("%w: %s",
74-
ErrInstructionBadDigit, err.Error())
75-
}
76-
if argContentLen > len(rawRune[i+1:]) {
77-
return Instruction{}, fmt.Errorf("%w: %s",
78-
ErrInstructionBadContent, raw)
79-
}
80-
argContent := string(rawRune[i+1 : argContentLen+i+1])
81-
args = append(args, argContent)
82-
rawRune = rawRune[argContentLen+i+1:]
83-
i = 0
84-
continue
85-
default:
86-
i++
87-
}
88-
if i >= len(rawRune) {
89-
return Instruction{}, fmt.Errorf("%w: %s", ErrInstructionBadContent, raw)
90-
}
63+
64+
decoder := NewInstructionDecoder(strings.NewReader(raw))
65+
ret, err = decoder.ReadInstruction()
66+
if err != nil {
67+
return Instruction{}, err
9168
}
92-
if len(args) < 1 {
93-
return Instruction{}, fmt.Errorf("%w: no content", ErrInstructionBadContent)
69+
70+
// ParseInstructionString represents exactly one instruction. Accepting a
71+
// second instruction here would silently reinterpret its opcode as an
72+
// argument of the first instruction.
73+
if _, nextErr := decoder.ReadInstruction(); nextErr == nil {
74+
return Instruction{}, fmt.Errorf("%w: multiple instructions", ErrInstructionTrailingData)
75+
} else if !errors.Is(nextErr, io.EOF) {
76+
return Instruction{}, fmt.Errorf("%w: %v", ErrInstructionTrailingData, nextErr)
9477
}
95-
return NewInstruction(args[0], args[1:]...), nil
96-
}
9778

98-
func trimSuffixSemicolonDelimiter(content string) string {
99-
return strings.TrimSuffix(content, semicolonDelimiter)
79+
return ret, nil
10080
}

0 commit comments

Comments
 (0)