实现首次启动临时TLS与安全会话
This commit is contained in:
parent
8cb1921d4a
commit
9898e0a025
234
backend/internal/bootstrap/session.go
Normal file
234
backend/internal/bootstrap/session.go
Normal file
@ -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
|
||||
}
|
||||
93
backend/internal/bootstrap/session_test.go
Normal file
93
backend/internal/bootstrap/session_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
226
backend/internal/bootstrap/tls.go
Normal file
226
backend/internal/bootstrap/tls.go
Normal file
@ -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
|
||||
}
|
||||
112
backend/internal/bootstrap/tls_test.go
Normal file
112
backend/internal/bootstrap/tls_test.go
Normal file
@ -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
|
||||
}
|
||||
7
backend/internal/bootstrap/token.go
Normal file
7
backend/internal/bootstrap/token.go
Normal file
@ -0,0 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
func encodeToken(value []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(value)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user