package main import ( "context" "crypto/ed25519" "encoding/base64" "encoding/json" "flag" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "evobgp/internal/birdfmt" "evobgp/internal/bundle" "evobgp/internal/signing" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } switch os.Args[1] { case "pull-bundle": os.Exit(cmdPullBundle(os.Args[2:])) case "verify-bundle": os.Exit(cmdVerifyBundle(os.Args[2:])) case "apply-bundle": os.Exit(cmdApplyBundle(os.Args[2:])) default: usage() os.Exit(2) } } func usage() { fmt.Fprintf(os.Stderr, `Usage: %s pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path] %s verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX) %s apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX) [-bird PATH] [-birdc PATH] [-socket PATH] [-timeout DURATION] apply-bundle verifies, extracts, runs bird -p on main bird.conf, then birdc configure. `, os.Args[0], os.Args[0], os.Args[0]) } func cmdPullBundle(args []string) int { fs := flag.NewFlagSet("pull-bundle", flag.ExitOnError) base := fs.String("base-url", "", "control plane base URL, e.g. http://localhost:8080") token := fs.String("token", "", "Bearer token (node role)") speaker := fs.String("speaker-id", "", "bgp_speaker id") revision := fs.String("revision-id", "", "revision to fetch (empty = latest pointer)") out := fs.String("o", "bundle.tar.gz", "output file") _ = fs.Parse(args) if strings.TrimSpace(*base) == "" || *token == "" || *speaker == "" { fmt.Fprintln(os.Stderr, "pull-bundle: -base-url, -token, -speaker-id are required") return 2 } rev := strings.TrimSpace(*revision) if rev == "" { var err error rev, err = fetchLatestRevision(*base, *token, *speaker) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } } body, err := fetchBundle(*base, *token, *speaker, rev) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } if err := os.WriteFile(*out, body, 0o644); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } fmt.Fprintf(os.Stderr, "wrote %s (revision %s)\n", *out, rev) return 0 } func fetchLatestRevision(base, token, speaker string) (string, error) { u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest" req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b))) } var out struct { RevisionID string `json:"revision_id"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } if out.RevisionID == "" { return "", fmt.Errorf("empty revision_id in response") } return out.RevisionID, nil } func fetchBundle(base, token, speaker, revision string) ([]byte, error) { u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/bundle/" + revision req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b))) } return io.ReadAll(resp.Body) } func loadPubKey(pubB64, pubHex string) (ed25519.PublicKey, error) { switch { case strings.TrimSpace(pubB64) != "": raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pubB64)) if err != nil { return nil, err } if len(raw) != ed25519.PublicKeySize { return nil, fmt.Errorf("pubkey-base64 must decode to %d bytes", ed25519.PublicKeySize) } return ed25519.PublicKey(raw), nil case strings.TrimSpace(pubHex) != "": return bundle.ParsePublicKeyHex(pubHex) default: return nil, fmt.Errorf("public key required") } } func cmdVerifyBundle(args []string) int { fs := flag.NewFlagSet("verify-bundle", flag.ExitOnError) path := fs.String("f", "", "path to bundle.tar.gz") pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)") pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)") _ = fs.Parse(args) if *path == "" { fmt.Fprintln(os.Stderr, "verify-bundle: -f required") return 2 } pub, err := loadPubKey(*pubB64, *pubHex) if err != nil { fmt.Fprintln(os.Stderr, err) return 2 } raw, err := os.ReadFile(*path) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } v, err := signing.VerifyGzippedTar(raw, pub) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } fmt.Fprintf(os.Stderr, "ok: revision %s, %d payload files\n", v.Manifest.RevisionID, len(v.Files)) return 0 } func cmdApplyBundle(args []string) int { fs := flag.NewFlagSet("apply-bundle", flag.ExitOnError) path := fs.String("f", "", "path to bundle.tar.gz") dir := fs.String("extract-dir", "", "directory to extract into") pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)") pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)") bird := fs.String("bird", "", "bird binary (default PATH)") birdc := fs.String("birdc", "", "birdc binary (default PATH)") socket := fs.String("socket", "", "birdc -s socket path") timeout := fs.Duration("timeout", 30*time.Second, "timeout for bird/birdc") _ = fs.Parse(args) if *path == "" || *dir == "" { fmt.Fprintln(os.Stderr, "apply-bundle: -f and -extract-dir required") return 2 } pub, err := loadPubKey(*pubB64, *pubHex) if err != nil { fmt.Fprintln(os.Stderr, err) return 2 } raw, err := os.ReadFile(*path) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } v, err := signing.VerifyGzippedTar(raw, pub) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } root := filepath.Clean(*dir) if err := os.MkdirAll(root, 0o755); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } if err := bundle.WriteExtractedFiles(root, v); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } mainRel := v.FindMainBirdConf() if mainRel == "" { fmt.Fprintln(os.Stderr, "bundle has no bird.conf path in manifest") return 1 } mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/"))) ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() ctl := &birdfmt.BirdCtl{Bird: *bird, Birdc: *birdc, Socket: *socket} if err := ctl.ParseCheck(ctx, mainPath); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } if err := ctl.Configure(ctx); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } fmt.Fprintf(os.Stderr, "applied revision %s (main config %s)\n", v.Manifest.RevisionID, mainPath) return 0 }