From ccb6ed287dae9625c55f10e4b6c38fbd82745fc3 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:12:25 +1000 Subject: [PATCH] fix: move bootstrap.json to different folder to tighten perms (#118) --- build/systemd/spinifex-awsgw.service | 2 +- cmd/spinifex/cmd/admin.go | 32 +++++++++++++++++----------- spinifex/admin/admin.go | 19 ++++++----------- spinifex/services/awsgw/awsgw.go | 26 +++++++++++++++++----- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/build/systemd/spinifex-awsgw.service b/build/systemd/spinifex-awsgw.service index 497d55964..6f622df1a 100644 --- a/build/systemd/spinifex-awsgw.service +++ b/build/systemd/spinifex-awsgw.service @@ -23,7 +23,7 @@ NoNewPrivileges=yes ProtectSystem=strict PrivateDevices=yes ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key -ReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex /etc/spinifex/bootstrap.json /tmp +ReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex /tmp # Hardening ProtectHome=yes diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index f6b8da5dc..862eda52a 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -785,14 +785,15 @@ func runAdminInit(cmd *cobra.Command, args []string) { fmt.Fprintf(os.Stderr, "Error generating IAM master key: %v\n", err) os.Exit(1) } - bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID) + bootstrapDir := filepath.Join(spxRoot, "awsgw") + bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID) if err != nil { fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err) os.Exit(1) } fmt.Println("\nšŸ” Generated IAM master key") fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key")) - fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json")) + fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json")) fmt.Printf("\nšŸ”‘ Generated admin credentials (save these — they won't be shown again):\n") fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey) @@ -1048,13 +1049,14 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, fmt.Fprintf(os.Stderr, "āŒ Error generating IAM master key: %v\n", err) os.Exit(1) } - bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID) + bootstrapDir := filepath.Join(spxRoot, "awsgw") + bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID) if err != nil { fmt.Fprintf(os.Stderr, "āŒ Error writing bootstrap files: %v\n", err) os.Exit(1) } fmt.Println("\nšŸ” Generated IAM master key") - fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json")) + fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json")) // Read CA cert/key for distribution to joining nodes caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem")) @@ -1495,12 +1497,13 @@ func runAdminJoin(cmd *cobra.Command, args []string) { fmt.Fprintf(os.Stderr, "āŒ Error decoding master key: %v\n", err) os.Exit(1) } - if err := writeBootstrapFilesWithAdmin(configDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID, creds.AdminAccessKey, creds.AdminSecretKey); err != nil { + bootstrapDir := filepath.Join(dataDir, "awsgw") + if err := writeBootstrapFilesWithAdmin(configDir, bootstrapDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID, creds.AdminAccessKey, creds.AdminSecretKey); err != nil { fmt.Fprintf(os.Stderr, "āŒ Error writing bootstrap files: %v\n", err) os.Exit(1) } fmt.Println("āœ… IAM master key received from leader") - fmt.Printf("āœ… Bootstrap file written: %s\n", filepath.Join(configDir, "bootstrap.json")) + fmt.Printf("āœ… Bootstrap file written: %s\n", filepath.Join(bootstrapDir, "bootstrap.json")) // Generate server cert signed by CA with this node's bind IP if err := admin.GenerateServerCertOnly(configDir, bindIP); err != nil { @@ -1890,8 +1893,9 @@ type writeBootstrapResult struct { } // writeBootstrapFiles generates new admin credentials and writes the bootstrap -// files (master.key + bootstrap.json). Used by init flows (single and multi-node). -func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) { +// files (master.key to configDir, bootstrap.json to bootstrapDir). +// Used by init flows (single and multi-node). +func writeBootstrapFiles(configDir, bootstrapDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) { adminAccessKey, err := admin.GenerateAWSAccessKey() if err != nil { return nil, fmt.Errorf("generate admin access key: %w", err) @@ -1900,7 +1904,7 @@ func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKe if err != nil { return nil, fmt.Errorf("generate admin secret key: %w", err) } - if err := writeBootstrapFilesWithAdmin(configDir, masterKey, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey); err != nil { + if err := writeBootstrapFilesWithAdmin(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey); err != nil { return nil, err } return &writeBootstrapResult{ @@ -1910,8 +1914,12 @@ func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKe } // writeBootstrapFilesWithAdmin writes the bootstrap files using the provided -// admin credentials. Used by join flows where admin creds come from the leader. -func writeBootstrapFilesWithAdmin(configDir string, masterKey []byte, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string) error { +// admin credentials. master.key goes to configDir, bootstrap.json goes to +// bootstrapDir (the awsgw data directory) so it stays outside /etc/spinifex. +func writeBootstrapFilesWithAdmin(configDir, bootstrapDir string, masterKey []byte, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string) error { + if err := os.MkdirAll(bootstrapDir, 0700); err != nil { + return fmt.Errorf("create bootstrap directory %s: %w", bootstrapDir, err) + } if err := handlers_iam.SaveMasterKey(filepath.Join(configDir, "master.key"), masterKey); err != nil { return fmt.Errorf("saving master key: %w", err) } @@ -1939,7 +1947,7 @@ func writeBootstrapFilesWithAdmin(configDir string, masterKey []byte, accessKey, }, } - return handlers_iam.SaveBootstrapData(filepath.Join(configDir, "bootstrap.json"), bd) + return handlers_iam.SaveBootstrapData(filepath.Join(bootstrapDir, "bootstrap.json"), bd) } // detectedWanBridge returns the OVS bridge name that OVN bridge-mappings should diff --git a/spinifex/admin/admin.go b/spinifex/admin/admin.go index 243735a9a..76fe7086c 100644 --- a/spinifex/admin/admin.go +++ b/spinifex/admin/admin.go @@ -338,20 +338,15 @@ func SetServiceOwnership() { } } - // /etc/spinifex needs group-write so awsgw (spinifex-gw) can delete - // bootstrap.json after consuming it on first boot. - if err := os.Chmod("/etc/spinifex", 0770); err != nil { //nolint:gosec // directory needs group-write for bootstrap.json deletion - slog.Warn("SetServiceOwnership: chmod /etc/spinifex failed", "err", err) - } - // Shared config files — root:spinifex, ca.key stays root:root 0600 + // bootstrap.json lives in the awsgw data dir (not /etc/spinifex), + // so /etc/spinifex stays at 0750 (no group-write needed). for path, mode := range map[string]os.FileMode{ - "/etc/spinifex/spinifex.toml": 0640, - "/etc/spinifex/master.key": 0640, - "/etc/spinifex/bootstrap.json": 0660, // group-write so awsgw can consume and truncate - "/etc/spinifex/server.pem": 0644, - "/etc/spinifex/server.key": 0640, - "/etc/spinifex/ca.pem": 0644, + "/etc/spinifex/spinifex.toml": 0640, + "/etc/spinifex/master.key": 0640, + "/etc/spinifex/server.pem": 0644, + "/etc/spinifex/server.key": 0640, + "/etc/spinifex/ca.pem": 0644, } { if _, err := os.Stat(path); err != nil { continue diff --git a/spinifex/services/awsgw/awsgw.go b/spinifex/services/awsgw/awsgw.go index c81894247..09eabbce8 100644 --- a/spinifex/services/awsgw/awsgw.go +++ b/spinifex/services/awsgw/awsgw.go @@ -108,11 +108,9 @@ func launchService(config *config.ClusterConfig) error { } // First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file. - // Resolve symlinks so the path matches systemd ReadWritePaths for deletion. - bootstrapPath := filepath.Join(nodeConfig.BaseDir, "config", "bootstrap.json") - if resolved, err := filepath.EvalSymlinks(bootstrapPath); err == nil { - bootstrapPath = resolved - } + // Check data directory first (production: /var/lib/spinifex/awsgw/), then + // awsgw subdir (dev: ~/spinifex/awsgw/), then legacy config dir. + bootstrapPath := findBootstrapFile(nodeConfig.BaseDir) data, err := handlers_iam.LoadBootstrapData(bootstrapPath) switch { case err == nil: @@ -228,3 +226,21 @@ func initIAMService(natsConn *nats.Conn, masterKey []byte, clusterSize int) (*ha retryDelay = min(retryDelay*2, 10*time.Second) } } + +// findBootstrapFile returns the path to bootstrap.json, checking the data +// directory first (production), then the awsgw subdir (dev mode), then the +// legacy config dir. Returns the first path that exists, or the primary +// location if none exist (so the caller gets a clean "not found" error). +func findBootstrapFile(baseDir string) string { + candidates := []string{ + filepath.Join(baseDir, "bootstrap.json"), + filepath.Join(baseDir, "awsgw", "bootstrap.json"), + filepath.Join(baseDir, "config", "bootstrap.json"), + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p + } + } + return candidates[0] +}