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.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /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
|
+
}
|