feat: serve local terraform repos as a provider registry (#102)
ci/woodpecker/tag/docker Pipeline was successful
ci/woodpecker/tag/docker Pipeline was successful
## Why
Local terraform repos already served the Terraform **network mirror** protocol, but consuming that requires every user to add a `provider_installation { network_mirror }` block to `~/.terraformrc`. A `source = "artifactapi.k8s.../ns/type"` address instead triggers the **provider registry** protocol (service discovery at `/.well-known/terraform.json` + GPG-signed SHA256SUMS), which returned 404 — hence *"does not offer a provider registry."*
Local repos are meant to be the real thing, so this makes a terraform local repo a first-class provider registry: `terraform init` installs from a bare source address with no client config.
## What
- Serve `/.well-known/terraform.json` service discovery and the `providers.v1` endpoints under `/terraform/v1/providers`: `versions`, `download/{os}/{arch}`, `sha256sums`, `sha256sums.sig`.
- Map the Terraform **namespace** segment to the artifactapi **repo name**; locate the provider by **type**. `download_url` points back at the existing `/api/v1/local/...` path.
- Generate `SHA256SUMS` per version and sign it with a GPG key loaded from `TF_SIGNING_KEY_PATH` (optional `TF_SIGNING_KEY_PASSPHRASE`); advertise the public key + key id in the download response. **No key → registry stays disabled (endpoints 404)**, so behaviour is unchanged until the signing secret is present.
- New `internal/tfsign` (key load + detached signing, via `x/crypto/openpgp`) and `internal/api/terraform` (registry handler). Export `ParseProviderZip` for reuse.
- `TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols.
- README section documenting usage.
## Consumer
```hcl
terraform {
required_providers {
artifactapi = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/artifactapi"
version = "0.1.2"
}
}
}
```
## Tests
- `internal/tfsign`: sign + verify round-trip, disabled/missing-key paths.
- `internal/api/terraform`: dockerised full flow (discovery → versions → download → sha256sums → sig), verifying the signature against the advertised public key.
## Follow-ups (separate PRs)
- **argocd-apps**: mount the signing K8s secret into the api deployment + set `TF_SIGNING_KEY_PATH`. The `/` HTTPRoute already routes `/.well-known` and `/terraform` to the API, so no gateway change is needed.
- Image/version bump once tagged.
## Note
Anchored the `terraform/` gitignore to the repo root (`/terraform/`) so it stops matching `internal/*/terraform/`. This surfaced `internal/provider/terraform/terraform_extra_test.go`, which had been silently untracked — now committed.
Reviewed-on: #102
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
This commit was merged in pull request #102.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
// Package tfsign loads a GPG signing key and produces the detached signatures
|
||||
// the Terraform provider registry protocol requires over SHA256SUMS files.
|
||||
package tfsign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"golang.org/x/crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
// KeyStore persists a generated signing key. *database.DB satisfies it.
|
||||
type KeyStore interface {
|
||||
GetSigningKey(ctx context.Context, purpose string) (armor, keyID string, found bool, err error)
|
||||
InsertSigningKeyIfAbsent(ctx context.Context, purpose, armor, keyID string) error
|
||||
}
|
||||
|
||||
// LoadOrCreate returns a signer for purpose, generating and persisting a new key
|
||||
// the first time it is needed. It is safe across replicas: a lost insert race
|
||||
// just re-reads whichever key won.
|
||||
func LoadOrCreate(ctx context.Context, store KeyStore, purpose string) (*Signer, error) {
|
||||
armored, _, found, err := store.GetSigningKey(ctx, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
newArmor, keyID, err := Generate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.InsertSigningKeyIfAbsent(ctx, purpose, newArmor, keyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if armored, _, _, err = store.GetSigningKey(ctx, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return LoadArmored(armored, "")
|
||||
}
|
||||
|
||||
// Signer holds a decrypted GPG entity and exposes what the registry download
|
||||
// response needs: a detached signature, the armored public key, and the key ID.
|
||||
type Signer struct {
|
||||
entity *openpgp.Entity
|
||||
publicASCII string
|
||||
keyID string
|
||||
}
|
||||
|
||||
// Load reads an armored private key from path, decrypting it with passphrase if
|
||||
// the key is protected. A blank path returns (nil, nil): a nil *Signer means the
|
||||
// caller should fall back to another source (e.g. a DB-stored key).
|
||||
func Load(path, passphrase string) (*Signer, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open signing key: %w", err)
|
||||
}
|
||||
return fromArmor(string(data), passphrase, path)
|
||||
}
|
||||
|
||||
// LoadArmored builds a signer from an in-memory armored private key, e.g. one
|
||||
// read from the database. A blank key returns (nil, nil).
|
||||
func LoadArmored(armored, passphrase string) (*Signer, error) {
|
||||
if armored == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return fromArmor(armored, passphrase, "stored key")
|
||||
}
|
||||
|
||||
// Generate creates a fresh signing keypair and returns the armored private key
|
||||
// (to persist) and its uppercase key id.
|
||||
func Generate() (armoredPrivateKey, keyID string, err error) {
|
||||
entity, err := openpgp.NewEntity("artifactapi terraform registry", "provider signing", "artifactapi@localhost", nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := entity.SerializePrivate(w, nil); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return buf.String(), strings.ToUpper(entity.PrimaryKey.KeyIdString()), nil
|
||||
}
|
||||
|
||||
func fromArmor(armored, passphrase, src string) (*Signer, error) {
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(armored))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signing key: %w", err)
|
||||
}
|
||||
if len(keyring) == 0 {
|
||||
return nil, fmt.Errorf("signing key (%s) contains no entities", src)
|
||||
}
|
||||
entity := keyring[0]
|
||||
|
||||
if entity.PrivateKey == nil {
|
||||
return nil, fmt.Errorf("signing key (%s) has no private key material", src)
|
||||
}
|
||||
if entity.PrivateKey.Encrypted {
|
||||
if err := decrypt(entity, passphrase); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
pub, err := armorPublicKey(entity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Signer{
|
||||
entity: entity,
|
||||
publicASCII: pub,
|
||||
keyID: entity.PrimaryKey.KeyIdString(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decrypt unlocks the entity's private key and all subkeys with the passphrase.
|
||||
func decrypt(entity *openpgp.Entity, passphrase string) error {
|
||||
pw := []byte(passphrase)
|
||||
if err := entity.PrivateKey.Decrypt(pw); err != nil {
|
||||
return fmt.Errorf("decrypt signing key: %w", err)
|
||||
}
|
||||
for _, sub := range entity.Subkeys {
|
||||
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
|
||||
_ = sub.PrivateKey.Decrypt(pw)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func armorPublicKey(entity *openpgp.Entity) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := entity.Serialize(w); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// Sign returns a binary detached signature over message, matching the
|
||||
// SHA256SUMS.sig format Terraform verifies.
|
||||
func (s *Signer) Sign(message []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := openpgp.DetachSign(&buf, s.entity, bytes.NewReader(message), nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// PublicKeyArmor returns the ASCII-armored public key for the registry's
|
||||
// signing_keys response.
|
||||
func (s *Signer) PublicKeyArmor() string { return s.publicASCII }
|
||||
|
||||
// KeyID returns the 16-hex-char uppercase key ID Terraform matches against the
|
||||
// signature's issuer.
|
||||
func (s *Signer) KeyID() string { return strings.ToUpper(s.keyID) }
|
||||
@@ -0,0 +1,155 @@
|
||||
package tfsign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"golang.org/x/crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
// armoredPrivateKey generates a throwaway armored private key for tests.
|
||||
func armoredPrivateKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
e, err := openpgp.NewEntity("artifactapi test", "tf registry", "tf@example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.SerializePrivate(w, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func writeKey(t *testing.T, contents string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "private-key.asc")
|
||||
if err := os.WriteFile(p, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestLoadSignAndVerify(t *testing.T) {
|
||||
path := writeKey(t, armoredPrivateKey(t))
|
||||
s, err := Load(path, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("expected a signer")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`^[0-9A-F]{16}$`).MatchString(s.KeyID()) {
|
||||
t.Errorf("key id %q is not 16 uppercase hex chars", s.KeyID())
|
||||
}
|
||||
|
||||
msg := []byte("deadbeef terraform-provider-x_1.0.0_linux_amd64.zip\n")
|
||||
sig, err := s.Sign(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The advertised public key must verify the signature over the same bytes.
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.PublicKeyArmor())))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(msg), bytes.NewReader(sig)); err != nil {
|
||||
t.Errorf("signature did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAndLoadArmored(t *testing.T) {
|
||||
priv, keyID, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9A-F]{16}$`).MatchString(keyID) {
|
||||
t.Errorf("generated key id %q malformed", keyID)
|
||||
}
|
||||
|
||||
s, err := LoadArmored(priv, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.KeyID() != keyID {
|
||||
t.Errorf("loaded key id %q != generated %q", s.KeyID(), keyID)
|
||||
}
|
||||
|
||||
msg := []byte("abc terraform-provider-x_1.0.0_linux_amd64.zip\n")
|
||||
sig, err := s.Sign(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyring, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.PublicKeyArmor())))
|
||||
if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(msg), bytes.NewReader(sig)); err != nil {
|
||||
t.Errorf("signature did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// memStore is an in-memory KeyStore that records how many keys it accepted.
|
||||
type memStore struct {
|
||||
armor, keyID string
|
||||
found bool
|
||||
inserts int
|
||||
}
|
||||
|
||||
func (m *memStore) GetSigningKey(_ context.Context, _ string) (string, string, bool, error) {
|
||||
return m.armor, m.keyID, m.found, nil
|
||||
}
|
||||
|
||||
func (m *memStore) InsertSigningKeyIfAbsent(_ context.Context, _, armor, keyID string) error {
|
||||
if !m.found { // ON CONFLICT DO NOTHING
|
||||
m.armor, m.keyID, m.found = armor, keyID, true
|
||||
m.inserts++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoadOrCreateGeneratesOnceThenReuses(t *testing.T) {
|
||||
store := &memStore{}
|
||||
|
||||
first, err := LoadOrCreate(context.Background(), store, "terraform-provider")
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("first LoadOrCreate: signer=%v err=%v", first, err)
|
||||
}
|
||||
|
||||
second, err := LoadOrCreate(context.Background(), store, "terraform-provider")
|
||||
if err != nil || second == nil {
|
||||
t.Fatalf("second LoadOrCreate: signer=%v err=%v", second, err)
|
||||
}
|
||||
|
||||
if store.inserts != 1 {
|
||||
t.Errorf("expected exactly one key generated, got %d", store.inserts)
|
||||
}
|
||||
if first.KeyID() != second.KeyID() {
|
||||
t.Errorf("key id changed between loads: %q vs %q", first.KeyID(), second.KeyID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptyPathDisabled(t *testing.T) {
|
||||
s, err := Load("", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s != nil {
|
||||
t.Error("empty path should yield a nil (disabled) signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
if _, err := Load(filepath.Join(t.TempDir(), "nope.asc"), ""); err == nil {
|
||||
t.Error("expected an error for a missing key file")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user