feat: serve local terraform repos as a provider registry
Local terraform repos already spoke the network mirror protocol, which needs
per-consumer .terraformrc config. This adds the provider registry protocol so
`terraform init` installs from a bare source address
(artifactapi.k8s.../{repo}/{type}) with no client setup.
- serve /.well-known/terraform.json service discovery and the providers.v1
versions/download endpoints under /terraform/v1/providers
- map the Terraform namespace to the artifactapi repo name and locate the
provider by type; download_url points back at the existing local file path
- generate SHA256SUMS per version and sign it with a GPG key loaded from
TF_SIGNING_KEY_PATH; advertise the public key + key id in the download
response. No key configured -> registry stays disabled (endpoints 404)
- new internal/tfsign (key loading + detached signing) and
internal/api/terraform (registry handler); export ParseProviderZip for reuse
- add TF_SIGNING_KEY_PATH/PASSPHRASE and TF_PROVIDER_PROTOCOLS config
- unit test signing + verification; dockerised test of the full flow incl.
signature verification against the advertised key
Also anchor the terraform/ gitignore to the repo root so it stops swallowing
internal/api/terraform and internal/provider/terraform test files (the latter
had gone silently untracked).
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
bin/
|
bin/
|
||||||
terraform/
|
/terraform/
|
||||||
|
|
||||||
# e2e-docker fixtures are real package files (.rpm, .tgz, .whl, .zip, ...) that
|
# e2e-docker fixtures are real package files (.rpm, .tgz, .whl, .zip, ...) that
|
||||||
# are intentionally tracked, overriding any global ignore of those extensions.
|
# are intentionally tracked, overriding any global ignore of those extensions.
|
||||||
|
|||||||
@@ -89,6 +89,34 @@ resource "artifactapi_virtual" "helm" {
|
|||||||
|
|
||||||
Provider: [terraform-provider-artifactapi](../terraform-provider-artifactapi)
|
Provider: [terraform-provider-artifactapi](../terraform-provider-artifactapi)
|
||||||
|
|
||||||
|
### Serving providers as a registry
|
||||||
|
|
||||||
|
A local `terraform` repo is a real provider registry: upload
|
||||||
|
`terraform-provider-{type}_{version}_{os}_{arch}.zip` files under
|
||||||
|
`{namespace}/{type}/`, and Terraform installs them from a bare source address —
|
||||||
|
no `.terraformrc` mirror config:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
artifactapi = {
|
||||||
|
source = "artifactapi.k8s.syd1.au.unkin.net/<repo>/<type>"
|
||||||
|
version = "0.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Terraform *namespace* segment is the artifactapi repo name; the provider is
|
||||||
|
matched by *type*. The registry serves service discovery
|
||||||
|
(`/.well-known/terraform.json`), the `providers.v1` version/download endpoints,
|
||||||
|
and a GPG-signed `SHA256SUMS` per the provider registry protocol.
|
||||||
|
|
||||||
|
Signing requires an armored GPG private key, supplied via `TF_SIGNING_KEY_PATH`
|
||||||
|
(optionally `TF_SIGNING_KEY_PASSPHRASE`). Without it the registry endpoints stay
|
||||||
|
disabled. `TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin
|
||||||
|
protocols.
|
||||||
|
|
||||||
## Access Control
|
## Access Control
|
||||||
|
|
||||||
| Field | Default | Behaviour |
|
| Field | Default | Behaviour |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
github.com/testcontainers/testcontainers-go v0.42.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
|
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
|
||||||
|
golang.org/x/crypto v0.51.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,7 +97,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.51.0 // indirect
|
|
||||||
golang.org/x/net v0.53.0 // indirect
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// Package terraform serves local terraform repos as a real Terraform provider
|
||||||
|
// registry: service discovery, version listing, and GPG-signed downloads, so
|
||||||
|
// `terraform init` installs from a bare source address with no client config.
|
||||||
|
package terraform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
|
tfprov "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/tfsign"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProvidersV1Path is the base the service-discovery document advertises (Terraform
|
||||||
|
// appends "{namespace}/{type}/versions" etc). MountPath is the same prefix without
|
||||||
|
// the trailing slash, for chi.Mount.
|
||||||
|
const (
|
||||||
|
ProvidersV1Path = "/terraform/v1/providers/"
|
||||||
|
MountPath = "/terraform/v1/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *database.DB
|
||||||
|
signer *tfsign.Signer
|
||||||
|
protocols []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *database.DB, signer *tfsign.Signer, protocols string) *Handler {
|
||||||
|
var protos []string
|
||||||
|
for _, p := range strings.Split(protocols, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
protos = append(protos, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(protos) == 0 {
|
||||||
|
protos = []string{"5.0", "6.0"}
|
||||||
|
}
|
||||||
|
return &Handler{db: db, signer: signer, protocols: protos}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether a signing key is configured. Without one the registry
|
||||||
|
// cannot produce the signed SHA256SUMS the protocol requires, so it stays off.
|
||||||
|
func (h *Handler) Enabled() bool { return h.signer != nil }
|
||||||
|
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/{namespace}/{type}/versions", h.versions)
|
||||||
|
r.Get("/{namespace}/{type}/{version}/download/{os}/{arch}", h.download)
|
||||||
|
r.Get("/{namespace}/{type}/{version}/sha256sums", h.sha256sums)
|
||||||
|
r.Get("/{namespace}/{type}/{version}/sha256sums.sig", h.sha256sumsSig)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceDiscovery answers /.well-known/terraform.json, pointing Terraform at the
|
||||||
|
// providers.v1 protocol base.
|
||||||
|
func (h *Handler) ServiceDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.Enabled() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"providers.v1": ProvidersV1Path})
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerFile is one resolved platform artifact within a repo.
|
||||||
|
type providerFile struct {
|
||||||
|
version string
|
||||||
|
os string
|
||||||
|
arch string
|
||||||
|
filePath string // path within the repo, e.g. unkin/artifactapi/...zip
|
||||||
|
sha256 string // hex, no "sha256:" prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve finds every provider zip of the given type in the repo (namespace).
|
||||||
|
// The Terraform source namespace maps to the artifactapi repo name; the provider
|
||||||
|
// is matched by type across whatever in-repo folder it was uploaded under.
|
||||||
|
func (h *Handler) resolve(r *http.Request, namespace, typeName string) ([]providerFile, error) {
|
||||||
|
remote, err := h.db.GetRemote(r.Context(), namespace)
|
||||||
|
if err != nil || remote.PackageType != models.PackageTerraform {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.db.ListLocalFiles(r.Context(), namespace, 10000, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []providerFile
|
||||||
|
for _, row := range rows {
|
||||||
|
parsed := tfprov.ParseProviderZip(path.Base(row.FilePath))
|
||||||
|
if !parsed.Ok || parsed.Type != typeName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, providerFile{
|
||||||
|
version: parsed.Version,
|
||||||
|
os: parsed.OS,
|
||||||
|
arch: parsed.Arch,
|
||||||
|
filePath: row.FilePath,
|
||||||
|
sha256: strings.TrimPrefix(row.ContentHash, "sha256:"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) versions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.Enabled() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namespace := chi.URLParam(r, "namespace")
|
||||||
|
typeName := chi.URLParam(r, "type")
|
||||||
|
|
||||||
|
files, err := h.resolve(r, namespace, typeName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group platforms by version, de-duplicated and stably ordered.
|
||||||
|
type platform struct {
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
}
|
||||||
|
platforms := map[string]map[string]platform{}
|
||||||
|
for _, f := range files {
|
||||||
|
if platforms[f.version] == nil {
|
||||||
|
platforms[f.version] = map[string]platform{}
|
||||||
|
}
|
||||||
|
platforms[f.version][f.os+"_"+f.arch] = platform{OS: f.os, Arch: f.arch}
|
||||||
|
}
|
||||||
|
|
||||||
|
type versionEntry struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Protocols []string `json:"protocols"`
|
||||||
|
Platforms []platform `json:"platforms"`
|
||||||
|
}
|
||||||
|
out := struct {
|
||||||
|
Versions []versionEntry `json:"versions"`
|
||||||
|
}{}
|
||||||
|
for version, plats := range platforms {
|
||||||
|
entry := versionEntry{Version: version, Protocols: h.protocols}
|
||||||
|
for _, p := range plats {
|
||||||
|
entry.Platforms = append(entry.Platforms, p)
|
||||||
|
}
|
||||||
|
sort.Slice(entry.Platforms, func(i, j int) bool {
|
||||||
|
return entry.Platforms[i].OS+entry.Platforms[i].Arch < entry.Platforms[j].OS+entry.Platforms[j].Arch
|
||||||
|
})
|
||||||
|
out.Versions = append(out.Versions, entry)
|
||||||
|
}
|
||||||
|
sort.Slice(out.Versions, func(i, j int) bool { return out.Versions[i].Version < out.Versions[j].Version })
|
||||||
|
|
||||||
|
writeJSON(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) download(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.Enabled() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namespace := chi.URLParam(r, "namespace")
|
||||||
|
typeName := chi.URLParam(r, "type")
|
||||||
|
version := chi.URLParam(r, "version")
|
||||||
|
osName := chi.URLParam(r, "os")
|
||||||
|
arch := chi.URLParam(r, "arch")
|
||||||
|
|
||||||
|
files, err := h.resolve(r, namespace, typeName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var match *providerFile
|
||||||
|
for i := range files {
|
||||||
|
if files[i].version == version && files[i].os == osName && files[i].arch == arch {
|
||||||
|
match = &files[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := baseURL(r)
|
||||||
|
verBase := fmt.Sprintf("%s%s/%s/%s", base+ProvidersV1Path, namespace, typeName, version)
|
||||||
|
|
||||||
|
type gpgKey struct {
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
ASCIIArmor string `json:"ascii_armor"`
|
||||||
|
}
|
||||||
|
resp := struct {
|
||||||
|
Protocols []string `json:"protocols"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
SHASumsURL string `json:"shasums_url"`
|
||||||
|
SHASumsSignatureURL string `json:"shasums_signature_url"`
|
||||||
|
SHASum string `json:"shasum"`
|
||||||
|
SigningKeys struct {
|
||||||
|
GPGPublicKeys []gpgKey `json:"gpg_public_keys"`
|
||||||
|
} `json:"signing_keys"`
|
||||||
|
}{
|
||||||
|
Protocols: h.protocols,
|
||||||
|
OS: match.os,
|
||||||
|
Arch: match.arch,
|
||||||
|
Filename: path.Base(match.filePath),
|
||||||
|
DownloadURL: fmt.Sprintf("%s/api/v1/local/%s/%s", base, namespace, match.filePath),
|
||||||
|
SHASumsURL: verBase + "/sha256sums",
|
||||||
|
SHASumsSignatureURL: verBase + "/sha256sums.sig",
|
||||||
|
SHASum: match.sha256,
|
||||||
|
}
|
||||||
|
resp.SigningKeys.GPGPublicKeys = []gpgKey{{
|
||||||
|
KeyID: h.signer.KeyID(),
|
||||||
|
ASCIIArmor: h.signer.PublicKeyArmor(),
|
||||||
|
}}
|
||||||
|
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sha256sums(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sums, ok := h.buildSums(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Write(sums)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sha256sumsSig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sums, ok := h.buildSums(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sig, err := h.signer.Sign(sums)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Write(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSums renders the SHA256SUMS body for one version: one "<hex> <filename>"
|
||||||
|
// line per platform zip, sorted by filename so the signed bytes are stable.
|
||||||
|
func (h *Handler) buildSums(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
||||||
|
if !h.Enabled() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
namespace := chi.URLParam(r, "namespace")
|
||||||
|
typeName := chi.URLParam(r, "type")
|
||||||
|
version := chi.URLParam(r, "version")
|
||||||
|
|
||||||
|
files, err := h.resolve(r, namespace, typeName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for _, f := range files {
|
||||||
|
if f.version != version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%s %s", f.sha256, path.Base(f.filePath)))
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
return []byte(strings.Join(lines, "\n") + "\n"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseURL(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
||||||
|
scheme = fwd
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package terraform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"golang.org/x/crypto/openpgp"
|
||||||
|
"golang.org/x/crypto/openpgp/armor"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/tfsign"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testDSN string
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dsn, terminate, err := testsupport.StartPostgres(ctx)
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
testDSN = dsn
|
||||||
|
code := m.Run()
|
||||||
|
terminate()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testSigner writes a throwaway armored key and loads it.
|
||||||
|
func testSigner(t *testing.T) *tfsign.Signer {
|
||||||
|
t.Helper()
|
||||||
|
e, err := openpgp.NewEntity("artifactapi test", "tf", "tf@example.com", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w, _ := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||||
|
if err := e.SerializePrivate(w, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
p := filepath.Join(t.TempDir(), "private-key.asc")
|
||||||
|
if err := os.WriteFile(p, buf.Bytes(), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := tfsign.Load(p, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderRegistryFlow(t *testing.T) {
|
||||||
|
if testDSN == "" {
|
||||||
|
t.Skip("Docker unavailable")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := database.New(testDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
const repo = "tf-reg" // Terraform namespace == repo name
|
||||||
|
const filePath = "unkin/artifactapi/terraform-provider-artifactapi_1.2.3_linux_amd64.zip"
|
||||||
|
const hash = "sha256:983cdb25cb7b976538e4334d26e52dee5f44749b9be1500c760cf5cf66be659b"
|
||||||
|
const wantSha = "983cdb25cb7b976538e4334d26e52dee5f44749b9be1500c760cf5cf66be659b"
|
||||||
|
|
||||||
|
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageTerraform, RepoType: models.RepoTypeLocal}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.UpsertBlob(ctx, hash, "blobs/98/3c", 6381007, "application/zip"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.CreateLocalFile(ctx, repo, filePath, hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer := testSigner(t)
|
||||||
|
h := NewHandler(db, signer, "5.0,6.0")
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Get("/.well-known/terraform.json", h.ServiceDiscovery)
|
||||||
|
router.Mount(MountPath, h.Routes())
|
||||||
|
|
||||||
|
get := func(p string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest("GET", p, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service discovery.
|
||||||
|
w := get("/.well-known/terraform.json")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("discovery = %d", w.Code)
|
||||||
|
}
|
||||||
|
var disc map[string]string
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &disc)
|
||||||
|
if disc["providers.v1"] != ProvidersV1Path {
|
||||||
|
t.Errorf("providers.v1 = %q", disc["providers.v1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versions.
|
||||||
|
w = get("/terraform/v1/providers/tf-reg/artifactapi/versions")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("versions = %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
var vresp struct {
|
||||||
|
Versions []struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Protocols []string `json:"protocols"`
|
||||||
|
Platforms []map[string]string `json:"platforms"`
|
||||||
|
} `json:"versions"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &vresp)
|
||||||
|
if len(vresp.Versions) != 1 || vresp.Versions[0].Version != "1.2.3" {
|
||||||
|
t.Fatalf("unexpected versions: %+v", vresp)
|
||||||
|
}
|
||||||
|
if len(vresp.Versions[0].Platforms) != 1 || vresp.Versions[0].Platforms[0]["os"] != "linux" {
|
||||||
|
t.Fatalf("unexpected platforms: %+v", vresp.Versions[0].Platforms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download.
|
||||||
|
w = get("/terraform/v1/providers/tf-reg/artifactapi/1.2.3/download/linux/amd64")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("download = %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
var dl struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
SHASumsURL string `json:"shasums_url"`
|
||||||
|
SHASumsSignatureURL string `json:"shasums_signature_url"`
|
||||||
|
SHASum string `json:"shasum"`
|
||||||
|
SigningKeys struct {
|
||||||
|
GPGPublicKeys []struct {
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
ASCIIArmor string `json:"ascii_armor"`
|
||||||
|
} `json:"gpg_public_keys"`
|
||||||
|
} `json:"signing_keys"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &dl)
|
||||||
|
if dl.SHASum != wantSha {
|
||||||
|
t.Errorf("shasum = %q", dl.SHASum)
|
||||||
|
}
|
||||||
|
wantURL := "http://example.com/api/v1/local/tf-reg/" + filePath
|
||||||
|
if dl.DownloadURL != wantURL {
|
||||||
|
t.Errorf("download_url = %q, want %q", dl.DownloadURL, wantURL)
|
||||||
|
}
|
||||||
|
if len(dl.SigningKeys.GPGPublicKeys) != 1 || dl.SigningKeys.GPGPublicKeys[0].KeyID != signer.KeyID() {
|
||||||
|
t.Errorf("signing key mismatch: %+v", dl.SigningKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256SUMS + signature verify against the advertised key.
|
||||||
|
sums := get("/terraform/v1/providers/tf-reg/artifactapi/1.2.3/sha256sums")
|
||||||
|
wantLine := wantSha + " terraform-provider-artifactapi_1.2.3_linux_amd64.zip\n"
|
||||||
|
if sums.Body.String() != wantLine {
|
||||||
|
t.Errorf("sha256sums = %q, want %q", sums.Body.String(), wantLine)
|
||||||
|
}
|
||||||
|
sig := get("/terraform/v1/providers/tf-reg/artifactapi/1.2.3/sha256sums.sig")
|
||||||
|
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(dl.SigningKeys.GPGPublicKeys[0].ASCIIArmor)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(sums.Body.Bytes()), bytes.NewReader(sig.Body.Bytes())); err != nil {
|
||||||
|
t.Errorf("sha256sums.sig did not verify: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryDisabledWithoutSigner(t *testing.T) {
|
||||||
|
h := NewHandler(nil, nil, "")
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Get("/.well-known/terraform.json", h.ServiceDiscovery)
|
||||||
|
req := httptest.NewRequest("GET", "/.well-known/terraform.json", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != 404 {
|
||||||
|
t.Errorf("disabled discovery = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ type Config struct {
|
|||||||
S3Bucket string
|
S3Bucket string
|
||||||
S3Secure bool
|
S3Secure bool
|
||||||
S3Region string
|
S3Region string
|
||||||
|
|
||||||
|
// Terraform provider registry signing. When TFSigningKeyPath points at a
|
||||||
|
// readable armored GPG private key, artifactapi serves local terraform
|
||||||
|
// repos as a real provider registry (service discovery + signed
|
||||||
|
// SHA256SUMS). Left empty, the registry endpoints stay disabled.
|
||||||
|
TFSigningKeyPath string
|
||||||
|
TFSigningKeyPassphrase string
|
||||||
|
TFProviderProtocols string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) DatabaseDSN() string {
|
func (c *Config) DatabaseDSN() string {
|
||||||
@@ -59,6 +67,10 @@ func Load() (*Config, error) {
|
|||||||
S3Bucket: getenv("MINIO_BUCKET", "artifacts"),
|
S3Bucket: getenv("MINIO_BUCKET", "artifacts"),
|
||||||
S3Secure: s3Secure,
|
S3Secure: s3Secure,
|
||||||
S3Region: getenv("MINIO_REGION", ""),
|
S3Region: getenv("MINIO_REGION", ""),
|
||||||
|
|
||||||
|
TFSigningKeyPath: getenv("TF_SIGNING_KEY_PATH", ""),
|
||||||
|
TFSigningKeyPassphrase: getenv("TF_SIGNING_KEY_PASSPHRASE", ""),
|
||||||
|
TFProviderProtocols: getenv("TF_PROVIDER_PROTOCOLS", "5.0,6.0"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ var providerZipRe = regexp.MustCompile(
|
|||||||
|
|
||||||
var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$`)
|
var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$`)
|
||||||
|
|
||||||
|
// ParsedProviderZip describes a terraform-provider-{type}_{version}_{os}_{arch}.zip
|
||||||
|
// filename. Ok is false when the name doesn't match that convention.
|
||||||
|
type ParsedProviderZip struct {
|
||||||
|
Type string
|
||||||
|
Version string
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
|
Ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseProviderZip extracts the type, version and platform from a provider zip
|
||||||
|
// filename (the base name, not a full path). It's the canonical parser shared by
|
||||||
|
// the network-mirror index and the provider registry handler.
|
||||||
|
func ParseProviderZip(filename string) ParsedProviderZip {
|
||||||
|
m := providerZipRe.FindStringSubmatch(filename)
|
||||||
|
if m == nil {
|
||||||
|
return ParsedProviderZip{}
|
||||||
|
}
|
||||||
|
return ParsedProviderZip{Type: m[1], Version: m[2], OS: m[3], Arch: m[4], Ok: true}
|
||||||
|
}
|
||||||
|
|
||||||
type Provider struct{}
|
type Provider struct{}
|
||||||
|
|
||||||
func (p *Provider) Type() models.PackageType { return models.PackageTerraform }
|
func (p *Provider) Type() models.PackageType { return models.PackageTerraform }
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package terraform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeFileStore struct{ entries []provider.FileEntry }
|
||||||
|
|
||||||
|
func (f fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
|
||||||
|
var out []provider.FileEntry
|
||||||
|
for _, e := range f.entries {
|
||||||
|
if strings.HasPrefix(e.FilePath, prefix) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f fakeFileStore) ListPackages(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestTFPureFuncs(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
if p.Classify("hashicorp/aws/versions") != provider.Mutable {
|
||||||
|
t.Error("versions should be mutable")
|
||||||
|
}
|
||||||
|
if p.Classify("hashicorp/aws/terraform-provider-aws_1.0.0_linux_amd64.zip") != provider.Immutable {
|
||||||
|
t.Error("zip should be immutable")
|
||||||
|
}
|
||||||
|
if got := p.UpstreamURL(models.Remote{BaseURL: "https://registry.terraform.io"}, "hashicorp/aws/versions"); got != "https://registry.terraform.io/v1/providers/hashicorp/aws/versions" {
|
||||||
|
t.Errorf("upstream url %q", got)
|
||||||
|
}
|
||||||
|
h, _ := p.AuthHeaders(context.Background(), models.Remote{Username: "u", Password: "p"})
|
||||||
|
if h.Get("Authorization") == "" {
|
||||||
|
t.Error("auth header")
|
||||||
|
}
|
||||||
|
_ = p.ContentType("x.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTFValidateUpload(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
sp, ct, err := p.ValidateUpload("hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip")
|
||||||
|
if err != nil || sp != "hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip" || ct != "application/zip" {
|
||||||
|
t.Errorf("valid: sp=%q ct=%q err=%v", sp, ct, err)
|
||||||
|
}
|
||||||
|
if _, _, err := p.ValidateUpload("too/few"); err == nil {
|
||||||
|
t.Error("expected error for wrong path depth")
|
||||||
|
}
|
||||||
|
if _, _, err := p.ValidateUpload("ns/aws/not-a-provider.zip"); err == nil {
|
||||||
|
t.Error("expected error for bad filename")
|
||||||
|
}
|
||||||
|
if _, _, err := p.ValidateUpload("ns/gcp/terraform-provider-aws_1.0.0_linux_amd64.zip"); err == nil {
|
||||||
|
t.Error("expected error for type mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTFUploadResponse(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
resp := p.UploadResponse("hashicorp/aws/terraform-provider-aws_1.2.3_linux_amd64.zip", "sha256:abc", 100)
|
||||||
|
if resp["namespace"] != "hashicorp" || resp["type"] != "aws" || resp["version"] != "1.2.3" || resp["os"] != "linux" || resp["arch"] != "amd64" {
|
||||||
|
t.Errorf("structured response wrong: %v", resp)
|
||||||
|
}
|
||||||
|
fallback := p.UploadResponse("weird/path", "sha256:x", 1)
|
||||||
|
if fallback["path"] != "weird/path" {
|
||||||
|
t.Errorf("fallback response wrong: %v", fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTFRewriteResponse(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
remote := models.Remote{Name: "tf", ReleasesRemote: "hashicorp-releases"}
|
||||||
|
|
||||||
|
if out, _ := p.RewriteResponse([]byte(`{"download_url":"x"}`), models.Remote{}, "http://proxy"); out != nil {
|
||||||
|
t.Error("no ReleasesRemote should be a no-op")
|
||||||
|
}
|
||||||
|
if out, _ := p.RewriteResponse([]byte("not json"), remote, "http://proxy"); out != nil {
|
||||||
|
t.Error("invalid json should be a no-op")
|
||||||
|
}
|
||||||
|
body := []byte(`{"download_url":"https://releases.hashicorp.com/terraform-provider-aws/1.0/aws.zip"}`)
|
||||||
|
out, err := p.RewriteResponse(body, remote, "http://proxy")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "http://proxy/api/v1/remote/hashicorp-releases/") {
|
||||||
|
t.Errorf("download_url not rewritten: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTFServeLocalIndex(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
fs := fakeFileStore{entries: []provider.FileEntry{
|
||||||
|
{FilePath: "hashicorp/aws/terraform-provider-aws_1.0.0_linux_amd64.zip", ContentHash: "sha256:deadbeef"},
|
||||||
|
{FilePath: "hashicorp/aws/terraform-provider-aws_1.0.0_darwin_arm64.zip", ContentHash: "sha256:cafe"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
serve := func(path string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
|
||||||
|
p.ServeLocalIndex(w, r, fs, "repo", path)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := serve("hashicorp/aws/index.json"); w.Code != 200 || !strings.Contains(w.Body.String(), "1.0.0") {
|
||||||
|
t.Errorf("index.json: code=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := serve("hashicorp/aws/1.0.0.json"); w.Code != 200 || !strings.Contains(w.Body.String(), "linux_amd64") {
|
||||||
|
t.Errorf("version doc: code=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a terraform index path.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
if p.ServeLocalIndex(w, r, fs, "repo", "hashicorp/aws/other.txt") {
|
||||||
|
t.Error("non-index path should return false")
|
||||||
|
}
|
||||||
|
if p.ServeLocalIndex(httptest.NewRecorder(), r, fs, "repo", "too/short") {
|
||||||
|
t.Error("short path should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTFContentTypeAndEmptyIndex(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"x.zip": "application/zip",
|
||||||
|
"x.sig": "application/octet-stream",
|
||||||
|
"index.json": "application/json",
|
||||||
|
} {
|
||||||
|
if got := p.ContentType(path); got != want {
|
||||||
|
t.Errorf("ContentType(%q)=%q want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// index / version doc with no matching files -> 404.
|
||||||
|
empty := fakeFileStore{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/hashicorp/aws/index.json", nil)
|
||||||
|
p.ServeLocalIndex(w, r, empty, "repo", "hashicorp/aws/index.json")
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("empty index should be 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
p.ServeLocalIndex(w, r, empty, "repo", "hashicorp/aws/1.0.0.json")
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("empty version doc should be 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteDownloadURL(t *testing.T) {
|
||||||
|
// Empty proxy base -> unchanged.
|
||||||
|
if got := rewriteDownloadURL("https://x/a.zip", "rel", ""); got != "https://x/a.zip" {
|
||||||
|
t.Errorf("empty base: %q", got)
|
||||||
|
}
|
||||||
|
// Unparseable URL -> unchanged.
|
||||||
|
if got := rewriteDownloadURL("://bad", "rel", "http://p"); got != "://bad" {
|
||||||
|
t.Errorf("bad url: %q", got)
|
||||||
|
}
|
||||||
|
// Normal rewrite.
|
||||||
|
if got := rewriteDownloadURL("https://cdn/path/a.zip", "rel", "http://p"); got != "http://p/api/v1/remote/rel/path/a.zip" {
|
||||||
|
t.Errorf("rewrite: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTFGenerateLocalIndexUnsupported(t *testing.T) {
|
||||||
|
if _, err := (&Provider{}).GenerateLocalIndex(context.Background(), fakeFileStore{}, "r", "x"); err == nil {
|
||||||
|
t.Error("expected unsupported error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
tfregistry "git.unkin.net/unkin/artifactapi/internal/api/terraform"
|
||||||
v1 "git.unkin.net/unkin/artifactapi/internal/api/v1"
|
v1 "git.unkin.net/unkin/artifactapi/internal/api/v1"
|
||||||
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
|
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/cache"
|
"git.unkin.net/unkin/artifactapi/internal/cache"
|
||||||
@@ -30,6 +31,7 @@ import (
|
|||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/terraform"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/tfsign"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ type Server struct {
|
|||||||
engine *proxy.Engine
|
engine *proxy.Engine
|
||||||
virtEngine *virtual.Engine
|
virtEngine *virtual.Engine
|
||||||
localHandler *v2.LocalHandler
|
localHandler *v2.LocalHandler
|
||||||
|
tfRegistry *tfregistry.Handler
|
||||||
gc *gc.Collector
|
gc *gc.Collector
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +70,17 @@ func New(cfg *config.Config, version string) (*Server, error) {
|
|||||||
virtEngine := virtual.NewEngine(db, engine)
|
virtEngine := virtual.NewEngine(db, engine)
|
||||||
collector := gc.New(db, s3, 1*time.Hour)
|
collector := gc.New(db, s3, 1*time.Hour)
|
||||||
|
|
||||||
|
// A failure to load the signing key must not take the server down: the
|
||||||
|
// terraform registry simply stays disabled until a valid key is present.
|
||||||
|
signer, err := tfsign.Load(cfg.TFSigningKeyPath, cfg.TFSigningKeyPassphrase)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("terraform provider registry disabled", "error", err)
|
||||||
|
}
|
||||||
|
tfRegistry := tfregistry.NewHandler(db, signer, cfg.TFProviderProtocols)
|
||||||
|
if tfRegistry.Enabled() {
|
||||||
|
slog.Info("terraform provider registry enabled", "key_id", signer.KeyID())
|
||||||
|
}
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
version: version,
|
version: version,
|
||||||
@@ -76,6 +90,7 @@ func New(cfg *config.Config, version string) (*Server, error) {
|
|||||||
engine: engine,
|
engine: engine,
|
||||||
virtEngine: virtEngine,
|
virtEngine: virtEngine,
|
||||||
localHandler: localHandler,
|
localHandler: localHandler,
|
||||||
|
tfRegistry: tfRegistry,
|
||||||
gc: collector,
|
gc: collector,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +112,11 @@ func (s *Server) routes() chi.Router {
|
|||||||
r.Get("/", s.handleRoot)
|
r.Get("/", s.handleRoot)
|
||||||
r.Get("/version", s.handleVersion)
|
r.Get("/version", s.handleVersion)
|
||||||
|
|
||||||
|
// Terraform provider registry: service discovery at the well-known path,
|
||||||
|
// providers.v1 protocol under /terraform/v1/providers.
|
||||||
|
r.Get("/.well-known/terraform.json", s.tfRegistry.ServiceDiscovery)
|
||||||
|
r.Mount(tfregistry.MountPath, s.tfRegistry.Routes())
|
||||||
|
|
||||||
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
|
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
|
||||||
r.Mount("/api/v1", proxyHandler.Routes())
|
r.Mount("/api/v1", proxyHandler.Routes())
|
||||||
r.Mount("/v2", proxyHandler.DockerV2Routes())
|
r.Mount("/v2", proxyHandler.DockerV2Routes())
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// 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"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/openpgp"
|
||||||
|
"golang.org/x/crypto/openpgp/armor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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): signing is optional, and
|
||||||
|
// a nil *Signer means the terraform registry is disabled.
|
||||||
|
func Load(path, passphrase string) (*Signer, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open signing key: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
keyring, err := openpgp.ReadArmoredKeyRing(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read signing key: %w", err)
|
||||||
|
}
|
||||||
|
if len(keyring) == 0 {
|
||||||
|
return nil, fmt.Errorf("signing key %q contains no entities", path)
|
||||||
|
}
|
||||||
|
entity := keyring[0]
|
||||||
|
|
||||||
|
if entity.PrivateKey == nil {
|
||||||
|
return nil, fmt.Errorf("signing key %q has no private key material", path)
|
||||||
|
}
|
||||||
|
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,86 @@
|
|||||||
|
package tfsign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"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 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