From 9898e0a0257b14d657257019ee374a60c309280e Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 16 Sep 2026 17:48:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E9=A6=96=E6=AC=A1=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E4=B8=B4=E6=97=B6TLS=E4=B8=8E=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/bootstrap/session.go | 234 +++++++++++++++++++++ backend/internal/bootstrap/session_test.go | 93 ++++++++ backend/internal/bootstrap/tls.go | 226 ++++++++++++++++++++ backend/internal/bootstrap/tls_test.go | 112 ++++++++++ backend/internal/bootstrap/token.go | 7 + 5 files changed, 672 insertions(+) create mode 100644 backend/internal/bootstrap/session.go create mode 100644 backend/internal/bootstrap/session_test.go create mode 100644 backend/internal/bootstrap/tls.go create mode 100644 backend/internal/bootstrap/tls_test.go create mode 100644 backend/internal/bootstrap/token.go diff --git a/backend/internal/bootstrap/session.go b/backend/internal/bootstrap/session.go new file mode 100644 index 0000000..c23b849 --- /dev/null +++ b/backend/internal/bootstrap/session.go @@ -0,0 +1,234 @@ +package bootstrap + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "errors" + "net" + "net/http" + "strings" + "sync" + "time" +) + +const ( + SessionCookieName = "scert_bootstrap_session" + bootstrapTokenLifetime = 30 * time.Minute + bootstrapSessionLifetime = 30 * time.Minute + bootstrapSessionCapacity = 32 + bootstrapExchangeInFlightLimit = 8 + bootstrapSourceCapacity = 256 + bootstrapSourceAttemptLimit = 6 + bootstrapSourceAttemptWindow = time.Minute + bootstrapTokenSize = 32 + bootstrapSessionTokenSize = 32 +) + +var readRandom = rand.Read + +var ( + ErrBootstrapTokenInvalid = errors.New("Bootstrap 一次性令牌无效或已经使用") + ErrBootstrapTokenExpired = errors.New("Bootstrap 一次性令牌已经过期,请重启服务生成新地址") + ErrBootstrapRateLimited = errors.New("Bootstrap 验证尝试过多,请稍后重试") + ErrBootstrapBusy = errors.New("Bootstrap 验证请求过多,请稍后重试") + ErrBootstrapSessionFull = errors.New("Bootstrap 会话数量已达上限,请等待现有会话过期后重试") +) + +type BootstrapSession struct { + Token string + ExpiresAt time.Time +} + +type bootstrapSessionRecord struct { + ExpiresAt time.Time +} + +type bootstrapSourceAttempts struct { + WindowStarted time.Time + Count int +} + +type SessionManager struct { + mu sync.Mutex + now func() time.Time + tokenHash [sha256.Size]byte + tokenExpiry time.Time + tokenUsed bool + sessions map[[sha256.Size]byte]bootstrapSessionRecord + sources map[string]bootstrapSourceAttempts + inFlight int +} + +func NewSessionManager() (*SessionManager, string, error) { + token, err := secureRandomToken(bootstrapTokenSize) + if err != nil { + return nil, "", err + } + now := time.Now().UTC() + return &SessionManager{ + now: time.Now, + tokenHash: sha256.Sum256([]byte(token)), + tokenExpiry: now.Add(bootstrapTokenLifetime), + sessions: make(map[[sha256.Size]byte]bootstrapSessionRecord), + sources: make(map[string]bootstrapSourceAttempts), + }, token, nil +} + +func (m *SessionManager) Exchange(token, source string) (BootstrapSession, error) { + if m == nil { + return BootstrapSession{}, errors.New("Bootstrap 会话服务尚未初始化") + } + source = normalizeBootstrapSource(source) + if source == "" { + return BootstrapSession{}, errors.New("Bootstrap 请求来源无效") + } + now := m.now().UTC() + if err := m.reserveExchange(source, now); err != nil { + return BootstrapSession{}, err + } + defer m.releaseExchange() + + candidateHash := sha256.Sum256([]byte(strings.TrimSpace(token))) + m.mu.Lock() + defer m.mu.Unlock() + m.pruneLocked(now) + if m.tokenUsed { + return BootstrapSession{}, ErrBootstrapTokenInvalid + } + if !m.tokenExpiry.After(now) { + return BootstrapSession{}, ErrBootstrapTokenExpired + } + if subtle.ConstantTimeCompare(candidateHash[:], m.tokenHash[:]) != 1 { + return BootstrapSession{}, ErrBootstrapTokenInvalid + } + if len(m.sessions) >= bootstrapSessionCapacity { + return BootstrapSession{}, ErrBootstrapSessionFull + } + sessionToken, err := secureRandomToken(bootstrapSessionTokenSize) + if err != nil { + return BootstrapSession{}, err + } + expiresAt := now.Add(bootstrapSessionLifetime) + m.sessions[sha256.Sum256([]byte(sessionToken))] = bootstrapSessionRecord{ExpiresAt: expiresAt} + m.tokenUsed = true + return BootstrapSession{Token: sessionToken, ExpiresAt: expiresAt}, nil +} + +func (m *SessionManager) Authenticate(token string) bool { + if m == nil || strings.TrimSpace(token) == "" { + return false + } + now := m.now().UTC() + hash := sha256.Sum256([]byte(token)) + m.mu.Lock() + defer m.mu.Unlock() + m.pruneLocked(now) + record, ok := m.sessions[hash] + return ok && record.ExpiresAt.After(now) +} + +func (m *SessionManager) RevokeAll() { + if m == nil { + return + } + m.mu.Lock() + clear(m.sessions) + m.mu.Unlock() +} + +func (m *SessionManager) reserveExchange(source string, now time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + m.pruneLocked(now) + if m.inFlight >= bootstrapExchangeInFlightLimit { + return ErrBootstrapBusy + } + attempts, ok := m.sources[source] + if !ok && len(m.sources) >= bootstrapSourceCapacity { + return ErrBootstrapRateLimited + } + if !ok || now.Sub(attempts.WindowStarted) >= bootstrapSourceAttemptWindow { + attempts = bootstrapSourceAttempts{WindowStarted: now} + } + if attempts.Count >= bootstrapSourceAttemptLimit { + return ErrBootstrapRateLimited + } + attempts.Count++ + m.sources[source] = attempts + m.inFlight++ + return nil +} + +func (m *SessionManager) releaseExchange() { + m.mu.Lock() + if m.inFlight > 0 { + m.inFlight-- + } + m.mu.Unlock() +} + +func (m *SessionManager) pruneLocked(now time.Time) { + for hash, record := range m.sessions { + if !record.ExpiresAt.After(now) { + delete(m.sessions, hash) + } + } + for source, attempts := range m.sources { + if now.Sub(attempts.WindowStarted) >= bootstrapSourceAttemptWindow { + delete(m.sources, source) + } + } +} + +func BootstrapSessionCookie(token string, expiresAt time.Time) *http.Cookie { + return &http.Cookie{ + Name: SessionCookieName, + Value: token, + Path: "/api/v1/bootstrap", + Expires: expiresAt.UTC(), + MaxAge: max(1, int(time.Until(expiresAt).Seconds())), + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + } +} + +func ClearBootstrapSessionCookie() *http.Cookie { + return &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/api/v1/bootstrap", + Expires: time.Unix(1, 0).UTC(), + MaxAge: -1, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + } +} + +func normalizeBootstrapSource(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if host, _, err := net.SplitHostPort(value); err == nil { + value = host + } + ip := net.ParseIP(strings.Trim(value, "[]")) + if ip == nil || ip.IsUnspecified() { + return "" + } + return ip.String() +} + +func secureRandomToken(size int) (string, error) { + if size < 16 { + return "", errors.New("安全令牌长度不足") + } + buffer := make([]byte, size) + if _, err := readRandom(buffer); err != nil { + return "", errors.New("生成安全令牌失败") + } + return encodeToken(buffer), nil +} diff --git a/backend/internal/bootstrap/session_test.go b/backend/internal/bootstrap/session_test.go new file mode 100644 index 0000000..e5bbd18 --- /dev/null +++ b/backend/internal/bootstrap/session_test.go @@ -0,0 +1,93 @@ +package bootstrap + +import ( + "crypto/sha256" + "errors" + "net/http" + "testing" + "time" +) + +func TestBootstrapTokenCanOnlyBeExchangedOnce(t *testing.T) { + manager, token, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + session, err := manager.Exchange(token, "192.0.2.10:49000") + if err != nil { + t.Fatalf("兑换 Bootstrap 一次性令牌失败:%v", err) + } + if session.Token == "" || !manager.Authenticate(session.Token) { + t.Fatal("Bootstrap Cookie 会话未生效") + } + if _, err := manager.Exchange(token, "192.0.2.10:49001"); !errors.Is(err, ErrBootstrapTokenInvalid) { + t.Fatalf("Bootstrap 一次性令牌被重复兑换:%v", err) + } + manager.RevokeAll() + if manager.Authenticate(session.Token) { + t.Fatal("Bootstrap 会话未被全部注销") + } +} + +func TestBootstrapSessionExpiryAndSourceRateLimit(t *testing.T) { + manager, _, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + manager.now = func() time.Time { return now } + expiredToken := "expired-session" + manager.sessions[sha256.Sum256([]byte(expiredToken))] = bootstrapSessionRecord{ExpiresAt: now.Add(-time.Second)} + if manager.Authenticate(expiredToken) { + t.Fatal("过期 Bootstrap 会话仍然有效") + } + + for index := 0; index < bootstrapSourceAttemptLimit; index++ { + if _, err := manager.Exchange("wrong-token", "198.51.100.20"); !errors.Is(err, ErrBootstrapTokenInvalid) { + t.Fatalf("错误令牌返回结果不正确:%v", err) + } + } + if _, err := manager.Exchange("wrong-token", "198.51.100.20"); !errors.Is(err, ErrBootstrapRateLimited) { + t.Fatalf("Bootstrap 来源限流未生效:%v", err) + } + manager.now = func() time.Time { return now.Add(bootstrapSourceAttemptWindow + time.Second) } + if _, err := manager.Exchange("wrong-token", "198.51.100.20"); !errors.Is(err, ErrBootstrapTokenInvalid) { + t.Fatalf("Bootstrap 来源限流窗口未恢复:%v", err) + } +} + +func TestBootstrapSessionCapacityAndGlobalConcurrencyLimit(t *testing.T) { + manager, token, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + now := manager.now().UTC() + for index := 0; index < bootstrapSessionCapacity; index++ { + value := sha256.Sum256([]byte{byte(index)}) + manager.sessions[value] = bootstrapSessionRecord{ExpiresAt: now.Add(time.Minute)} + } + if _, err := manager.Exchange(token, "203.0.113.12"); !errors.Is(err, ErrBootstrapSessionFull) { + t.Fatalf("Bootstrap 会话容量限制未生效:%v", err) + } + + manager.sessions = make(map[[sha256.Size]byte]bootstrapSessionRecord) + manager.inFlight = bootstrapExchangeInFlightLimit + if _, err := manager.Exchange(token, "203.0.113.13"); !errors.Is(err, ErrBootstrapBusy) { + t.Fatalf("Bootstrap 全局并发限制未生效:%v", err) + } +} + +func TestBootstrapCookieSecurityAttributes(t *testing.T) { + expiresAt := time.Now().UTC().Add(10 * time.Minute) + cookie := BootstrapSessionCookie("session-token", expiresAt) + if cookie.Name != SessionCookieName || cookie.Value != "session-token" { + t.Fatalf("Bootstrap Cookie 名称或值错误:%+v", cookie) + } + if cookie.Path != "/api/v1/bootstrap" || !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("Bootstrap Cookie 安全属性不完整:%+v", cookie) + } + cleared := ClearBootstrapSessionCookie() + if cleared.MaxAge != -1 || cleared.Value != "" || !cleared.Secure || !cleared.HttpOnly || cleared.SameSite != http.SameSiteStrictMode { + t.Fatalf("Bootstrap Cookie 清理属性不正确:%+v", cleared) + } +} diff --git a/backend/internal/bootstrap/tls.go b/backend/internal/bootstrap/tls.go new file mode 100644 index 0000000..4c3807b --- /dev/null +++ b/backend/internal/bootstrap/tls.go @@ -0,0 +1,226 @@ +package bootstrap + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const temporaryCertificateLifetime = 24 * time.Hour + +var interfaceAddresses = net.InterfaceAddrs + +type TLSMaterial struct { + Certificate tls.Certificate + Leaf *x509.Certificate + IPAddresses []net.IP +} + +// NewTemporaryTLSMaterial 生成仅在当前 Bootstrap 进程内使用的临时自签名证书。 +func NewTemporaryTLSMaterial(explicitPublicIP string) (TLSMaterial, error) { + ips, err := bootstrapCertificateIPs(explicitPublicIP) + if err != nil { + return TLSMaterial{}, err + } + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return TLSMaterial{}, fmt.Errorf("生成 Bootstrap 临时私钥失败:%w", err) + } + serialLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serial, err := rand.Int(rand.Reader, serialLimit) + if err != nil { + return TLSMaterial{}, fmt.Errorf("生成 Bootstrap 临时证书序列号失败:%w", err) + } + now := time.Now().UTC() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "Scert Bootstrap", + Organization: []string{"Scert"}, + }, + NotBefore: now.Add(-5 * time.Minute), + NotAfter: now.Add(temporaryCertificateLifetime), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: cloneIPs(ips), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + return TLSMaterial{}, fmt.Errorf("生成 Bootstrap 临时证书失败:%w", err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return TLSMaterial{}, fmt.Errorf("编码 Bootstrap 临时私钥失败:%w", err) + } + certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + certificate, err := tls.X509KeyPair(certificatePEM, privateKeyPEM) + if err != nil { + return TLSMaterial{}, fmt.Errorf("装载 Bootstrap 临时证书失败:%w", err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + return TLSMaterial{}, fmt.Errorf("解析 Bootstrap 临时证书失败:%w", err) + } + certificate.Leaf = leaf + return TLSMaterial{Certificate: certificate, Leaf: leaf, IPAddresses: cloneIPs(ips)}, nil +} + +func bootstrapCertificateIPs(explicitPublicIP string) ([]net.IP, error) { + result := make([]net.IP, 0, 8) + seen := make(map[string]struct{}) + appendIP := func(ip net.IP) { + if ip == nil || ip.IsUnspecified() || ip.IsMulticast() { + return + } + if ipv4 := ip.To4(); ipv4 != nil { + ip = ipv4 + } else { + ip = ip.To16() + } + if ip == nil { + return + } + key := ip.String() + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + result = append(result, append(net.IP(nil), ip...)) + } + appendIP(net.ParseIP("127.0.0.1")) + appendIP(net.ParseIP("::1")) + addresses, err := interfaceAddresses() + if err != nil { + return nil, fmt.Errorf("读取本机网络地址失败:%w", err) + } + for _, address := range addresses { + var raw string + switch value := address.(type) { + case *net.IPNet: + raw = value.IP.String() + case *net.IPAddr: + raw = value.IP.String() + default: + raw = address.String() + if host, _, splitErr := net.SplitHostPort(raw); splitErr == nil { + raw = host + } else if index := strings.LastIndex(raw, "/"); index > 0 { + raw = raw[:index] + } + } + appendIP(net.ParseIP(strings.Trim(strings.TrimSpace(raw), "[]"))) + } + if value := strings.TrimSpace(explicitPublicIP); value != "" { + ip := net.ParseIP(strings.Trim(value, "[]")) + if ip == nil || ip.IsUnspecified() || ip.IsMulticast() { + return nil, fmt.Errorf("Bootstrap 公网 IP 格式无效:%s", value) + } + appendIP(ip) + } + return result, nil +} + +func advertisedBootstrapIP(explicitPublicIP string, certificateIPs []net.IP) (net.IP, error) { + if value := strings.TrimSpace(explicitPublicIP); value != "" { + ip := net.ParseIP(strings.Trim(value, "[]")) + if ip == nil || ip.IsUnspecified() || ip.IsMulticast() { + return nil, fmt.Errorf("Bootstrap 公网 IP 格式无效:%s", value) + } + return append(net.IP(nil), ip...), nil + } + for _, ip := range certificateIPs { + if ip != nil && !ip.IsLoopback() && ip.To4() != nil { + return append(net.IP(nil), ip...), nil + } + } + for _, ip := range certificateIPs { + if ip != nil && !ip.IsLoopback() { + return append(net.IP(nil), ip...), nil + } + } + return net.ParseIP("127.0.0.1"), nil +} + +func bootstrapURL(ip net.IP, port int, token string) (string, error) { + if ip == nil || ip.IsUnspecified() || ip.IsMulticast() { + return "", errors.New("Bootstrap 访问 IP 无效") + } + if port < 1 || port > 65535 { + return "", fmt.Errorf("Bootstrap 访问端口无效:%d", port) + } + if strings.TrimSpace(token) == "" { + return "", errors.New("Bootstrap 一次性令牌不能为空") + } + value := &url.URL{ + Scheme: "https", + Host: net.JoinHostPort(ip.String(), strconv.Itoa(port)), + Path: "/", + Fragment: "token=" + url.QueryEscape(token), + } + return value.String(), nil +} + +// WriteBootstrapURL 以原子替换方式写入仅管理员可读的一次性初始化地址。 +func WriteBootstrapURL(path, value string) error { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "." || !filepath.IsAbs(path) { + return errors.New("Bootstrap 地址文件必须使用绝对路径") + } + if strings.TrimSpace(value) == "" { + return errors.New("Bootstrap 地址不能为空") + } + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return fmt.Errorf("创建 Bootstrap 运行目录失败:%w", err) + } + if err := os.Chmod(directory, 0o700); err != nil { + return fmt.Errorf("设置 Bootstrap 运行目录权限失败:%w", err) + } + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return errors.New("Bootstrap 地址文件不能是符号链接") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("检查 Bootstrap 地址文件失败:%w", err) + } + temporaryPath := path + ".tmp" + if info, err := os.Lstat(temporaryPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + return errors.New("Bootstrap 临时地址文件不能是符号链接") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("检查 Bootstrap 临时地址文件失败:%w", err) + } + if err := os.WriteFile(temporaryPath, []byte(value+"\n"), 0o600); err != nil { + return fmt.Errorf("写入 Bootstrap 临时地址文件失败:%w", err) + } + if err := os.Chmod(temporaryPath, 0o600); err != nil { + _ = os.Remove(temporaryPath) + return fmt.Errorf("设置 Bootstrap 临时地址文件权限失败:%w", err) + } + if err := os.Rename(temporaryPath, path); err != nil { + _ = os.Remove(temporaryPath) + return fmt.Errorf("替换 Bootstrap 地址文件失败:%w", err) + } + return os.Chmod(path, 0o600) +} + +func cloneIPs(values []net.IP) []net.IP { + result := make([]net.IP, 0, len(values)) + for _, value := range values { + result = append(result, append(net.IP(nil), value...)) + } + return result +} diff --git a/backend/internal/bootstrap/tls_test.go b/backend/internal/bootstrap/tls_test.go new file mode 100644 index 0000000..3e69a38 --- /dev/null +++ b/backend/internal/bootstrap/tls_test.go @@ -0,0 +1,112 @@ +package bootstrap + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "net" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +type testAddress string + +func (a testAddress) Network() string { return "test" } +func (a testAddress) String() string { return string(a) } + +func TestTemporaryTLSMaterialUsesECDSAP256AndExpectedIPSANs(t *testing.T) { + previous := interfaceAddresses + interfaceAddresses = func() ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{IP: net.ParseIP("10.20.30.40"), Mask: net.CIDRMask(24, 32)}, + testAddress("2001:db8::20/64"), + testAddress("0.0.0.0/0"), + }, nil + } + t.Cleanup(func() { interfaceAddresses = previous }) + + material, err := NewTemporaryTLSMaterial("203.0.113.18") + if err != nil { + t.Fatalf("生成 Bootstrap 临时证书失败:%v", err) + } + privateKey, ok := material.Certificate.PrivateKey.(*ecdsa.PrivateKey) + if !ok || privateKey.Curve != elliptic.P256() { + t.Fatalf("Bootstrap 临时证书未使用 ECDSA P-256:%T", material.Certificate.PrivateKey) + } + if material.Leaf == nil || material.Leaf.CheckSignature(material.Leaf.SignatureAlgorithm, material.Leaf.RawTBSCertificate, material.Leaf.Signature) != nil { + t.Fatal("Bootstrap 临时证书不是有效的自签名证书") + } + for _, expected := range []string{"127.0.0.1", "::1", "10.20.30.40", "2001:db8::20", "203.0.113.18"} { + if !containsIP(material.Leaf.IPAddresses, expected) { + t.Fatalf("Bootstrap 临时证书缺少 IP SAN:%s,实际:%v", expected, material.Leaf.IPAddresses) + } + } + if containsIP(material.Leaf.IPAddresses, "0.0.0.0") { + t.Fatal("Bootstrap 临时证书不应包含未指定地址") + } +} + +func TestBootstrapURLKeepsTokenInFragment(t *testing.T) { + value, err := bootstrapURL(net.ParseIP("2001:db8::8"), 8080, "one-time-token") + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(value) + if err != nil { + t.Fatal(err) + } + if parsed.Scheme != "https" || parsed.Host != "[2001:db8::8]:8080" || parsed.RawQuery != "" { + t.Fatalf("Bootstrap 地址格式不正确:%s", value) + } + if parsed.Fragment != "token=one-time-token" { + t.Fatalf("一次性令牌未放在 URL Fragment:%s", value) + } +} + +func TestWriteBootstrapURLRejectsSymlinkAndUsesPrivateMode(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "run", "bootstrap-url") + if err := WriteBootstrapURL(path, "https://127.0.0.1:8080/#token=test"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(data)) != "https://127.0.0.1:8080/#token=test" { + t.Fatalf("Bootstrap 地址文件内容错误:%q", data) + } + if runtime.GOOS != "windows" { + info, statErr := os.Stat(path) + if statErr != nil { + t.Fatal(statErr) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("Bootstrap 地址文件权限错误:%o", info.Mode().Perm()) + } + } + + symlinkPath := filepath.Join(directory, "bootstrap-link") + if err := os.Symlink(path, symlinkPath); err != nil { + if runtime.GOOS == "windows" { + t.Skip("当前 Windows 环境不允许创建符号链接") + } + t.Fatal(err) + } + if err := WriteBootstrapURL(symlinkPath, "https://127.0.0.1:8080/#token=other"); err == nil { + t.Fatal("Bootstrap 地址文件符号链接未被拒绝") + } +} + +func containsIP(values []net.IP, expected string) bool { + wanted := net.ParseIP(expected) + for _, value := range values { + if value.Equal(wanted) { + return true + } + } + return false +} diff --git a/backend/internal/bootstrap/token.go b/backend/internal/bootstrap/token.go new file mode 100644 index 0000000..dbb8e81 --- /dev/null +++ b/backend/internal/bootstrap/token.go @@ -0,0 +1,7 @@ +package bootstrap + +import "encoding/base64" + +func encodeToken(value []byte) string { + return base64.RawURLEncoding.EncodeToString(value) +}