diff --git a/internal/http/http.go b/internal/http/http.go index 1f1d24011..7b9852a71 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -3,12 +3,14 @@ package http import ( "bytes" + "crypto/tls" + "crypto/x509" "fmt" "html/template" "io" h "net/http" - "net/url" "os" + "runtime" "strings" "github.com/apex/log" @@ -97,6 +99,10 @@ func CheckConfig(ctx *context.Context, put *config.Put, kind string) error { return misconfigured(kind, put, fmt.Sprintf("missing %s environment variable", envName)) } + if put.TrustedCerts != "" && !x509.NewCertPool().AppendCertsFromPEM([]byte(put.TrustedCerts)) { + return misconfigured(kind, put, "no certificate could be added from the specified trusted_certificates configuration") + } + return nil } @@ -143,7 +149,7 @@ func Upload(ctx *context.Context, puts []config.Put, kind string, check Response }).Error(err.Error()) return err } - if err := uploadWithFilter(ctx, put, artifact.Or(filters...), kind, check); err != nil { + if err := uploadWithFilter(ctx, &put, artifact.Or(filters...), kind, check); err != nil { return err } } @@ -151,7 +157,7 @@ func Upload(ctx *context.Context, puts []config.Put, kind string, check Response return nil } -func uploadWithFilter(ctx *context.Context, put config.Put, filter artifact.Filter, kind string, check ResponseChecker) error { +func uploadWithFilter(ctx *context.Context, put *config.Put, filter artifact.Filter, kind string, check ResponseChecker) error { var g = semerrgroup.New(ctx.Parallelism) for _, artifact := range ctx.Artifacts.Filter(filter).List() { artifact := artifact @@ -163,7 +169,7 @@ func uploadWithFilter(ctx *context.Context, put config.Put, filter artifact.Filt } // uploadAsset uploads file to target and logs all actions -func uploadAsset(ctx *context.Context, put config.Put, artifact artifact.Artifact, kind string, check ResponseChecker) error { +func uploadAsset(ctx *context.Context, put *config.Put, artifact artifact.Artifact, kind string, check ResponseChecker) error { envBase := fmt.Sprintf("%s_%s_", strings.ToUpper(kind), strings.ToUpper(put.Name)) username := put.Username if username == "" { @@ -193,7 +199,7 @@ func uploadAsset(ctx *context.Context, put config.Put, artifact artifact.Artifac } targetURL += artifact.Name - _, err = uploadAssetToServer(ctx, targetURL, username, secret, asset, check) + _, err = uploadAssetToServer(ctx, put, targetURL, username, secret, asset, check) if err != nil { msg := fmt.Sprintf("%s: upload failed", kind) log.WithError(err).WithFields(log.Fields{ @@ -212,22 +218,18 @@ func uploadAsset(ctx *context.Context, put config.Put, artifact artifact.Artifac } // uploadAssetToServer uploads the asset file to target -func uploadAssetToServer(ctx *context.Context, target, username, secret string, a *asset, check ResponseChecker) (*h.Response, error) { +func uploadAssetToServer(ctx *context.Context, put *config.Put, target, username, secret string, a *asset, check ResponseChecker) (*h.Response, error) { req, err := newUploadRequest(target, username, secret, a) if err != nil { return nil, err } - return executeHTTPRequest(ctx, req, check) + return executeHTTPRequest(ctx, put, req, check) } // newUploadRequest creates a new h.Request for uploading func newUploadRequest(target, username, secret string, a *asset) (*h.Request, error) { - u, err := url.Parse(target) - if err != nil { - return nil, err - } - req, err := h.NewRequest("PUT", u.String(), a.ReadCloser) + req, err := h.NewRequest("PUT", target, a.ReadCloser) if err != nil { return nil, err } @@ -238,9 +240,36 @@ func newUploadRequest(target, username, secret string, a *asset) (*h.Request, er return req, err } +func getHTTPClient(put *config.Put) (*h.Client, error) { + if put.TrustedCerts == "" { + return h.DefaultClient, nil + } + pool, err := x509.SystemCertPool() + if err != nil { + if runtime.GOOS == "windows" { + // on windows ignore errors until golang issues #16736 & #18609 get fixed + pool = x509.NewCertPool() + } else { + return nil, err + } + } + pool.AppendCertsFromPEM([]byte(put.TrustedCerts)) // already validated certs checked by CheckConfig + return &h.Client{ + Transport: &h.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + }, + }, + }, nil +} + // executeHTTPRequest processes the http call with respect of context ctx -func executeHTTPRequest(ctx *context.Context, req *h.Request, check ResponseChecker) (*h.Response, error) { - resp, err := h.DefaultClient.Do(req) +func executeHTTPRequest(ctx *context.Context, put *config.Put, req *h.Request, check ResponseChecker) (*h.Response, error) { + client, err := getHTTPClient(put) + if err != nil { + return nil, err + } + resp, err := client.Do(req) if err != nil { // If we got an error, and the context has been canceled, // the context's error is probably more useful. @@ -249,7 +278,6 @@ func executeHTTPRequest(ctx *context.Context, req *h.Request, check ResponseChec return nil, ctx.Err() default: } - return nil, err } @@ -280,21 +308,21 @@ type targetData struct { // resolveTargetTemplate returns the resolved target template with replaced variables // Those variables can be replaced by the given context, goos, goarch, goarm and more -func resolveTargetTemplate(ctx *context.Context, artifactory config.Put, artifact artifact.Artifact) (string, error) { +func resolveTargetTemplate(ctx *context.Context, put *config.Put, artifact artifact.Artifact) (string, error) { data := targetData{ Version: ctx.Version, Tag: ctx.Git.CurrentTag, ProjectName: ctx.Config.ProjectName, } - if artifactory.Mode == ModeBinary { + if put.Mode == ModeBinary { data.Os = replace(ctx.Config.Archive.Replacements, artifact.Goos) data.Arch = replace(ctx.Config.Archive.Replacements, artifact.Goarch) data.Arm = replace(ctx.Config.Archive.Replacements, artifact.Goarm) } var out bytes.Buffer - t, err := template.New(ctx.Config.ProjectName).Parse(artifactory.Target) + t, err := template.New(ctx.Config.ProjectName).Parse(put.Target) if err != nil { return "", err } diff --git a/internal/http/http_test.go b/internal/http/http_test.go index bc26d14fe..ccad186d4 100644 --- a/internal/http/http_test.go +++ b/internal/http/http_test.go @@ -2,12 +2,14 @@ package http import ( "bytes" + "encoding/pem" "fmt" "io" "io/ioutil" h "net/http" "net/http/httptest" "os" + "strings" "sync" "testing" @@ -18,20 +20,6 @@ import ( "github.com/goreleaser/goreleaser/pkg/context" ) -var ( - mux *h.ServeMux - srv *httptest.Server -) - -func setup() { - mux = h.NewServeMux() - srv = httptest.NewServer(mux) -} - -func teardown() { - srv.Close() -} - func TestAssetOpenDefault(t *testing.T) { tf, err := ioutil.TempFile("", "") if err != nil { @@ -112,6 +100,7 @@ func TestCheckConfig(t *testing.T) { {"name missing", args{ctx, &config.Put{Target: "http://blabla", Username: "pepe", Mode: ModeArchive}, "test"}, true}, {"mode missing", args{ctx, &config.Put{Name: "a", Target: "http://blabla", Username: "pepe"}, "test"}, true}, {"mode invalid", args{ctx, &config.Put{Name: "a", Target: "http://blabla", Username: "pepe", Mode: "blabla"}, "test"}, true}, + {"cert invalid", args{ctx, &config.Put{Name: "a", Target: "http://blabla", Username: "pepe", Mode: ModeBinary, TrustedCerts: "bad cert!"}, "test"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -196,11 +185,10 @@ func doCheck(c check, r *h.Request) error { } func TestUpload(t *testing.T) { - setup() - defer teardown() content := []byte("blah!") requests := []*h.Request{} var m sync.Mutex + mux := h.NewServeMux() mux.Handle("/", h.HandlerFunc(func(w h.ResponseWriter, r *h.Request) { bs, err := ioutil.ReadAll(r.Body) if err != nil { @@ -247,37 +235,93 @@ func TestUpload(t *testing.T) { } { ctx.Artifacts.Add(artifact.Artifact{Name: "a." + a.ext, Path: "/a/a." + a.ext, Type: a.typ}) } + tests := []struct { - name string - ctx *context.Context - wantErr bool - put config.Put - check func(r []*h.Request) error + name string + tryPlain bool + tryTLS bool + wantErrPlain bool + wantErrTLS bool + setup func(*httptest.Server) (*context.Context, config.Put) + check func(r []*h.Request) error }{ - {"wrong-mode", ctx, true, - config.Put{Mode: "wrong-mode", Name: "a", Target: srv.URL + "/{{.ProjectName}}/{{.Version}}/", Username: "u1"}, + {"wrong-mode", true, true, true, true, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: "wrong-mode", + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}/", + Username: "u1", + TrustedCerts: cert(s), + } + }, checks(), }, - {"username-from-env", ctx, false, - config.Put{Mode: ModeArchive, Name: "a", Target: srv.URL + "/{{.ProjectName}}/{{.Version}}/"}, + {"username-from-env", true, true, false, false, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: ModeArchive, + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}/", + TrustedCerts: cert(s), + } + }, checks( check{"/blah/2.1.0/a.deb", "u2", "x", content}, check{"/blah/2.1.0/a.tar", "u2", "x", content}, ), }, - {"archive", ctx, false, - config.Put{Mode: ModeArchive, Name: "a", Target: srv.URL + "/{{.ProjectName}}/{{.Version}}/", Username: "u1"}, + {"archive", true, true, false, false, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: ModeArchive, + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}/", + Username: "u1", + TrustedCerts: cert(s), + } + }, checks( check{"/blah/2.1.0/a.deb", "u1", "x", content}, check{"/blah/2.1.0/a.tar", "u1", "x", content}, ), }, - {"binary", ctx, false, - config.Put{Mode: ModeBinary, Name: "a", Target: srv.URL + "/{{.ProjectName}}/{{.Version}}/", Username: "u2"}, + {"binary", true, true, false, false, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: ModeBinary, + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}/", + Username: "u2", + TrustedCerts: cert(s), + } + }, checks(check{"/blah/2.1.0/a.ubi", "u2", "x", content}), }, - {"archive-with-checksum-and-signature", ctx, false, - config.Put{Mode: ModeArchive, Name: "a", Target: srv.URL + "/{{.ProjectName}}/{{.Version}}/", Username: "u3", Checksum: true, Signature: true}, + {"binary-add-ending-bar", true, true, false, false, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: ModeBinary, + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}", + Username: "u2", + TrustedCerts: cert(s), + } + }, + checks(check{"/blah/2.1.0/a.ubi", "u2", "x", content}), + }, + {"archive-with-checksum-and-signature", true, true, false, false, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: ModeArchive, + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}/", + Username: "u3", + Checksum: true, + Signature: true, + TrustedCerts: cert(s), + } + }, checks( check{"/blah/2.1.0/a.deb", "u3", "x", content}, check{"/blah/2.1.0/a.tar", "u3", "x", content}, @@ -285,16 +329,100 @@ func TestUpload(t *testing.T) { check{"/blah/2.1.0/a.sig", "u3", "x", content}, ), }, + {"bad-template", true, true, true, true, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: ModeBinary, + Name: "a", + Target: s.URL + "/{{.ProjectNameXXX}}/{{.VersionXXX}}/", + Username: "u3", + Checksum: true, + Signature: true, + TrustedCerts: cert(s), + } + }, + checks(), + }, + {"failed-request", true, true, true, true, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: ModeBinary, + Name: "a", + Target: s.URL[0:strings.LastIndex(s.URL, ":")] + "/{{.ProjectName}}/{{.Version}}/", + Username: "u3", + Checksum: true, + Signature: true, + TrustedCerts: cert(s), + } + }, + checks(), + }, + {"broken-cert", false, true, false, true, + func(s *httptest.Server) (*context.Context, config.Put) { + return ctx, config.Put{ + Mode: ModeBinary, + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}/", + Username: "u3", + Checksum: false, + Signature: false, + TrustedCerts: "bad certs!", + } + }, + checks(), + }, + {"skip-publishing", true, true, true, true, + func(s *httptest.Server) (*context.Context, config.Put) { + c := *ctx + c.SkipPublish = true + return &c, config.Put{} + }, + checks(), + }, } + + uploadAndCheck := func(setup func(*httptest.Server) (*context.Context, config.Put), wantErrPlain, wantErrTLS bool, check func(r []*h.Request) error, srv *httptest.Server) { + requests = nil + ctx, put := setup(srv) + wantErr := wantErrPlain + if srv.Certificate() != nil { + wantErr = wantErrTLS + } + if err := Upload(ctx, []config.Put{put}, "test", is2xx); (err != nil) != wantErr { + t.Errorf("Upload() error = %v, wantErr %v", err, wantErr) + } + if err := check(requests); err != nil { + t.Errorf("Upload() request invalid. Error: %v", err) + } + } + for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - requests = nil - if err := Upload(tt.ctx, []config.Put{tt.put}, "test", is2xx); (err != nil) != tt.wantErr { - t.Errorf("Upload() error = %v, wantErr %v", err, tt.wantErr) - } - if err := tt.check(requests); err != nil { - t.Errorf("Upload() request invalid. Error: %v", err) - } - }) + if tt.tryPlain { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(mux) + defer srv.Close() + uploadAndCheck(tt.setup, tt.wantErrPlain, tt.wantErrTLS, tt.check, srv) + }) + } + if tt.tryTLS { + t.Run(tt.name+"-tls", func(t *testing.T) { + srv := httptest.NewUnstartedServer(mux) + srv.StartTLS() + defer srv.Close() + uploadAndCheck(tt.setup, tt.wantErrPlain, tt.wantErrTLS, tt.check, srv) + }) + } } + +} + +func cert(srv *httptest.Server) string { + if srv == nil || srv.Certificate() == nil { + return "" + } + block := &pem.Block{ + Type: "CERTIFICATE", + Bytes: srv.Certificate().Raw, + } + return string(pem.EncodeToMemory(block)) } diff --git a/pkg/config/config.go b/pkg/config/config.go index a8a042a97..5d2365218 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -287,12 +287,13 @@ type S3 struct { // Put HTTP upload configuration type Put struct { - Name string `yaml:",omitempty"` - Target string `yaml:",omitempty"` - Username string `yaml:",omitempty"` - Mode string `yaml:",omitempty"` - Checksum bool `yaml:",omitempty"` - Signature bool `yaml:",omitempty"` + Name string `yaml:",omitempty"` + Target string `yaml:",omitempty"` + Username string `yaml:",omitempty"` + Mode string `yaml:",omitempty"` + Checksum bool `yaml:",omitempty"` + Signature bool `yaml:",omitempty"` + TrustedCerts string `yaml:"trusted_certificates,omitempty"` } // Project includes all project configuration diff --git a/www/content/artifactory.md b/www/content/artifactory.md index 5fc3767d7..00baac49c 100644 --- a/www/content/artifactory.md +++ b/www/content/artifactory.md @@ -88,6 +88,34 @@ If your instance is named `production`, you need to store the secret in the environment variable `ARTIFACTORY_PRODUCTION_SECRET`. The name will be transformed to uppercase. +### Server authentication + +You can authenticate your Artifactory TLS server adding a trusted X.509 +certificate chain in your configuration. + +The trusted certificate chain will be used to validate the server certificates. + +You can set the trusted certificate chain using the `trusted_certificates` +setting the artifactory section with PEM encoded certificates on a YAML literal +block like this: + +```yaml +puts: + - name: "some artifactory server with a private TLS certificate" + #...(other settings)... + trusted_certificates: | + -----BEGIN CERTIFICATE----- + MIIDrjCCApagAwIBAgIIShr2zchZo+8wDQYJKoZIhvcNAQENBQAwNTEXMBUGA1UE + ...(edited content)... + TyzMJasj5BPZrmKjJb6O/tOtEIJ66xPSBTxPShkEYHnB7A== + -----END CERTIFICATE----- + -----BEGIN CERTIFICATE----- + MIIDrjCCApagAwIBAgIIShr2zchZo+8wDQYJKoZIhvcNAQENBQAwNTEXMBUGA1UE + ...(edited content)... + TyzMJasj5BPZrmKjJb6O/tOtEIJ66xPSBTxPShkEYHnB7A== + -----END CERTIFICATE----- +``` + ## Customization Of course, you can customize a lot of things: @@ -112,6 +140,13 @@ artifactories: checksum: true # Upload signatures (defaults to false) signature: true + # Certificate chain used to validate server certificates + trusted_certificates: | + -----BEGIN CERTIFICATE----- + MIIDrjCCApagAwIBAgIIShr2zchZo+8wDQYJKoZIhvcNAQENBQAwNTEXMBUGA1UE + ...(edited content)... + TyzMJasj5BPZrmKjJb6O/tOtEIJ66xPSBTxPShkEYHnB7A== + -----END CERTIFICATE----- ``` These settings should allow you to push your artifacts into multiple Artifactories. diff --git a/www/content/put.md b/www/content/put.md index be70d25f4..5a1f33aec 100644 --- a/www/content/put.md +++ b/www/content/put.md @@ -5,7 +5,6 @@ hideFromIndex: true weight: 120 --- -Since [vX.Y.Z](https://github.com/goreleaser/goreleaser/releases/tag/vX.Y.Z), GoReleaser supports building and pushing artifacts to HTTP servers using simple HTTP PUT requests. ## How it works @@ -85,6 +84,34 @@ If your instance is named `production`, you need to store the secret in the environment variable `PUT_PRODUCTION_SECRET`. The name will be transformed to uppercase. +### Server authentication + +You can authenticate your TLS server adding a trusted X.509 certificate chain +in your put configuration. + +The trusted certificate chain will be used to validate the server certificates. + +You can set the trusted certificate chain using the `trusted_certificates` +setting the put section with PEM encoded certificates on a YAML literal block +like this: + +```yaml +puts: + - name: "some HTTP/TLS server" + #...(other settings)... + trusted_certificates: | + -----BEGIN CERTIFICATE----- + MIIDrjCCApagAwIBAgIIShr2zchZo+8wDQYJKoZIhvcNAQENBQAwNTEXMBUGA1UE + ...(edited content)... + TyzMJasj5BPZrmKjJb6O/tOtEIJ66xPSBTxPShkEYHnB7A== + -----END CERTIFICATE----- + -----BEGIN CERTIFICATE----- + MIIDrjCCApagAwIBAgIIShr2zchZo+8wDQYJKoZIhvcNAQENBQAwNTEXMBUGA1UE + ...(edited content)... + TyzMJasj5BPZrmKjJb6O/tOtEIJ66xPSBTxPShkEYHnB7A== + -----END CERTIFICATE----- +``` + ## Customization Of course, you can customize a lot of things: @@ -102,13 +129,20 @@ puts: # Default is `archive`. mode: archive # URL to be used as target of the HTTP PUT request - target: http://some.server/some/path/example-repo-local/{{ .ProjectName }}/{{ .Version }}/ + target: https://some.server/some/path/example-repo-local/{{ .ProjectName }}/{{ .Version }}/ # User that will be used for the deployment username: deployuser # Upload checksums (defaults to false) checksum: true # Upload signatures (defaults to false) signature: true + # Certificate chain used to validate server certificates + trusted_certificates: | + -----BEGIN CERTIFICATE----- + MIIDrjCCApagAwIBAgIIShr2zchZo+8wDQYJKoZIhvcNAQENBQAwNTEXMBUGA1UE + ...(edited content)... + TyzMJasj5BPZrmKjJb6O/tOtEIJ66xPSBTxPShkEYHnB7A== + -----END CERTIFICATE----- ``` These settings should allow you to push your artifacts into multiple HTTP servers.