Files
MyGoNavi/internal/app/methods_jvm.go
Syngnat ffc4f2c2d9 🐛 fix(jvm): 强化变更确认令牌校验
将 JVM 变更确认从可重算校验值升级为服务端发放的一次性令牌,避免未预览、重放或上下文变更后继续执行高风险变更。
2026-04-28 09:42:21 +08:00

384 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
"github.com/google/uuid"
)
var newJVMProvider = jvm.NewProvider
const defaultJVMPreviewConfirmationTokenTTL = 10 * time.Minute
type jvmPreviewConfirmationToken struct {
contextHash string
expiresAt time.Time
}
type jvmPreviewConfirmationContext struct {
ConfigHash string `json:"configHash"`
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source"`
ExpectedVersion string `json:"expectedVersion"`
PayloadHash string `json:"payloadHash"`
PreviewChecksum string `json:"previewChecksum"`
RiskLevel string `json:"riskLevel"`
BeforeVersion string `json:"beforeVersion"`
AfterVersion string `json:"afterVersion"`
}
func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability {
probeCfg := cfg
probeCfg.JVM.PreferredMode = mode
return jvm.Capability{
Mode: mode,
DisplayLabel: jvm.ModeDisplayLabel(mode),
Reason: jvm.DescribeConnectionTestError(probeCfg, err),
}
}
func resolveJVMProvider(cfg connection.ConnectionConfig) (connection.ConnectionConfig, jvm.Provider, error) {
return resolveJVMProviderForMode(cfg, "")
}
func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (connection.ConnectionConfig, jvm.Provider, error) {
normalized, selectedMode, err := jvm.ResolveProviderMode(cfg, mode)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
normalized.JVM.PreferredMode = selectedMode
provider, err := newJVMProvider(selectedMode)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
return normalized, provider, nil
}
func (a *App) issueJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
contextHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
if err != nil {
return "", err
}
token := uuid.NewString()
now := time.Now()
ttl := a.jvmPreviewTokenTTL
if ttl <= 0 {
ttl = defaultJVMPreviewConfirmationTokenTTL
}
a.jvmPreviewTokenMu.Lock()
defer a.jvmPreviewTokenMu.Unlock()
if a.jvmPreviewTokens == nil {
a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken)
}
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
a.jvmPreviewTokens[token] = jvmPreviewConfirmationToken{
contextHash: contextHash,
expiresAt: now.Add(ttl),
}
return token, nil
}
func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) error {
if !preview.RequiresConfirmation {
return nil
}
if strings.TrimSpace(preview.ConfirmationToken) == "" {
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
}
token := strings.TrimSpace(req.ConfirmationToken)
if token == "" {
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
}
expectedHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
if err != nil {
return err
}
now := time.Now()
a.jvmPreviewTokenMu.Lock()
if a.jvmPreviewTokens == nil {
a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken)
}
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
entry, ok := a.jvmPreviewTokens[token]
if ok {
delete(a.jvmPreviewTokens, token)
}
a.jvmPreviewTokenMu.Unlock()
if !ok {
return fmt.Errorf("确认令牌已失效,请重新预览并确认")
}
if !entry.expiresAt.After(now) {
return fmt.Errorf("确认令牌已过期,请重新预览并确认")
}
if subtle.ConstantTimeCompare([]byte(entry.contextHash), []byte(expectedHash)) != 1 {
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
}
return nil
}
func (a *App) pruneExpiredJVMPreviewConfirmationTokensLocked(now time.Time) {
for token, entry := range a.jvmPreviewTokens {
if !entry.expiresAt.After(now) {
delete(a.jvmPreviewTokens, token)
}
}
}
func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
configHash, err := hashJSONValue(cfg)
if err != nil {
return "", fmt.Errorf("生成 JVM 预览上下文失败: %w", err)
}
payloadHash, err := hashJSONValue(req.Payload)
if err != nil {
return "", fmt.Errorf("生成 JVM 预览载荷摘要失败: %w", err)
}
input := jvmPreviewConfirmationContext{
ConfigHash: configHash,
ProviderMode: strings.TrimSpace(cfg.JVM.PreferredMode),
ResourceID: strings.TrimSpace(req.ResourceID),
Action: strings.TrimSpace(req.Action),
Reason: strings.TrimSpace(req.Reason),
Source: strings.TrimSpace(req.Source),
ExpectedVersion: strings.TrimSpace(req.ExpectedVersion),
PayloadHash: payloadHash,
PreviewChecksum: strings.TrimSpace(preview.ConfirmationToken),
RiskLevel: strings.TrimSpace(preview.RiskLevel),
BeforeVersion: strings.TrimSpace(preview.Before.Version),
AfterVersion: strings.TrimSpace(preview.After.Version),
}
return hashJSONValue(input)
}
func hashJSONValue(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := provider.TestConnection(a.ctx, normalized); err != nil {
return connection.QueryResult{Success: false, Message: jvm.DescribeConnectionTestError(normalized, err)}
}
return connection.QueryResult{Success: true, Message: "JVM 连接成功"}
}
func (a *App) JVMListResources(cfg connection.ConnectionConfig, parentPath string) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
items, err := provider.ListResources(a.ctx, normalized, parentPath)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: items}
}
func (a *App) JVMGetValue(cfg connection.ConnectionConfig, resourcePath string) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
value, err := provider.GetValue(a.ctx, normalized, resourcePath)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: value}
}
func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult {
var err error
req, err = jvm.NormalizeChangeRequest(req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if preview.Allowed && preview.RequiresConfirmation {
token, err := a.issueJVMPreviewConfirmationToken(normalized, req, preview)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
preview.ConfirmationToken = token
}
return connection.QueryResult{Success: true, Data: preview}
}
func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult {
var err error
req, err = jvm.NormalizeChangeRequest(req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if !preview.Allowed {
message := strings.TrimSpace(preview.BlockingReason)
if message == "" {
message = "当前变更被 Guard 拦截"
}
return connection.QueryResult{Success: false, Message: message}
}
if err := a.consumeJVMPreviewConfirmationToken(normalized, req, preview); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
auditStore := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl"))
appendAuditRecord := func(record jvm.AuditRecord) error {
return auditStore.Append(record)
}
appendAudit := func(result string, timestamp int64) error {
return appendAuditRecord(jvm.AuditRecord{
Timestamp: timestamp,
ConnectionID: normalized.ID,
ProviderMode: normalized.JVM.PreferredMode,
ResourceID: req.ResourceID,
Action: req.Action,
Reason: req.Reason,
Source: req.Source,
Result: result,
})
}
appendWarning := func(message string, warning string) string {
message = strings.TrimSpace(message)
warning = strings.TrimSpace(warning)
if warning == "" {
return message
}
if message == "" {
return warning
}
return message + "" + warning
}
pendingTimestamp := time.Now().UnixMilli()
terminalAuditTimestamp := func() int64 {
ts := time.Now().UnixMilli()
if ts <= pendingTimestamp {
return pendingTimestamp + 1
}
return ts
}
if err := appendAudit("pending", pendingTimestamp); err != nil {
return connection.QueryResult{Success: false, Message: "审计记录写入失败,已阻止 JVM 变更: " + err.Error()}
}
result, err := provider.ApplyChange(a.ctx, normalized, req)
if err != nil {
if auditErr := appendAudit("failed", terminalAuditTimestamp()); auditErr != nil {
return connection.QueryResult{Success: false, Message: err.Error() + ";失败审计写入失败: " + auditErr.Error()}
}
return connection.QueryResult{Success: false, Message: err.Error()}
}
terminalResult := strings.TrimSpace(result.Status)
if terminalResult == "" {
terminalResult = "applied"
}
if err := appendAudit(terminalResult, terminalAuditTimestamp()); err != nil {
result.Message = appendWarning(result.Message, "终态审计写入失败: "+err.Error())
return connection.QueryResult{Success: true, Message: result.Message, Data: result}
}
return connection.QueryResult{Success: true, Data: result}
}
func (a *App) JVMListAuditRecords(connectionID string, limit int) connection.QueryResult {
records, err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).List(connectionID, limit)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: records}
}
func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, err := jvm.NormalizeConnectionConfig(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
items := make([]jvm.Capability, 0, len(normalized.JVM.AllowedModes))
for _, mode := range normalized.JVM.AllowedModes {
probeCfg := normalized
probeCfg.JVM.PreferredMode = mode
provider, providerErr := newJVMProvider(mode)
if providerErr != nil {
items = append(items, buildJVMCapabilityError(mode, probeCfg, providerErr))
continue
}
caps, probeErr := provider.ProbeCapabilities(a.ctx, probeCfg)
if probeErr != nil {
items = append(items, buildJVMCapabilityError(mode, probeCfg, probeErr))
continue
}
items = append(items, caps...)
}
return connection.QueryResult{Success: true, Data: items}
}
func (a *App) auditRootDir() string {
if strings.TrimSpace(a.configDir) != "" {
return a.configDir
}
return resolveAppConfigDir()
}