package nodecli import ( "context" "crypto/ed25519" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "evobgp/internal/birdfmt" "evobgp/internal/bundle" "evobgp/internal/signing" ) // SyncConfig drives pull → verify → apply on a replica node. type SyncConfig struct { BaseURL string Token string SpeakerID string RevisionID string // empty = latest published on CP PubKeyB64 string PubKeyHex string ExtractDir string BundlePath string // temp file; default os.TempDir()/evobgp-bundle.tar.gz BirdBin string BirdcBin string Socket string HTTPClient interface { Do(req interface{}) (interface{}, error) } Timeout time.Duration } // SyncResult summarizes a successful sync. type SyncResult struct { RevisionID string `json:"revision_id"` MainConfig string `json:"main_config,omitempty"` } // SyncBundle pulls (if needed), verifies Ed25519 signature, extracts, parse-checks, and birdc configure. func SyncBundle(ctx context.Context, cfg SyncConfig) (SyncResult, error) { if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" || strings.TrimSpace(cfg.SpeakerID) == "" { return SyncResult{}, fmt.Errorf("nodecli: sync: base-url, token, speaker-id required") } if strings.TrimSpace(cfg.ExtractDir) == "" { return SyncResult{}, fmt.Errorf("nodecli: sync: extract-dir required") } pub, err := loadPubKey(cfg.PubKeyB64, cfg.PubKeyHex) if err != nil { return SyncResult{}, fmt.Errorf("nodecli: sync: %w", err) } timeout := cfg.Timeout if timeout <= 0 { timeout = 30 * time.Second } rev := strings.TrimSpace(cfg.RevisionID) if rev == "" { var err error rev, err = fetchLatestRevision(cfg.BaseURL, cfg.Token, cfg.SpeakerID) if err != nil { return SyncResult{}, err } } raw, err := fetchBundle(cfg.BaseURL, cfg.Token, cfg.SpeakerID, rev) if err != nil { return SyncResult{}, err } bundlePath := strings.TrimSpace(cfg.BundlePath) if bundlePath == "" { bundlePath = filepath.Join(os.TempDir(), "evobgp-bundle.tar.gz") } if err := os.WriteFile(bundlePath, raw, 0o644); err != nil { return SyncResult{}, err } v, err := signing.VerifyGzippedTar(raw, pub) if err != nil { return SyncResult{}, err } root := filepath.Clean(cfg.ExtractDir) if err := os.MkdirAll(root, 0o755); err != nil { return SyncResult{}, err } if err := bundle.WriteExtractedFiles(root, v); err != nil { return SyncResult{}, err } mainRel := v.FindMainBirdConf() if mainRel == "" { return SyncResult{}, fmt.Errorf("nodecli: sync: bundle has no bird.conf in manifest") } mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/"))) opCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket} if err := ctl.ParseCheck(opCtx, mainPath); err != nil { return SyncResult{}, err } if err := ctl.Configure(opCtx); err != nil { return SyncResult{}, err } return SyncResult{RevisionID: rev, MainConfig: mainPath}, nil } // SyncResultJSON encodes SyncResult for HTTP responses. func SyncResultJSON(r SyncResult) ([]byte, error) { return json.Marshal(map[string]any{ "ok": true, "applied_revision_id": r.RevisionID, "main_config": r.MainConfig, }) } // LoadPublicKey exports loadPubKey for other packages. func LoadPublicKey(pubB64, pubHex string) (ed25519.PublicKey, error) { return loadPubKey(pubB64, pubHex) }