feat: create Forgejo registry token

This commit is contained in:
eding 2026-07-29 20:48:56 +02:00
parent ecd2fece59
commit 45d54794af
7 changed files with 349 additions and 6 deletions

View file

@ -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
}

16
cmd/bootstrap_test.go Normal file
View file

@ -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")
}
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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
}