From 45d54794af7755e56bf6700c4e358f5e089c1fec Mon Sep 17 00:00:00 2001 From: eding Date: Wed, 29 Jul 2026 20:48:56 +0200 Subject: [PATCH] feat: create Forgejo registry token --- cmd/bootstrap.go | 49 +++++++++++++++ cmd/bootstrap_test.go | 16 +++++ internal/bootstrap/bootstrap.go | 84 ++++++++++++++++++++++++-- internal/bootstrap/bootstrap_test.go | 90 ++++++++++++++++++++++++++++ internal/forgejo/repo.go | 50 ++++++++++++++++ internal/forgejo/repo_test.go | 32 ++++++++++ internal/ui/prompts.go | 34 +++++++++++ 7 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 cmd/bootstrap_test.go diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 5e14ef4..f412708 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -18,6 +18,8 @@ var bootstrapYes bool var bootstrapPromptDemocraticCSI bool var bootstrapPromptOperationalSecrets bool var bootstrapInitializeOpenBaoRecovery bool +var bootstrapInitializeOpenBao bool +var bootstrapCreateForgejoRegistryToken bool var bootstrapPublishAppFrom string var bootstrapMergeBootstrapPR bool var bootstrapManageNetworkBridges bool @@ -37,6 +39,8 @@ func init() { bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config") bootstrapCmd.Flags().BoolVar(&bootstrapPromptOperationalSecrets, "prompt-operational-secrets", false, "Prompt for and encrypt operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config") + bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config") + bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao") bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR") bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges") @@ -45,6 +49,26 @@ func init() { func runBootstrap(cmd *cobra.Command, args []string) error { var cfg config.Config var err error + if bootstrapCreateForgejoRegistryToken { + if bootstrapConfigPath == "" { + return fmt.Errorf("--create-forgejo-registry-token requires --config") + } + cfg, err = config.Load(bootstrapConfigPath) + if err != nil { + return err + } + return createForgejoRegistryToken(cfg) + } + if bootstrapInitializeOpenBao { + if bootstrapConfigPath == "" { + return fmt.Errorf("--initialize-openbao requires --config") + } + cfg, err = config.Load(bootstrapConfigPath) + if err != nil { + return err + } + return bootstrap.InitializeOpenBao(cfg) + } if bootstrapMergeBootstrapPR { if bootstrapConfigPath == "" { return fmt.Errorf("--merge-bootstrap-pr requires --config") @@ -150,3 +174,28 @@ func runBootstrap(cmd *cobra.Command, args []string) error { runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes} return runner.Run() } + +func createForgejoRegistryToken(cfg config.Config) error { + if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil { + return err + } + password, otp, name, err := ui.PromptForgejoRegistryToken() + if err != nil { + return err + } + token, err := forgejo.CreateRegistryToken(cfg.Git.BaseURL, cfg.Git.Username, password, otp, name) + if err != nil { + return fmt.Errorf("create Forgejo registry token: %w", err) + } + dockerConfig, err := bootstrap.ForgejoRegistryDockerConfig(cfg.Delivery.ImageRepository, cfg.Git.Username, token) + if err != nil { + return err + } + if err := bootstrap.UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-registry", "dockerconfigjson", dockerConfig); err != nil { + return fmt.Errorf("Forgejo registry token was created but could not be saved; revoke the new token in Forgejo and retry: %w", err) + } + if err := bootstrap.InitializeOpenBao(cfg); err != nil { + return fmt.Errorf("Forgejo registry token was saved to encrypted operational secrets but OpenBao seeding failed; rerun bootstrap --config %q --initialize-openbao: %w", bootstrapConfigPath, err) + } + return nil +} diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go new file mode 100644 index 0000000..ef50bf3 --- /dev/null +++ b/cmd/bootstrap_test.go @@ -0,0 +1,16 @@ +package cmd + +import "testing" + +func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) { + originalConfigPath, originalCreate := bootstrapConfigPath, bootstrapCreateForgejoRegistryToken + defer func() { + bootstrapConfigPath = originalConfigPath + bootstrapCreateForgejoRegistryToken = originalCreate + }() + bootstrapConfigPath = "" + bootstrapCreateForgejoRegistryToken = true + if err := runBootstrap(nil, nil); err == nil { + t.Fatal("--create-forgejo-registry-token accepted a missing --config") + } +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 1b126ac..f7f1be7 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -36,6 +37,12 @@ type Runner struct { ConfirmRebuild bool } +type operationalSecrets struct { + Secrets map[string]map[string]string `yaml:"secrets"` +} + +var initializeOpenBao = openbao.Initialize + func (r Runner) Run() error { resolved, err := config.Resolve(r.Config) if err != nil { @@ -259,13 +266,74 @@ func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKe } func WriteOperationalSecrets(path, ageKeyPath string, secrets map[string]map[string]string) error { - plaintext, err := yaml.Marshal(struct { - Secrets map[string]map[string]string `yaml:"secrets"` - }{Secrets: secrets}) + plaintext, err := yaml.Marshal(operationalSecrets{Secrets: secrets}) if err != nil { return err } - return writeSOPSEncryptedFile(path, ageKeyPath, plaintext) + return writeSOPSEncrypted(path, ageKeyPath, plaintext, "^(secrets)$") +} + +func UpsertOperationalSecret(path, ageKeyPath, secretPath, key, value string) error { + secrets, err := ReadOperationalSecrets(path, ageKeyPath) + if err != nil { + return err + } + values := secrets[secretPath] + if values == nil { + values = map[string]string{} + secrets[secretPath] = values + } + values[key] = value + return WriteOperationalSecrets(path, ageKeyPath, secrets) +} + +func ReadOperationalSecrets(path, ageKeyPath string) (map[string]map[string]string, error) { + plaintext, err := decryptSOPSFile(path, ageKeyPath) + if err != nil { + return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err) + } + decoder := yaml.NewDecoder(bytes.NewReader(plaintext)) + decoder.KnownFields(true) + var document operationalSecrets + if err := decoder.Decode(&document); err != nil { + return nil, fmt.Errorf("parse operational SOPS secrets: %w", err) + } + if err := decoder.Decode(&operationalSecrets{}); !errors.Is(err, io.EOF) { + return nil, errors.New("operational SOPS secrets must contain one YAML document") + } + if document.Secrets == nil { + return nil, errors.New("operational SOPS secrets requires a secrets mapping") + } + for secretPath, values := range document.Secrets { + if values == nil { + return nil, fmt.Errorf("operational SOPS secret %q must be a mapping", secretPath) + } + } + return document.Secrets, nil +} + +func ForgejoRegistryDockerConfig(imageRepository, username, token string) (string, error) { + registryHost := strings.Split(strings.TrimSpace(imageRepository), "/")[0] + if registryHost == "" || username == "" || token == "" { + return "", errors.New("delivery registry host, Forgejo username, and token are required") + } + config, err := json.Marshal(map[string]map[string]map[string]string{ + "auths": { + registryHost: {"auth": base64.StdEncoding.EncodeToString([]byte(username + ":" + token))}, + }, + }) + if err != nil { + return "", err + } + return string(config), nil +} + +func InitializeOpenBao(cfg config.Config) error { + kubeconfig := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig") + if _, err := initializeOpenBao(kubeconfig, cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath); err != nil { + return fmt.Errorf("initialize OpenBao: %w", err) + } + return nil } func NewWebhookAuthorization() (string, error) { @@ -277,6 +345,10 @@ func NewWebhookAuthorization() (string, error) { } func writeSOPSEncryptedFile(path, ageKeyPath string, plaintext []byte) error { + return writeSOPSEncrypted(path, ageKeyPath, plaintext, "^(data|stringData)$") +} + +func writeSOPSEncrypted(path, ageKeyPath string, plaintext []byte, encryptedRegex string) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } @@ -284,11 +356,11 @@ func writeSOPSEncryptedFile(path, ageKeyPath string, plaintext []byte) error { if err != nil { return fmt.Errorf("derive SOPS age recipient: %w", err) } - command := exec.Command("sops", "encrypt", "--age", strings.TrimSpace(string(recipient)), "--encrypted-regex", "^(data|stringData)$", "--input-type", "yaml", "--output-type", "yaml", "--filename-override", filepath.Base(path)) + command := exec.Command("sops", "encrypt", "--age", strings.TrimSpace(string(recipient)), "--encrypted-regex", encryptedRegex, "--input-type", "yaml", "--output-type", "yaml", "--filename-override", filepath.Base(path)) command.Stdin = bytes.NewReader(plaintext) encrypted, err := command.Output() if err != nil { - return fmt.Errorf("encrypt Democratic CSI secret: %w", err) + return fmt.Errorf("encrypt SOPS file: %w", err) } return os.WriteFile(path, encrypted, 0600) } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 67ef04f..0c12af3 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -1,6 +1,9 @@ package bootstrap import ( + "encoding/base64" + "encoding/json" + "errors" "os" "os/exec" "path/filepath" @@ -104,6 +107,93 @@ func TestNewWebhookAuthorization(t *testing.T) { } } +func TestForgejoRegistryDockerConfig(t *testing.T) { + dockerConfig, err := ForgejoRegistryDockerConfig("registry.example.test/team/app", "registry-user", "registry-token") + if err != nil { + t.Fatal(err) + } + var document struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + if err := json.Unmarshal([]byte(dockerConfig), &document); err != nil { + t.Fatal(err) + } + if document.Auths["registry.example.test"].Auth != base64.StdEncoding.EncodeToString([]byte("registry-user:registry-token")) { + t.Fatal("Forgejo registry Docker config has the wrong credentials") + } +} + +func TestUpsertOperationalSecretPreservesExistingSecrets(t *testing.T) { + if _, err := exec.LookPath("age-keygen"); err != nil { + t.Skip("age-keygen is required for bootstrap encryption") + } + if _, err := exec.LookPath("sops"); err != nil { + t.Skip("sops is required for bootstrap encryption") + } + dir := t.TempDir() + identity := filepath.Join(dir, "age-key.txt") + if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "operational-secrets.sops.yaml") + if err := WriteOperationalSecrets(path, identity, map[string]map[string]string{ + "cicd/forgejo": {"token": "existing-token"}, + "platform/cloudflare": {"api-token": "existing-api-token"}, + "cicd/forgejo-registry": {"dockerconfigjson": "old-config"}, + }); err != nil { + t.Fatal(err) + } + if err := UpsertOperationalSecret(path, identity, "cicd/forgejo-registry", "dockerconfigjson", "new-config"); err != nil { + t.Fatal(err) + } + encrypted, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encrypted), "existing-token") || strings.Contains(string(encrypted), "new-config") || !strings.Contains(string(encrypted), "sops:") { + t.Fatal("operational secret upsert was not SOPS encrypted") + } + secrets, err := ReadOperationalSecrets(path, identity) + if err != nil { + t.Fatal(err) + } + if secrets["cicd/forgejo"]["token"] != "existing-token" || secrets["platform/cloudflare"]["api-token"] != "existing-api-token" || secrets["cicd/forgejo-registry"]["dockerconfigjson"] != "new-config" { + t.Fatal("operational secret upsert did not preserve unrelated secrets") + } +} + +func TestInitializeOpenBaoUsesGeneratedKubeconfig(t *testing.T) { + original := initializeOpenBao + defer func() { initializeOpenBao = original }() + var kubeconfig string + initializeOpenBao = func(path, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) { + kubeconfig = path + if recipient != "recipient" || identityPath != "identity" || bundlePath != "bundle" || ageKeyPath != "age" || operationalSecretsPath != "secrets" { + t.Fatal("InitializeOpenBao passed incorrect configured paths") + } + return nil, nil + } + cfg := config.Config{ + Git: config.GitConfig{CloneParent: "checkout"}, + Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"}, + SOPS: config.SOPSConfig{RecoveryRecipient: "recipient", RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, + } + if err := InitializeOpenBao(cfg); err != nil { + t.Fatal(err) + } + if kubeconfig != filepath.Join("checkout", "talos", "generated", "kubeconfig") { + t.Fatal("InitializeOpenBao did not use the configured generated kubeconfig") + } + initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) { + return nil, errors.New("unavailable") + } + if err := InitializeOpenBao(cfg); err == nil { + t.Fatal("InitializeOpenBao accepted a seeding error") + } +} + func TestCopyDirSkipsGitDirectory(t *testing.T) { source := t.TempDir() destination := t.TempDir() diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index a90eb48..6ef2242 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -67,6 +67,15 @@ type APIError struct { Status string } +type createTokenRequest struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` +} + +type accessToken struct { + SHA1 string `json:"sha1"` +} + func (e *APIError) Error() string { return fmt.Sprintf("forgejo returned %s", e.Status) } @@ -85,6 +94,47 @@ func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, br } } +// CreateRegistryToken creates the package-registry token that Forgejo returns once. +func CreateRegistryToken(baseURL, username, password, otp, name string) (string, error) { + return createRegistryToken(&http.Client{Timeout: 15 * time.Second}, baseURL, username, password, otp, name) +} + +func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) { + if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" { + return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required") + } + body, err := json.Marshal(createTokenRequest{Name: name, Scopes: []string{"read:package", "write:package"}}) + if err != nil { + return "", err + } + endpoint := fmt.Sprintf("%s/api/v1/users/%s/tokens", strings.TrimRight(baseURL, "/"), url.PathEscape(username)) + request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return "", err + } + request.SetBasicAuth(username, password) + request.Header.Set("Content-Type", "application/json") + if otp != "" { + request.Header.Set("X-Forgejo-OTP", otp) + } + response, err := client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode != http.StatusCreated { + return "", &APIError{StatusCode: response.StatusCode, Status: response.Status} + } + var token accessToken + if err := json.NewDecoder(response.Body).Decode(&token); err != nil { + return "", fmt.Errorf("parse Forgejo registry token response: %w", err) + } + if token.SHA1 == "" { + return "", fmt.Errorf("Forgejo did not return a registry token") + } + return token.SHA1, nil +} + func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux func(string) error) error { if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil { return err diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index 24626af..b8ab937 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -4,9 +4,41 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "testing" ) +func TestCreateRegistryTokenUsesBasicAuthAndPackageScopes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/api/v1/users/registry-user/tokens" { + t.Fatal("unexpected Forgejo token request") + } + username, password, ok := request.BasicAuth() + if !ok || username != "registry-user" || password != "password" { + t.Fatal("Forgejo token request did not use the supplied basic authentication") + } + if request.Header.Get("X-Forgejo-OTP") != "123456" || request.Header.Get("Content-Type") != "application/json" { + t.Fatal("Forgejo token request headers are incorrect") + } + var body createTokenRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Name != "maidn-registry" || !reflect.DeepEqual(body.Scopes, []string{"read:package", "write:package"}) { + t.Fatal("Forgejo token request did not use the least-privilege package scopes") + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte(`{"sha1":"registry-token"}`)) + })) + defer server.Close() + + token, err := createRegistryToken(server.Client(), server.URL, "registry-user", "password", "123456", "maidn-registry") + if err != nil || token != "registry-token" { + t.Fatal("CreateRegistryToken() did not return the Forgejo token") + } +} + func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusUnauthorized) diff --git a/internal/ui/prompts.go b/internal/ui/prompts.go index 18f995d..d36a711 100644 --- a/internal/ui/prompts.go +++ b/internal/ui/prompts.go @@ -85,6 +85,19 @@ func GetUserInputForGithubAuth() (username, token string, err error) { return } +// PromptForgejoRegistryToken collects credentials used only to create a registry token. +func PromptForgejoRegistryToken() (password, otp, name string, err error) { + password, err = promptHiddenRequired("Forgejo account password") + if err != nil { + return "", "", "", err + } + if otp, err = promptHiddenOptional("Forgejo OTP (optional)"); err != nil { + return "", "", "", err + } + name = prompt(bufio.NewReader(os.Stdin), "Forgejo registry token name", "maidn-registry") + return password, otp, name, nil +} + func promptForToken(reader *bufio.Reader, prompt string) (string, error) { token := promptSecret(reader, prompt, "") if token == "" { @@ -129,3 +142,24 @@ func promptSecret(reader *bufio.Reader, prompt, fallback string) string { } return secret } + +func promptHiddenRequired(prompt string) (string, error) { + value, err := promptHiddenOptional(prompt) + if err != nil { + return "", err + } + if value == "" { + return "", fmt.Errorf("%s cannot be empty", strings.ToLower(prompt)) + } + return value, nil +} + +func promptHiddenOptional(prompt string) (string, error) { + fmt.Printf("%s: ", prompt) + value, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", err + } + return strings.TrimSpace(string(value)), nil +}