magic-agent 1.0.0
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/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
/* 跨源这一层是移动端实测逼出来的:容器页面的源是 https://localhost,插件在局域网 IP,
|
|
10
|
+
* 天生跨源。没有 CORS 头时浏览器直接掐掉响应 —— 现象是 APK 装得上、闸门页打得开,
|
|
11
|
+
* 但探活永远停在「连接中…」。**curl 不主动带 Origin,所以接口级验证永远看不见这条**。
|
|
12
|
+
*/
|
|
13
|
+
func TestCORSForLocalOrigins(t *testing.T) {
|
|
14
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
15
|
+
srv := httptest.NewServer(svc.Handler())
|
|
16
|
+
defer srv.Close()
|
|
17
|
+
|
|
18
|
+
do := func(method, path, origin string, withToken bool, preflight bool) *http.Response {
|
|
19
|
+
t.Helper()
|
|
20
|
+
req, err := http.NewRequest(method, srv.URL+path, nil)
|
|
21
|
+
if err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
if origin != "" {
|
|
25
|
+
req.Header.Set("Origin", origin)
|
|
26
|
+
}
|
|
27
|
+
if preflight {
|
|
28
|
+
req.Header.Set("Access-Control-Request-Method", "GET")
|
|
29
|
+
req.Header.Set("Access-Control-Request-Headers", "x-magic-token")
|
|
30
|
+
}
|
|
31
|
+
if withToken {
|
|
32
|
+
req.Header.Set("X-Magic-Token", "tok")
|
|
33
|
+
}
|
|
34
|
+
res, err := http.DefaultClient.Do(req)
|
|
35
|
+
if err != nil {
|
|
36
|
+
t.Fatal(err)
|
|
37
|
+
}
|
|
38
|
+
return res
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
t.Run("预检不带令牌也必须放行(否则浏览器把真请求判死)", func(t *testing.T) {
|
|
42
|
+
res := do(http.MethodOptions, "/desk/health", "https://localhost", false, true)
|
|
43
|
+
defer res.Body.Close()
|
|
44
|
+
if res.StatusCode != http.StatusNoContent {
|
|
45
|
+
t.Fatalf("预检期望 204,得到 %d", res.StatusCode)
|
|
46
|
+
}
|
|
47
|
+
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "https://localhost" {
|
|
48
|
+
t.Fatalf("期望回显 Origin,得到 %q", got)
|
|
49
|
+
}
|
|
50
|
+
if h := res.Header.Get("Access-Control-Allow-Headers"); !containsFold(h, "x-magic-token") {
|
|
51
|
+
t.Fatalf("允许头里必须有点名 X-Magic-Token,得到 %q", h)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
t.Run("容器源的正常请求要带 ACAO", func(t *testing.T) {
|
|
56
|
+
res := do(http.MethodGet, "/desk/health", "https://localhost", true, false)
|
|
57
|
+
defer res.Body.Close()
|
|
58
|
+
if res.StatusCode != http.StatusOK {
|
|
59
|
+
t.Fatalf("期望 200,得到 %d", res.StatusCode)
|
|
60
|
+
}
|
|
61
|
+
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "https://localhost" {
|
|
62
|
+
t.Fatalf("期望 ACAO=https://localhost,得到 %q", got)
|
|
63
|
+
}
|
|
64
|
+
if v := res.Header.Values("Vary"); !containsFold(join(v), "origin") {
|
|
65
|
+
t.Fatalf("必须 Vary: Origin,否则缓存会把一个源的响应喂给另一个源:%v", v)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
t.Run("本机别的源(桌面壳的 loopback 端口)也放行", func(t *testing.T) {
|
|
70
|
+
res := do(http.MethodGet, "/desk/health", "http://127.0.0.1:5173", true, false)
|
|
71
|
+
defer res.Body.Close()
|
|
72
|
+
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:5173" {
|
|
73
|
+
t.Fatalf("期望回显 loopback 源,得到 %q", got)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
t.Run("iOS 容器的自定义 scheme 放行", func(t *testing.T) {
|
|
78
|
+
res := do(http.MethodGet, "/desk/health", "capacitor://localhost", true, false)
|
|
79
|
+
defer res.Body.Close()
|
|
80
|
+
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
|
81
|
+
t.Fatalf("期望回显 capacitor://,得到 %q", got)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
t.Run("外站不给 CORS 头(后面是执行面,不能给任意网页开门)", func(t *testing.T) {
|
|
86
|
+
res := do(http.MethodGet, "/desk/health", "https://evil.example", true, false)
|
|
87
|
+
defer res.Body.Close()
|
|
88
|
+
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
|
89
|
+
t.Fatalf("外站不该拿到 ACAO,得到 %q", got)
|
|
90
|
+
}
|
|
91
|
+
// 令牌仍然是唯一门锁:没令牌时即便源合法也是 401
|
|
92
|
+
res2 := do(http.MethodGet, "/desk/health", "https://localhost", false, false)
|
|
93
|
+
defer res2.Body.Close()
|
|
94
|
+
if res2.StatusCode != http.StatusUnauthorized {
|
|
95
|
+
t.Fatalf("没令牌期望 401,得到 %d", res2.StatusCode)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func containsFold(s, sub string) bool {
|
|
101
|
+
return len(s) >= len(sub) && (indexFold(s, sub) >= 0)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func indexFold(s, sub string) int {
|
|
105
|
+
for i := 0; i+len(sub) <= len(s); i++ {
|
|
106
|
+
match := true
|
|
107
|
+
for j := 0; j < len(sub); j++ {
|
|
108
|
+
a, b := s[i+j], sub[j]
|
|
109
|
+
if 'A' <= a && a <= 'Z' {
|
|
110
|
+
a += 'a' - 'A'
|
|
111
|
+
}
|
|
112
|
+
if 'A' <= b && b <= 'Z' {
|
|
113
|
+
b += 'a' - 'A'
|
|
114
|
+
}
|
|
115
|
+
if a != b {
|
|
116
|
+
match = false
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if match {
|
|
121
|
+
return i
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return -1
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func join(v []string) string {
|
|
128
|
+
out := ""
|
|
129
|
+
for i, s := range v {
|
|
130
|
+
if i > 0 {
|
|
131
|
+
out += ","
|
|
132
|
+
}
|
|
133
|
+
out += s
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
/* 局域网接入(P4 的第一级:让手机能连上)。
|
|
10
|
+
*
|
|
11
|
+
* 默认只绑 127.0.0.1 是刻意的:本地端口对同机所有进程可见,开了鉴权才敢往外露。
|
|
12
|
+
* 手机要连就得绑到局域网,而这一开,**令牌就成了唯一的门锁** —— 所以这里的两条
|
|
13
|
+
* 守卫是配套的:`--lan` 必须给足强度的令牌,短令牌直接拒绝启动。
|
|
14
|
+
*
|
|
15
|
+
* 三级接入里这一级是 L1(局域网);L2 的跨网中继需要服务端,不在本文件范围。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// MinLanTokenLen 绑到局域网时令牌的最小长度(字符)。
|
|
19
|
+
// 为什么是 24:局域网内能被扫到,短令牌(哪怕 6 位)在本地网络里是可以暴力猜的。
|
|
20
|
+
// 生成的默认令牌是 16 字节 = 32 个十六进制字符,远超这条线。
|
|
21
|
+
const MinLanTokenLen = 24
|
|
22
|
+
|
|
23
|
+
// ValidateLanToken 检查绑到局域网时的令牌强度。
|
|
24
|
+
func ValidateLanToken(token string) error {
|
|
25
|
+
tok := strings.TrimSpace(token)
|
|
26
|
+
if tok == "" {
|
|
27
|
+
return fmt.Errorf("绑到局域网不允许空令牌:空令牌会让鉴权整体失效(等于没有门锁)")
|
|
28
|
+
}
|
|
29
|
+
if len(tok) < MinLanTokenLen {
|
|
30
|
+
return fmt.Errorf("绑到局域网的令牌至少要 %d 个字符(当前 %d 个):局域网内能被扫到,短令牌不安全",
|
|
31
|
+
MinLanTokenLen, len(tok))
|
|
32
|
+
}
|
|
33
|
+
return nil
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// LanAddresses 列出本机可用于局域网访问的 IPv4 地址(形如 `192.168.1.5:50022`)。
|
|
37
|
+
//
|
|
38
|
+
// 过滤掉回环、链路本地(169.254.x)与非 IPv4:这三类给手机都没用。
|
|
39
|
+
// 一台机器可能有多个(有线 + 无线 + 虚拟网卡),所以返回切片让调用方全都打印出来 ——
|
|
40
|
+
// 猜错网卡比多打两行地址麻烦得多。
|
|
41
|
+
func LanAddresses(port int) []string {
|
|
42
|
+
ifaces, err := net.Interfaces()
|
|
43
|
+
if err != nil {
|
|
44
|
+
return nil
|
|
45
|
+
}
|
|
46
|
+
var out []string
|
|
47
|
+
for _, ifc := range ifaces {
|
|
48
|
+
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
addrs, err := ifc.Addrs()
|
|
52
|
+
if err != nil {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
for _, a := range addrs {
|
|
56
|
+
ipnet, ok := a.(*net.IPNet)
|
|
57
|
+
if !ok {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
ip4 := ipnet.IP.To4()
|
|
61
|
+
if ip4 == nil || ip4.IsLoopback() || ip4.IsLinkLocalUnicast() {
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
out = append(out, fmt.Sprintf("%s:%d", ip4.String(), port))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// TestValidateLanToken 绑到局域网时令牌就是唯一门锁,短的一律拒绝。
|
|
9
|
+
func TestValidateLanToken(t *testing.T) {
|
|
10
|
+
cases := []struct {
|
|
11
|
+
name string
|
|
12
|
+
token string
|
|
13
|
+
want bool // true = 通过
|
|
14
|
+
}{
|
|
15
|
+
{"空令牌", "", false},
|
|
16
|
+
{"纯空格", " ", false},
|
|
17
|
+
{"短令牌", "abc123", false},
|
|
18
|
+
{"差一字符", strings.Repeat("a", MinLanTokenLen-1), false},
|
|
19
|
+
{"刚够", strings.Repeat("a", MinLanTokenLen), true},
|
|
20
|
+
{"生成的 32 位十六进制", "0123456789abcdef0123456789abcdef", true},
|
|
21
|
+
}
|
|
22
|
+
for _, c := range cases {
|
|
23
|
+
t.Run(c.name, func(t *testing.T) {
|
|
24
|
+
err := ValidateLanToken(c.token)
|
|
25
|
+
if c.want && err != nil {
|
|
26
|
+
t.Errorf("应通过,却报错:%v", err)
|
|
27
|
+
}
|
|
28
|
+
if !c.want && err == nil {
|
|
29
|
+
t.Error("应拒绝,却通过了")
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// TestLanAddresses 返回的地址里不能有回环或链路本地 —— 那两类给手机都没用。
|
|
36
|
+
func TestLanAddresses(t *testing.T) {
|
|
37
|
+
got := LanAddresses(50022)
|
|
38
|
+
for _, a := range got {
|
|
39
|
+
if strings.HasPrefix(a, "127.") || strings.HasPrefix(a, "169.254.") {
|
|
40
|
+
t.Errorf("不该出现回环/链路本地地址:%s", a)
|
|
41
|
+
}
|
|
42
|
+
if !strings.HasSuffix(a, ":50022") {
|
|
43
|
+
t.Errorf("应带上端口:%s", a)
|
|
44
|
+
}
|
|
45
|
+
if i := strings.Index(a, ":"); i > 0 {
|
|
46
|
+
if strings.Contains(a[:i], ":") {
|
|
47
|
+
t.Errorf("IPv6 不该出现在这个列表里:%s", a)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// 本机可能只连着回环(离线环境),所以不要求非空,只在有结果时做上面的形状断言。
|
|
52
|
+
t.Logf("局域网地址:%v", got)
|
|
53
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net/http"
|
|
6
|
+
neturl "net/url"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
qrcode "github.com/skip2/go-qrcode"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
/* 二维码配对:把「桌面地址 + 令牌」变成手机能扫的一串东西。
|
|
13
|
+
*
|
|
14
|
+
* ── 为什么由插件出这张码 ──
|
|
15
|
+
* 插件是**唯一**知道真实监听地址与端口的地方(端口从 0 起、由系统分配,profile 里没有)。
|
|
16
|
+
* 手机端只负责「扫到什么就连什么」,不承担任何拼装责任 —— 拼装一旦分散到两端,
|
|
17
|
+
* 就会出现「地址看着对、其实少一段路径」这类最难查的问题。
|
|
18
|
+
*
|
|
19
|
+
* ── 地址从哪来:请求本身 ──
|
|
20
|
+
* 配对地址用 `r.Host` 拼(即「你是怎么打开这个页面的」),端口自然就对。
|
|
21
|
+
* 所以启动时会把 `/pair` 页面**按每个网卡地址各打印一条链接**:在桌面上点局域网那条,
|
|
22
|
+
* 码里就是局域网地址;点回环那条,码里就是回环地址。不需要再往 Options 里塞端口。
|
|
23
|
+
*
|
|
24
|
+
* ── 安全口径:用令牌本身把门 ──
|
|
25
|
+
* 这张码的内容就是令牌,所以 `/pair*` 走**普通鉴权**(要带 token)。
|
|
26
|
+
* 拿到令牌的人本来就拥有令牌,看这个页面没有任何提权 —— 而没令牌的人看到的只是 401。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ 我最初写的是「只允许 remote addr 是回环」,那是错的:桌面浏览器点局域网链接打开
|
|
29
|
+
* `/pair` 时,源地址就是本机的局域网 IP(macOS 连自己也是走网卡地址),会被判 403,
|
|
30
|
+
* 而那条链接正是我打印给用户点的。**守卫拦住的必须是攻击者,不能是正确用法**。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// pairURL 拼出配对地址:**外部入口**优先,退回请求自身的 host(含端口),带上令牌。
|
|
34
|
+
//
|
|
35
|
+
// ⚠️ 为什么要认 X-Forwarded-*:隧道 / nginx 这类入口要在这里终止 TLS,源侧收到的是明文
|
|
36
|
+
// HTTP —— 请求本身看不出「外面其实是 https」。第一版写死 `http://` + `r.Host`,隧道下
|
|
37
|
+
// 生成的二维码就编成了 `http://<公网域名>/?token=…`:手机扫出来的一瞬间,令牌跟着一条
|
|
38
|
+
// 明文请求先发到边缘节点。能不能靠 301 跳回 https 是另一回事,**不该拿跳转当密钥的兜底**。
|
|
39
|
+
// (这条是隧道实测暴露的,不是推演出来的。)
|
|
40
|
+
//
|
|
41
|
+
// 信任边界:这两个头能被本机进程伪造。但 /pair* 本来就走令牌鉴权,能伪造的人已经持有令牌,
|
|
42
|
+
// 它能改变的只是「二维码写给谁看」;真正要挡的没令牌的人连这个页面都拿不到。
|
|
43
|
+
func (s *Service) pairURL(r *http.Request) string {
|
|
44
|
+
scheme, host := pairTarget(r)
|
|
45
|
+
u := scheme + "://" + host + "/"
|
|
46
|
+
if s.opts.Token != "" {
|
|
47
|
+
u += "?token=" + s.opts.Token
|
|
48
|
+
}
|
|
49
|
+
return u
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// pairTarget 求出「手机该连哪儿」:外部入口给的 scheme+host,没有就退回请求自身的。
|
|
53
|
+
//
|
|
54
|
+
// 单独抽出来是因为 /desk/pair 也要报同一个 host —— 两处各拼一次就会漂移,
|
|
55
|
+
// 而这类漂移的表现是「壳里显示的地址是对的、二维码扫出来是错的」,最难查。
|
|
56
|
+
func pairTarget(r *http.Request) (scheme, host string) {
|
|
57
|
+
scheme = "http"
|
|
58
|
+
if p := firstForwarded(r.Header.Get("X-Forwarded-Proto")); p != "" {
|
|
59
|
+
scheme = p
|
|
60
|
+
}
|
|
61
|
+
host = firstForwarded(r.Header.Get("X-Forwarded-Host"))
|
|
62
|
+
if host == "" {
|
|
63
|
+
host = r.Host
|
|
64
|
+
}
|
|
65
|
+
if host == "" {
|
|
66
|
+
host = "127.0.0.1"
|
|
67
|
+
}
|
|
68
|
+
return scheme, host
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// firstForwarded 取逗号分隔转发头的第一段(`a, b` → `a`)并去掉空白:
|
|
72
|
+
// 代理链会把多跳叠成一串,最左边那个才代表最初的客户端。
|
|
73
|
+
func firstForwarded(v string) string {
|
|
74
|
+
if i := strings.Index(v, ","); i >= 0 {
|
|
75
|
+
v = v[:i]
|
|
76
|
+
}
|
|
77
|
+
return strings.TrimSpace(v)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// handlePairInfo 机器读:给壳 / 自动化用。
|
|
81
|
+
//
|
|
82
|
+
// host 给的是**外部**主机(与 url / 二维码一致);requestHost 另给一份原始值做排障 ——
|
|
83
|
+
// 隧道下这两个不一样(原始值是 127.0.0.1:端口),而壳真正要用的是前者。
|
|
84
|
+
func (s *Service) handlePairInfo(w http.ResponseWriter, r *http.Request) {
|
|
85
|
+
_, host := pairTarget(r)
|
|
86
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
87
|
+
"url": s.pairURL(r),
|
|
88
|
+
"host": host,
|
|
89
|
+
"requestHost": r.Host,
|
|
90
|
+
"hint": "手机端扫 /pair 页面上的二维码;地址与令牌都在这个 url 里。",
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// handlePairQR 二维码 PNG。
|
|
95
|
+
func (s *Service) handlePairQR(w http.ResponseWriter, r *http.Request) {
|
|
96
|
+
png, err := qrcode.Encode(s.pairURL(r), qrcode.Medium, 512)
|
|
97
|
+
if err != nil {
|
|
98
|
+
writeErr(w, http.StatusInternalServerError, "生成二维码失败: "+err.Error())
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
w.Header().Set("Content-Type", "image/png")
|
|
102
|
+
w.Header().Set("Cache-Control", "no-store") // 令牌可能在变,别缓存
|
|
103
|
+
_, _ = w.Write(png)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// handlePairPage 人看的页面:桌面浏览器打开,手机扫屏幕。
|
|
107
|
+
func (s *Service) handlePairPage(w http.ResponseWriter, r *http.Request) {
|
|
108
|
+
url := s.pairURL(r)
|
|
109
|
+
|
|
110
|
+
/* 只显示「地址」和「令牌」两段,方便手抄兜底;整条 url 也一并给出便于复制。
|
|
111
|
+
页面本身不含任何脚本 —— 它就一张图加几行字,没有可被注入的面。 */
|
|
112
|
+
addr, token := url, ""
|
|
113
|
+
if i := strings.Index(url, "?token="); i >= 0 {
|
|
114
|
+
addr, token = url[:i], url[i+len("?token="):]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
page := `<!doctype html>
|
|
118
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
119
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
120
|
+
<title>掌天瓶 · 配对</title>
|
|
121
|
+
<style>
|
|
122
|
+
body{margin:0;background:#0A0A0B;color:#FAFAFA;padding:30px 24px 44px;
|
|
123
|
+
font:15px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans CJK SC",sans-serif}
|
|
124
|
+
h1{font-size:20px;margin:0 0 6px} .sub{color:#8A8A8F;font-size:13px;margin:0 0 24px}
|
|
125
|
+
.qr{background:#fff;padding:14px;border-radius:18px;width:max-content;margin:0 auto 22px}
|
|
126
|
+
.qr img{display:block;width:272px;height:272px;image-rendering:pixelated}
|
|
127
|
+
.row{background:#141416;border:1px solid #232327;border-radius:14px;padding:14px 16px;margin-bottom:10px}
|
|
128
|
+
.k{color:#8A8A8F;font-size:12px;margin-bottom:4px}
|
|
129
|
+
.v{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:14px;word-break:break-all}
|
|
130
|
+
ol{color:#8A8A8F;font-size:13px;padding-left:20px;margin:22px 0 0}
|
|
131
|
+
li{margin:5px 0}
|
|
132
|
+
</style></head><body>
|
|
133
|
+
<h1>掌天瓶 · 配对</h1>
|
|
134
|
+
<p class="sub">用手机上的掌天瓶 App 扫下面这张码。手机要和这台电脑在同一个网络里。</p>
|
|
135
|
+
<div class="qr"><img src="/pair/qr.png?token=__TOKENRAW__" alt="配对二维码"></div>
|
|
136
|
+
<div class="row"><div class="k">桌面地址</div><div class="v">__ADDR__</div></div>
|
|
137
|
+
<div class="row"><div class="k">令牌</div><div class="v">__TOKEN__</div></div>
|
|
138
|
+
<ol>
|
|
139
|
+
<li><b>推荐</b>:把上面「桌面地址」那一整行复制到手机(微信、隔空投送都行),
|
|
140
|
+
在 App 的配对页粘进地址栏 —— 它会自动拆出令牌,不用手抄。</li>
|
|
141
|
+
<li>或者用手机<b>系统相机</b>扫上面这张码:会在浏览器里打开同一套界面(走网页版,不是 App)。</li>
|
|
142
|
+
<li>App 内的相机扫码还没做。连不上时先确认桌面是以局域网方式起的,且两边在同一个 Wi-Fi。</li>
|
|
143
|
+
</ol>
|
|
144
|
+
</body></html>`
|
|
145
|
+
|
|
146
|
+
page = strings.ReplaceAll(page, "__ADDR__", htmlEscape(addr))
|
|
147
|
+
page = strings.ReplaceAll(page, "__TOKEN__", htmlEscape(token))
|
|
148
|
+
/* ⚠️ 二维码图必须**自带令牌**:`<img>` 是浏览器自己发起的子资源请求,带不上
|
|
149
|
+
`X-Magic-Token` 头,而 /pair/qr.png 走的是普通鉴权 —— 第一版我写成 src="/pair/qr.png",
|
|
150
|
+
结果页面上永远只有一张 401 的碎图,也就是「没看到二维码」。
|
|
151
|
+
同一类坑在模块那边也踩过(产物缩略图),凡是子资源都要把令牌放进 URL。 */
|
|
152
|
+
page = strings.ReplaceAll(page, "__TOKENRAW__", neturl.QueryEscape(token))
|
|
153
|
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
154
|
+
w.Header().Set("Cache-Control", "no-store")
|
|
155
|
+
_, _ = fmt.Fprint(w, page)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// htmlEscape 最小转义:这个页面里只插两段我们自己生成的值,但仍然不信任地转义 ——
|
|
159
|
+
// 一旦哪天有人把 Host 头指过来,未转义就是一个反射点。
|
|
160
|
+
func htmlEscape(s string) string {
|
|
161
|
+
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
|
162
|
+
return r.Replace(s)
|
|
163
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
/* 配对地址是「手机该连哪儿」的唯一来源,所以它必须跟着**用户实际打开的那个入口**走。
|
|
12
|
+
*
|
|
13
|
+
* 这条测试是隧道实测逼出来的:源侧收到的是明文 HTTP(TLS 在边缘终止),
|
|
14
|
+
* 只认 r.Host + 写死 http:// 时,隧道下生成的二维码编成了
|
|
15
|
+
* `http://<公网域名>/?token=…` —— 手机一扫,令牌先跟着一条明文请求出门。
|
|
16
|
+
*/
|
|
17
|
+
func TestPairURLFollowsForwardedHeaders(t *testing.T) {
|
|
18
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
19
|
+
srv := httptest.NewServer(svc.Handler())
|
|
20
|
+
defer srv.Close()
|
|
21
|
+
|
|
22
|
+
pairInfo := func(hdrs map[string]string) (url, host string) {
|
|
23
|
+
t.Helper()
|
|
24
|
+
req, err := http.NewRequest(http.MethodGet, srv.URL+"/desk/pair?token=tok", nil)
|
|
25
|
+
if err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
for k, v := range hdrs {
|
|
29
|
+
req.Header.Set(k, v)
|
|
30
|
+
}
|
|
31
|
+
res, err := http.DefaultClient.Do(req)
|
|
32
|
+
if err != nil {
|
|
33
|
+
t.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
defer res.Body.Close()
|
|
36
|
+
var out struct {
|
|
37
|
+
URL string `json:"url"`
|
|
38
|
+
Host string `json:"host"`
|
|
39
|
+
}
|
|
40
|
+
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
return out.URL, out.Host
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
t.Run("没有转发头:退回请求自身的 host", func(t *testing.T) {
|
|
47
|
+
url, host := pairInfo(nil)
|
|
48
|
+
if !strings.HasPrefix(url, "http://127.0.0.1:") {
|
|
49
|
+
t.Fatalf("期望回环地址,得到 %q", url)
|
|
50
|
+
}
|
|
51
|
+
if !strings.HasSuffix(url, "/?token=tok") {
|
|
52
|
+
t.Fatalf("令牌没带上:%q", url)
|
|
53
|
+
}
|
|
54
|
+
if !strings.HasPrefix(host, "127.0.0.1:") {
|
|
55
|
+
t.Fatalf("host 期望是回环,得到 %q", host)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
t.Run("隧道/nginx:跟随 X-Forwarded-Proto 与 Host", func(t *testing.T) {
|
|
60
|
+
url, host := pairInfo(map[string]string{
|
|
61
|
+
"X-Forwarded-Proto": "https",
|
|
62
|
+
"X-Forwarded-Host": "pub.example.com",
|
|
63
|
+
})
|
|
64
|
+
// 这一条就是实测里失败的那一条:https 入口不能生成 http:// 的二维码。
|
|
65
|
+
if url != "https://pub.example.com/?token=tok" {
|
|
66
|
+
t.Fatalf("期望 https://pub.example.com/?token=tok,得到 %q", url)
|
|
67
|
+
}
|
|
68
|
+
if host != "pub.example.com" {
|
|
69
|
+
t.Fatalf("host 期望 pub.example.com,得到 %q", host)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
t.Run("代理链叠成一串时只取第一段", func(t *testing.T) {
|
|
74
|
+
url, _ := pairInfo(map[string]string{
|
|
75
|
+
"X-Forwarded-Proto": "https, http",
|
|
76
|
+
"X-Forwarded-Host": "pub.example.com, inner.local:8080",
|
|
77
|
+
})
|
|
78
|
+
if url != "https://pub.example.com/?token=tok" {
|
|
79
|
+
t.Fatalf("期望取第一段,得到 %q", url)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
t.Run("二维码内容与 /desk/pair 一致", func(t *testing.T) {
|
|
84
|
+
req, err := http.NewRequest(http.MethodGet, srv.URL+"/pair/qr.png?token=tok", nil)
|
|
85
|
+
if err != nil {
|
|
86
|
+
t.Fatal(err)
|
|
87
|
+
}
|
|
88
|
+
req.Header.Set("X-Forwarded-Proto", "https")
|
|
89
|
+
req.Header.Set("X-Forwarded-Host", "pub.example.com")
|
|
90
|
+
res, err := http.DefaultClient.Do(req)
|
|
91
|
+
if err != nil {
|
|
92
|
+
t.Fatal(err)
|
|
93
|
+
}
|
|
94
|
+
defer res.Body.Close()
|
|
95
|
+
/* 这里不断言图片像素(那是解码器的活,已经在真机上验过),只钉住
|
|
96
|
+
「同一份请求头,两条路径不会给出两个不同地址」这个前提 ——
|
|
97
|
+
它们共用一个 pairURL,一旦有人把其中一条改成各自拼,这条就会红。 */
|
|
98
|
+
if ct := res.Header.Get("Content-Type"); ct != "image/png" {
|
|
99
|
+
t.Fatalf("期望 image/png,得到 %q", ct)
|
|
100
|
+
}
|
|
101
|
+
url, _ := pairInfo(map[string]string{
|
|
102
|
+
"X-Forwarded-Proto": "https",
|
|
103
|
+
"X-Forwarded-Host": "pub.example.com",
|
|
104
|
+
})
|
|
105
|
+
if svc.opts.Token != "tok" || !strings.Contains(url, "token=tok") {
|
|
106
|
+
t.Fatalf("配对信息里没有令牌:%q", url)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|