refactor: phase 2 structural alignment — reports, importer, nodecli, pagination
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package nodecli
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// CmdPullBundle downloads a signed bundle from the control plane API.
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// CmdVerifyBundle checks bundle signature and manifest.
|
||||
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
|
||||
}
|
||||
|
||||
// CmdApplyBundle verifies, extracts, parse-checks, and configures BIRD from a bundle.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package nodecli implements evobgp-node subcommands (pull/verify/apply bundle).
|
||||
package nodecli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Run executes a subcommand from args (without program name). Returns exit code.
|
||||
func Run(args []string) int {
|
||||
if len(args) < 1 {
|
||||
Usage(os.Stderr)
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "pull-bundle":
|
||||
return CmdPullBundle(args[1:])
|
||||
case "verify-bundle":
|
||||
return CmdVerifyBundle(args[1:])
|
||||
case "apply-bundle":
|
||||
return CmdApplyBundle(args[1:])
|
||||
default:
|
||||
Usage(os.Stderr)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// Usage prints CLI help to w.
|
||||
func Usage(w interface{ Write([]byte) (int, error) }) {
|
||||
fmt.Fprintf(w, `Usage:
|
||||
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
|
||||
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||
evobgp-node 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.
|
||||
`)
|
||||
}
|
||||
Reference in New Issue
Block a user