magic-agent 1.0.3 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -0,0 +1,223 @@
1
+ package client
2
+
3
+ import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+ )
13
+
14
+ /* 插件目录(发现页的「插件」):
15
+ * ① 清单只从服务端那张表里来,客户端传什么都在表里找;
16
+ * ② 安装动作只跑表里写死的 argv,**不接受客户端传命令**;
17
+ * ③ 状态如实:没装就说没装,失败把退出码与 stderr 一起回。 */
18
+
19
+ // fakePluginRepo 造一个「源码目录 + 两个假 CLI」的临时环境。
20
+ // 假 CLI 是 shell 脚本:安装那一刻才被 `go test` 之外的东西创建出来 —— 这里由 npm/git 桩创建。
21
+ func fakePluginRepo(t *testing.T) (repo string, binDir string) {
22
+ t.Helper()
23
+ root := t.TempDir()
24
+ repo = filepath.Join(root, "magic-shorts")
25
+ binDir = filepath.Join(root, "bin")
26
+ for _, d := range []string{repo, binDir} {
27
+ if err := os.MkdirAll(d, 0o755); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ }
31
+ if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(`{"name":"magic-shorts","version":"9.9.9"}`), 0o644); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ t.Setenv("MAGIC_CLIENT_SHORTS_DIR", repo)
35
+ t.Setenv("MAGIC_CLIENT_VIDEO_DIR", filepath.Join(root, "magic-video"))
36
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
37
+ return repo, binDir
38
+ }
39
+
40
+ func pluginService() *Service { return &Service{opts: Options{}} }
41
+
42
+ func TestPluginsListsCatalog(t *testing.T) {
43
+ fakePluginRepo(t)
44
+ rec := httptest.NewRecorder()
45
+ pluginService().handlePlugins(rec, httptest.NewRequest(http.MethodGet, "/desk/plugins", nil))
46
+ if rec.Code != http.StatusOK {
47
+ t.Fatalf("状态码 = %d,期望 200", rec.Code)
48
+ }
49
+ var body struct {
50
+ Plugins []pluginInfo `json:"plugins"`
51
+ }
52
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
53
+ t.Fatalf("响应不是合法 JSON:%v(%s)", err, rec.Body.String())
54
+ }
55
+ if len(body.Plugins) != 2 {
56
+ t.Fatalf("插件数 = %d,期望 2(magic-shorts / magic-video)", len(body.Plugins))
57
+ }
58
+ byID := map[string]pluginInfo{}
59
+ for _, p := range body.Plugins {
60
+ byID[p.ID] = p
61
+ }
62
+ shorts, ok := byID["magic-shorts"]
63
+ if !ok {
64
+ t.Fatal("清单里没有 magic-shorts")
65
+ }
66
+ // 路径上还没有那个 CLI → 必须如实说没装,而不是给一个看起来成功的空壳
67
+ if shorts.Installed {
68
+ t.Fatal("PATH 上没有 magic-shorts 时 Installed 应为 false")
69
+ }
70
+ if !shorts.SourceExists {
71
+ t.Fatal("源码目录存在时 SourceExists 应为 true")
72
+ }
73
+ if !strings.Contains(shorts.Install, "npm install -g") {
74
+ t.Fatalf("安装命令缺失或形状不对:%q", shorts.Install)
75
+ }
76
+ if shorts.Tagline == "" || shorts.Description == "" || len(shorts.Examples) == 0 {
77
+ t.Fatal("介绍三件套(一句话简介 / 详细说明 / 示例)不能空 —— 发现页要展示它们")
78
+ }
79
+ if !strings.Contains(shorts.UsePrompt, "magic-shorts") {
80
+ t.Fatal("让它干活的预填提示里要点名 CLI,否则 Agent 不知道用哪个")
81
+ }
82
+ video := byID["magic-video"]
83
+ if !strings.Contains(video.Install, "@magicstock/magic-cli") {
84
+ t.Fatalf("magic-video 的安装命令应装 npm 包 @magicstock/magic-cli:%q", video.Install)
85
+ }
86
+ }
87
+
88
+ // 版本号从可执行文件所属 npm 包的 package.json 读(不跑 --version:magic-video 不认这个 flag)。
89
+ func TestPluginVersionFromPackageJSON(t *testing.T) {
90
+ _, binDir := fakePluginRepo(t)
91
+ pkgDir := filepath.Join(filepath.Dir(binDir), "lib", "node_modules", "magic-shorts")
92
+ if err := os.MkdirAll(pkgDir, 0o755); err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ if err := os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"name":"magic-shorts","version":"1.2.3"}`), 0o644); err != nil {
96
+ t.Fatal(err)
97
+ }
98
+ bin := filepath.Join(pkgDir, "bin", "magic-shorts.js")
99
+ if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
103
+ t.Fatal(err)
104
+ }
105
+ if got := versionNearBin(bin); got != "1.2.3" {
106
+ t.Fatalf("版本 = %q,期望 1.2.3", got)
107
+ }
108
+ if err := os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte("not json"), 0o644); err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ if got := versionNearBin(bin); got != "" {
112
+ t.Fatalf("坏 package.json 应回空串,实际 %q", got)
113
+ }
114
+ }
115
+
116
+ // 安装动作:只跑表里那几条 argv(这里用桩 npm 顶替),并把输出按行流回去。
117
+ func TestPluginInstallStreamsAndRefreshes(t *testing.T) {
118
+ repo, binDir := fakePluginRepo(t)
119
+ // 桩 npm:把自己收到的参数写成一行输出,并在「成功」时把假 CLI 放进 PATH ——
120
+ // 于是「跑完重新探测」这条链真的会翻成 installed=true,而不是靠测试自己断言。
121
+ npm := filepath.Join(binDir, "npm")
122
+ script := `#!/bin/sh
123
+ echo "npm $*"
124
+ case "$*" in
125
+ *install*) printf '#!/bin/sh\nexit 0\n' > ` + filepath.Join(binDir, "magic-shorts") + `; chmod +x ` + filepath.Join(binDir, "magic-shorts") + ` ;;
126
+ esac
127
+ exit 0
128
+ `
129
+ if err := os.WriteFile(npm, []byte(script), 0o755); err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ // python3 桩:pip 那一步不该真的装东西
133
+ py := filepath.Join(binDir, "python3")
134
+ if err := os.WriteFile(py, []byte("#!/bin/sh\necho \"pip $*\"\nexit 0\n"), 0o755); err != nil {
135
+ t.Fatal(err)
136
+ }
137
+
138
+ rec := httptest.NewRecorder()
139
+ pluginService().handlePluginAction(rec,
140
+ httptest.NewRequest(http.MethodPost, "/desk/plugins/magic-shorts/install", nil))
141
+ if rec.Code != http.StatusOK {
142
+ t.Fatalf("状态码 = %d,期望 200(SSE):%s", rec.Code, rec.Body.String())
143
+ }
144
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
145
+ t.Fatalf("Content-Type = %q,期望 SSE", ct)
146
+ }
147
+
148
+ // 解析 SSE:按 event 名归集 data
149
+ events := map[string][]string{}
150
+ var cur string
151
+ sc := bufio.NewScanner(strings.NewReader(rec.Body.String()))
152
+ for sc.Scan() {
153
+ line := sc.Text()
154
+ switch {
155
+ case strings.HasPrefix(line, "event: "):
156
+ cur = strings.TrimPrefix(line, "event: ")
157
+ case strings.HasPrefix(line, "data: "):
158
+ events[cur] = append(events[cur], strings.TrimPrefix(line, "data: "))
159
+ }
160
+ }
161
+ if len(events["open"]) != 1 {
162
+ t.Fatalf("应先发一条 open(告诉客户端要跑什么),实际 %v", events)
163
+ }
164
+ var open struct {
165
+ Commands []string `json:"commands"`
166
+ }
167
+ _ = json.Unmarshal([]byte(events["open"][0]), &open)
168
+ if len(open.Commands) != 2 || !strings.Contains(open.Commands[0], "npm") {
169
+ t.Fatalf("open.commands = %v,期望两条(npm 安装 + pip -e)", open.Commands)
170
+ }
171
+ if !strings.Contains(strings.Join(events["line"], "\n"), "npm install -g "+repo) {
172
+ t.Fatalf("安装输出没有流回客户端:%v", events["line"])
173
+ }
174
+ if len(events["end"]) != 1 {
175
+ t.Fatalf("必须恰好一条 end 收尾,实际 %d", len(events["end"]))
176
+ }
177
+ var end struct {
178
+ OK bool `json:"ok"`
179
+ Plugin pluginInfo `json:"plugin"`
180
+ }
181
+ _ = json.Unmarshal([]byte(events["end"][0]), &end)
182
+ if !end.OK {
183
+ t.Fatalf("安装成功却回了 ok=false:%s", events["end"][0])
184
+ }
185
+ if !end.Plugin.Installed {
186
+ t.Fatalf("跑完应重新探测到已安装(end.plugin.installed 仍为 false):%s", events["end"][0])
187
+ }
188
+ }
189
+
190
+ // 未知 id / 未知动作一律 404,且**没有任何命令被执行** —— 这条接口不能变成任意命令执行器。
191
+ func TestPluginActionRejectsUnknown(t *testing.T) {
192
+ fakePluginRepo(t)
193
+ for _, target := range []string{
194
+ "/desk/plugins/rm-rf/install",
195
+ "/desk/plugins/magic-shorts/run",
196
+ "/desk/plugins/magic-shorts",
197
+ } {
198
+ rec := httptest.NewRecorder()
199
+ pluginService().handlePluginAction(rec, httptest.NewRequest(http.MethodPost, target, nil))
200
+ if rec.Code != http.StatusNotFound {
201
+ t.Fatalf("%s 状态码 = %d,期望 404", target, rec.Code)
202
+ }
203
+ }
204
+ }
205
+
206
+ // 失败的安装:退出码 + 错误要回给客户端,end.ok=false(不能静默当成功)。
207
+ func TestPluginInstallFailureReported(t *testing.T) {
208
+ _, binDir := fakePluginRepo(t)
209
+ npm := filepath.Join(binDir, "npm")
210
+ if err := os.WriteFile(npm, []byte("#!/bin/sh\necho 'EACCES: permission denied' >&2\nexit 3\n"), 0o755); err != nil {
211
+ t.Fatal(err)
212
+ }
213
+ rec := httptest.NewRecorder()
214
+ pluginService().handlePluginAction(rec,
215
+ httptest.NewRequest(http.MethodPost, "/desk/plugins/magic-shorts/install", nil))
216
+ body := rec.Body.String()
217
+ if !strings.Contains(body, "EACCES: permission denied") {
218
+ t.Fatalf("stderr 没有流回客户端:%s", body)
219
+ }
220
+ if !strings.Contains(body, `"ok":false`) {
221
+ t.Fatalf("失败应以 ok=false 收尾:%s", body)
222
+ }
223
+ }
@@ -0,0 +1,374 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/binary"
5
+ "fmt"
6
+ "io"
7
+ "net"
8
+ "time"
9
+ )
10
+
11
+ /* 极简 SOCKS5 服务:给移动端 VPN 隧道核心(TUN → SOCKS5)做**出口**。
12
+ *
13
+ * ── 为什么是 SOCKS5 ──
14
+ * 主流隧道内核(hev-socks5-tunnel / tun2socks 等)说的都是 SOCKS5,且都支持
15
+ * RFC 1929 用户名口令认证 —— 令牌正好塞在这里,不用自己发明协议。
16
+ *
17
+ * ── 范围 ──
18
+ * 只实现 CONNECT(TCP)+ 用户名口令认证。浏览器绝大多数请求走 TCP;
19
+ * UDP ASSOCIATE(QUIC/HTTP3)暂不支持,缺了它浏览器会自动退回 TCP(H2),不影响可用性。
20
+ * 需要时再补 UDP,别一上来就把复杂度拉满。
21
+ *
22
+ * ── 安全 ──
23
+ * 这是个**开放出口**:能连到它的人就能借这台机器上网。所以必须绑在受控网络上,
24
+ * 且口令(= 服务令牌)是唯一门锁;没配令牌时只允许回环,绝不对局域网裸奔。
25
+ */
26
+
27
+ const (
28
+ socksVersion = 0x05
29
+ socksAuthNone = 0x00
30
+ socksAuthUserPw = 0x02
31
+ socksAuthNoOK = 0x01
32
+ socksCmdConnect = 0x01
33
+ socksCmdUDPAssoc = 0x03
34
+ socksATYPIPv4 = 0x01
35
+ socksATYPDomain = 0x03
36
+ socksATYPIPv6 = 0x04
37
+ )
38
+
39
+ // ServeProxy 在 ln 上跑 SOCKS5 服务,阻塞直到监听器出错。
40
+ func (s *Service) ServeProxy(ln net.Listener) error {
41
+ for {
42
+ conn, err := ln.Accept()
43
+ if err != nil {
44
+ return err
45
+ }
46
+ go func() {
47
+ defer conn.Close()
48
+ if err := s.serveSOCKS5(conn); err != nil {
49
+ s.log.Printf("socks5: %v", err)
50
+ }
51
+ }()
52
+ }
53
+ }
54
+
55
+ func (s *Service) serveSOCKS5(conn net.Conn) error {
56
+ _ = conn.SetDeadline(time.Now().Add(30 * time.Second))
57
+
58
+ // ── 协商:必须有「用户名口令」这一项;一个方法都不能给的客户端直接拒。 ──
59
+ head := make([]byte, 2)
60
+ if _, err := io.ReadFull(conn, head); err != nil {
61
+ return err
62
+ }
63
+ if head[0] != socksVersion {
64
+ return fmt.Errorf("非 SOCKS5 版本:%d", head[0])
65
+ }
66
+ methods := make([]byte, int(head[1]))
67
+ if _, err := io.ReadFull(conn, methods); err != nil {
68
+ return err
69
+ }
70
+ if !hasByte(methods, socksAuthUserPw) {
71
+ _, _ = conn.Write([]byte{socksVersion, 0xFF})
72
+ return fmt.Errorf("客户端不支持用户名口令认证")
73
+ }
74
+ if _, err := conn.Write([]byte{socksVersion, socksAuthUserPw}); err != nil {
75
+ return err
76
+ }
77
+
78
+ // ── 认证(RFC 1929):口令即服务令牌。 ──
79
+ if err := s.socksAuth(conn); err != nil {
80
+ return err
81
+ }
82
+
83
+ // ── 请求:CONNECT(TCP)或 UDP ASSOCIATE(DNS / QUIC 等 UDP 流量)。 ──
84
+ req := make([]byte, 4)
85
+ if _, err := io.ReadFull(conn, req); err != nil {
86
+ return err
87
+ }
88
+ if req[0] != socksVersion {
89
+ return fmt.Errorf("版本不对:%d", req[0])
90
+ }
91
+ host, err := readSOCKSAddr(conn, req[3])
92
+ if err != nil {
93
+ return err
94
+ }
95
+ portBuf := make([]byte, 2)
96
+ if _, err := io.ReadFull(conn, portBuf); err != nil {
97
+ return err
98
+ }
99
+ target := net.JoinHostPort(host, fmt.Sprint(binary.BigEndian.Uint16(portBuf)))
100
+
101
+ switch req[1] {
102
+ case socksCmdConnect:
103
+ return s.serveConnect(conn, target)
104
+ case socksCmdUDPAssoc:
105
+ return s.serveUDPAssociate(conn)
106
+ default:
107
+ _, _ = conn.Write([]byte{socksVersion, 0x07, 0x00, socksATYPIPv4, 0, 0, 0, 0, 0, 0})
108
+ return fmt.Errorf("不支持的命令:%d", req[1])
109
+ }
110
+ }
111
+
112
+ // serveConnect 处理 CONNECT:连目标并双向搬运字节。
113
+ func (s *Service) serveConnect(conn net.Conn, target string) error {
114
+ upstream, err := net.DialTimeout("tcp", target, 20*time.Second)
115
+ if err != nil {
116
+ _, _ = conn.Write([]byte{socksVersion, 0x05, 0x00, socksATYPIPv4, 0, 0, 0, 0, 0, 0})
117
+ return fmt.Errorf("连 %s 失败:%w", target, err)
118
+ }
119
+ defer upstream.Close()
120
+
121
+ if _, err := conn.Write([]byte{socksVersion, 0x00, 0x00, socksATYPIPv4, 0, 0, 0, 0, 0, 0}); err != nil {
122
+ return err
123
+ }
124
+
125
+ // 握手完成,此后是纯字节搬运(隧道里可能是 TLS,我们不看不碰)。
126
+ _ = conn.SetDeadline(time.Time{})
127
+ done := make(chan struct{}, 2)
128
+ go func() { _, _ = io.Copy(upstream, conn); done <- struct{}{} }()
129
+ go func() { _, _ = io.Copy(conn, upstream); done <- struct{}{} }()
130
+ <-done
131
+ return nil
132
+ }
133
+
134
+ /* serveUDPAssociate 处理 UDP ASSOCIATE。
135
+ *
136
+ * 这条不能省:VPN 一旦接管默认路由,**DNS 查询(UDP 53)也进了隧道**。
137
+ * 没有 UDP,手机上任何域名都解析不了 —— 表现是「网页打不开、但 ping IP 通」。
138
+ *
139
+ * 协议:TCP 控制连接先回一个「UDP 中继端点」,此后客户端把每个数据报按
140
+ * RSV(2) FRAG(1) ATYP ADDR PORT DATA 的格式发到这个端点,我们解包后转发,
141
+ * 收到回包再按同样格式包回去。控制连接断开即撤销。
142
+ */
143
+ func (s *Service) serveUDPAssociate(conn net.Conn) error {
144
+ udp, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
145
+ if err != nil {
146
+ return err
147
+ }
148
+ defer udp.Close()
149
+
150
+ // 把中继端点告诉客户端(用它自己命中的那个本机地址,别报 0.0.0.0)。
151
+ tcpLocal, _ := conn.LocalAddr().(*net.TCPAddr)
152
+ ip4 := net.IPv4zero
153
+ if tcpLocal != nil && tcpLocal.IP.To4() != nil {
154
+ ip4 = tcpLocal.IP.To4()
155
+ }
156
+ port := uint16(udp.LocalAddr().(*net.UDPAddr).Port)
157
+ reply := []byte{socksVersion, 0x00, 0x00, socksATYPIPv4}
158
+ reply = append(reply, ip4...)
159
+ reply = append(reply, byte(port>>8), byte(port))
160
+ if _, err := conn.Write(reply); err != nil {
161
+ return err
162
+ }
163
+
164
+ // 控制连接断开 = 撤销这个 UDP 关联。
165
+ go func() {
166
+ _, _ = io.Copy(io.Discard, conn)
167
+ _ = udp.Close()
168
+ }()
169
+ _ = conn.SetDeadline(time.Time{})
170
+
171
+ var client *net.UDPAddr
172
+ flows := map[string]*net.UDPConn{} // 目标 → 出站 UDP 连接(复用,别每个包都新建)
173
+ defer func() {
174
+ for _, c := range flows {
175
+ _ = c.Close()
176
+ }
177
+ }()
178
+
179
+ buf := make([]byte, 65535)
180
+ for {
181
+ n, from, err := udp.ReadFromUDP(buf)
182
+ if err != nil {
183
+ return err
184
+ }
185
+ // 只服务控制连接对端发来的包,避免本机其它进程蹭这条出口。
186
+ if client == nil {
187
+ client = from
188
+ }
189
+ if !from.IP.Equal(client.IP) {
190
+ continue
191
+ }
192
+
193
+ dstAddr, payload, ok := parseSocksUDP(buf[:n])
194
+ if !ok {
195
+ continue
196
+ }
197
+ key := dstAddr.String()
198
+ out, ok := flows[key]
199
+ if !ok {
200
+ out, err = net.DialUDP("udp", nil, dstAddr)
201
+ if err != nil {
202
+ continue
203
+ }
204
+ flows[key] = out
205
+ go func(out *net.UDPConn, dst *net.UDPAddr) {
206
+ rb := make([]byte, 65535)
207
+ for {
208
+ _ = out.SetReadDeadline(time.Now().Add(120 * time.Second))
209
+ rn, rerr := out.Read(rb)
210
+ if rerr != nil {
211
+ return
212
+ }
213
+ pkt := buildSocksUDP(dst, rb[:rn])
214
+ if client != nil {
215
+ _, _ = udp.WriteToUDP(pkt, client)
216
+ }
217
+ }
218
+ }(out, dstAddr)
219
+ }
220
+ _, _ = out.Write(payload)
221
+ }
222
+ }
223
+
224
+ // parseSocksUDP 解 SOCKS5 UDP 请求头:RSV(2) FRAG(1) ATYP ADDR PORT DATA。
225
+ func parseSocksUDP(pkt []byte) (*net.UDPAddr, []byte, bool) {
226
+ if len(pkt) < 4 {
227
+ return nil, nil, false
228
+ }
229
+ if pkt[2] != 0x00 { // 不支持分片
230
+ return nil, nil, false
231
+ }
232
+ atyp := pkt[3]
233
+ i := 4
234
+ var host string
235
+ switch atyp {
236
+ case socksATYPIPv4:
237
+ if len(pkt) < i+4 {
238
+ return nil, nil, false
239
+ }
240
+ host = net.IP(pkt[i : i+4]).String()
241
+ i += 4
242
+ case socksATYPIPv6:
243
+ if len(pkt) < i+16 {
244
+ return nil, nil, false
245
+ }
246
+ host = net.IP(pkt[i : i+16]).String()
247
+ i += 16
248
+ case socksATYPDomain:
249
+ if len(pkt) < i+1 {
250
+ return nil, nil, false
251
+ }
252
+ l := int(pkt[i])
253
+ i++
254
+ if len(pkt) < i+l {
255
+ return nil, nil, false
256
+ }
257
+ host = string(pkt[i : i+l])
258
+ i += l
259
+ default:
260
+ return nil, nil, false
261
+ }
262
+ if len(pkt) < i+2 {
263
+ return nil, nil, false
264
+ }
265
+ port := binary.BigEndian.Uint16(pkt[i : i+2])
266
+ i += 2
267
+ addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, fmt.Sprint(port)))
268
+ if err != nil {
269
+ return nil, nil, false
270
+ }
271
+ return addr, pkt[i:], true
272
+ }
273
+
274
+ // buildSocksUDP 把回包按 SOCKS5 UDP 格式包好(源地址即回包来源)。
275
+ func buildSocksUDP(from *net.UDPAddr, payload []byte) []byte {
276
+ out := []byte{0x00, 0x00, 0x00}
277
+ if ip4 := from.IP.To4(); ip4 != nil {
278
+ out = append(out, socksATYPIPv4)
279
+ out = append(out, ip4...)
280
+ } else {
281
+ out = append(out, socksATYPIPv6)
282
+ out = append(out, from.IP.To16()...)
283
+ }
284
+ out = append(out, byte(from.Port>>8), byte(from.Port))
285
+ return append(out, payload...)
286
+ }
287
+
288
+ func (s *Service) socksAuth(conn net.Conn) error {
289
+ // RFC 1929:ver(1) 后跟「用户名(len+data)」,再跟「口令(len+data)」。
290
+ // ⚠️ 这里只能读 1 个字节 —— 把版本和用户名长度一起读会整体错位一个字节,
291
+ // 表现为握手挂死到超时。
292
+ ver := make([]byte, 1)
293
+ if _, err := io.ReadFull(conn, ver); err != nil {
294
+ return err
295
+ }
296
+ if ver[0] != 0x01 {
297
+ return fmt.Errorf("认证版本不对:%d", ver[0])
298
+ }
299
+ user, err := readLenPrefixed(conn)
300
+ if err != nil {
301
+ return err
302
+ }
303
+ pass, err := readLenPrefixed(conn)
304
+ if err != nil {
305
+ return err
306
+ }
307
+ want := s.opts.Token
308
+ if want == "" {
309
+ // 没配令牌只允许回环 —— 绝不把开放出口暴露到局域网。
310
+ if !isLoopback(conn.RemoteAddr()) {
311
+ _, _ = conn.Write([]byte{0x01, socksAuthNoOK})
312
+ return fmt.Errorf("未配置令牌,且来源非回环:%s", conn.RemoteAddr())
313
+ }
314
+ } else if pass != want {
315
+ _, _ = conn.Write([]byte{0x01, socksAuthNoOK})
316
+ return fmt.Errorf("口令错误(user=%q)", user)
317
+ }
318
+ _, err = conn.Write([]byte{0x01, 0x00})
319
+ return err
320
+ }
321
+
322
+ func readSOCKSAddr(conn net.Conn, atyp byte) (string, error) {
323
+ switch atyp {
324
+ case socksATYPIPv4:
325
+ b := make([]byte, 4)
326
+ if _, err := io.ReadFull(conn, b); err != nil {
327
+ return "", err
328
+ }
329
+ return net.IP(b).String(), nil
330
+ case socksATYPIPv6:
331
+ b := make([]byte, 16)
332
+ if _, err := io.ReadFull(conn, b); err != nil {
333
+ return "", err
334
+ }
335
+ return net.IP(b).String(), nil
336
+ case socksATYPDomain:
337
+ return readLenPrefixed(conn)
338
+ default:
339
+ return "", fmt.Errorf("未知地址类型:%d", atyp)
340
+ }
341
+ }
342
+
343
+ func readLenPrefixed(r io.Reader) (string, error) {
344
+ n := make([]byte, 1)
345
+ if _, err := io.ReadFull(r, n); err != nil {
346
+ return "", err
347
+ }
348
+ b := make([]byte, int(n[0]))
349
+ if _, err := io.ReadFull(r, b); err != nil {
350
+ return "", err
351
+ }
352
+ return string(b), nil
353
+ }
354
+
355
+ func hasByte(b []byte, want byte) bool {
356
+ for _, x := range b {
357
+ if x == want {
358
+ return true
359
+ }
360
+ }
361
+ return false
362
+ }
363
+
364
+ func isLoopback(addr net.Addr) bool {
365
+ if addr == nil {
366
+ return false
367
+ }
368
+ host, _, err := net.SplitHostPort(addr.String())
369
+ if err != nil {
370
+ return false
371
+ }
372
+ ip := net.ParseIP(host)
373
+ return ip != nil && ip.IsLoopback()
374
+ }