diff --git a/internal/http/http.go b/internal/http/http.go index 728862c1f51..148c81435e5 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -112,6 +112,19 @@ func CheckConfig(ctx *context.Context, upload *config.Upload, kind string) error return misconfigured(kind, upload, "no certificate could be added from the specified trusted_certificates configuration") } + if upload.ClientX509Cert != "" && upload.ClientX509Key == "" { + return misconfigured(kind, upload, "'client_x509_key' must be set when 'client_x509_cert' is set") + } + if upload.ClientX509Key != "" && upload.ClientX509Cert == "" { + return misconfigured(kind, upload, "'client_x509_cert' must be set when 'client_x509_key' is set") + } + if upload.ClientX509Cert != "" && upload.ClientX509Key != "" { + if _, err := tls.LoadX509KeyPair(upload.ClientX509Cert, upload.ClientX509Key); err != nil { + return misconfigured(kind, upload, + "client x509 certificate could not be loaded from the specified 'client_x509_cert' and 'client_x509_key'") + } + } + return nil } @@ -306,27 +319,34 @@ func newUploadRequest(ctx *context.Context, method, target, username, secret str } func getHTTPClient(upload *config.Upload) (*h.Client, error) { - if upload.TrustedCerts == "" { + if upload.TrustedCerts == "" && upload.ClientX509Cert == "" && upload.ClientX509Key == "" { 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 { + transport := &h.Transport{ + Proxy: h.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{}, + } + if upload.TrustedCerts != "" { + 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(upload.TrustedCerts)) // already validated certs checked by CheckConfig + transport.TLSClientConfig.RootCAs = pool + } + if upload.ClientX509Cert != "" && upload.ClientX509Key != "" { + cert, err := tls.LoadX509KeyPair(upload.ClientX509Cert, upload.ClientX509Key) + if err != nil { return nil, err } + transport.TLSClientConfig.Certificates = []tls.Certificate{cert} } - pool.AppendCertsFromPEM([]byte(upload.TrustedCerts)) // already validated certs checked by CheckConfig - return &h.Client{ - Transport: &h.Transport{ - Proxy: h.ProxyFromEnvironment, - TLSClientConfig: &tls.Config{ // nolint: gosec - RootCAs: pool, - }, - }, - }, nil + return &h.Client{Transport: transport}, nil } // executeHTTPRequest processes the http call with respect of context ctx. diff --git a/internal/http/http_test.go b/internal/http/http_test.go index 1966464a3de..b5296f8bfd2 100644 --- a/internal/http/http_test.go +++ b/internal/http/http_test.go @@ -2,6 +2,7 @@ package http import ( "bytes" + "crypto/tls" "encoding/pem" "errors" "fmt" @@ -566,6 +567,47 @@ func TestUpload(t *testing.T) { check{"/blah/2.1.0/a.deb", "u3", "x", content, map[string]string{}}, ), }, + { + name: "given a server with ClientAuth = RequireAnyClientCert, " + + "and an Upload with ClientX509Cert and ClientX509Key set, " + + "then the response should pass", + tryTLS: true, + setup: func(s *httptest.Server) (*context.Context, config.Upload) { + s.TLS.ClientAuth = tls.RequireAnyClientCert + return ctx, config.Upload{ + Mode: ModeArchive, + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}/", + Username: "u3", + TrustedCerts: cert(s), + ClientX509Cert: "testcert.pem", + ClientX509Key: "testkey.pem", + Exts: []string{"deb", "rpm"}, + } + }, + check: checks( + check{"/blah/2.1.0/a.deb", "u3", "x", content, map[string]string{}}, + ), + }, + { + name: "given a server with ClientAuth = RequireAnyClientCert, " + + "and an Upload without either ClientX509Cert or ClientX509Key set, " + + "then the response should fail", + tryTLS: true, + setup: func(s *httptest.Server) (*context.Context, config.Upload) { + s.TLS.ClientAuth = tls.RequireAnyClientCert + return ctx, config.Upload{ + Mode: ModeArchive, + Name: "a", + Target: s.URL + "/{{.ProjectName}}/{{.Version}}/", + Username: "u3", + TrustedCerts: cert(s), + Exts: []string{"deb", "rpm"}, + } + }, + wantErrTLS: true, + check: checks(), + }, } uploadAndCheck := func(t *testing.T, setup func(*httptest.Server) (*context.Context, config.Upload), wantErrPlain, wantErrTLS bool, check func(r []*h.Request) error, srv *httptest.Server) { @@ -585,21 +627,23 @@ func TestUpload(t *testing.T) { } for _, tt := range tests { - if tt.tryPlain { - t.Run(tt.name, func(t *testing.T) { - srv := httptest.NewServer(mux) - defer srv.Close() - uploadAndCheck(t, 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(t, tt.setup, tt.wantErrPlain, tt.wantErrTLS, tt.check, srv) - }) - } + t.Run(tt.name, func(t *testing.T) { + if tt.tryPlain { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(mux) + defer srv.Close() + uploadAndCheck(t, 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(t, tt.setup, tt.wantErrPlain, tt.wantErrTLS, tt.check, srv) + }) + } + }) } } diff --git a/internal/http/testcert.pem b/internal/http/testcert.pem new file mode 100644 index 00000000000..0d0f35c441c --- /dev/null +++ b/internal/http/testcert.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFgTCCA2mgAwIBAgIUScqw7e1i0RlxSe+l4VSg6cJXjSAwDQYJKoZIhvcNAQEL +BQAwUDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMSEwHwYDVQQKDBhJbnRlcm5l +dCBXaWRnaXRzIFB0eSBMdGQxETAPBgNVBAMMCHRlc3RjZXJ0MB4XDTIyMDgxMDA3 +NTMzN1oXDTIyMDkwOTA3NTMzN1owUDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNB +MSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxETAPBgNVBAMMCHRl +c3RjZXJ0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0I10EpRJapUo +hQrvTvK12utZ1L6UASW7twzN2l4alM/hRi9qyrUUWIhD8uB0oEaPxa8ErAJFuK2e +paNMB7j0bS4iMaaTZx7hh9IoEz8iROiBILt+68LM9zEVN19YbLws6m7IQ7LAcjj8 +imshswzVIDyOArLSko8z81nEE8fAbzXzBRfG5+x4T5JnVTy/B4qNC3Rk5McNfsOj +bTUklsVOeOmsWoNsMZXUgPMKXZbwQ1fJlNEcXalfxWXF7MBVuZD59eKeHZdFCvg9 +otVqdaD4tEDcm9rjd7osNEasdGPSGG6kNIbUE8LYmSTR3OL1oPoQiqi+ic9NUOer +lsUgjbHwH8B0arw7QIbNDLNgIsKJX5FuGfb6BgfWoItrGc2wqFvXAWVn0EmTGCd6 +x7IioW/U4TI+WHKlSZ2PdwtGnEmp0JXzwx3n6XTja30DMZXXTZ4MXd+YdLn7Lajz +33BXm6UYiVpxkCD/3QN2+32SwYWYa/js7rf2gZ3G3lpt4Iqb82v4/p4wxRcUl5sj +ws8yteV15iecXP1ow/wmUfzLBmHQkwy9WD1poKGWL7fVzWpTe4U+lYM47mXznvzu +WkXI2K1/70L+IablW6USDdMolf4ZR8IOS8cXl30z1XP7c+u0V9SGHOLVToOEfGIx +zxgT2P8wfkAiDY/qhNT2R5nMVrSymm8CAwEAAaNTMFEwHQYDVR0OBBYEFABsCi9C +i5QxArCJBWh1R1Tw4ntLMB8GA1UdIwQYMBaAFABsCi9Ci5QxArCJBWh1R1Tw4ntL +MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAEC/KYL0s/GYKtEY +PsDjF+JwJ2dAKf8ZzfykTUTH4MWA1aGVFlRs5K/pbr1r4nPNumhPyw0+q7YNE8oK +0rhL4LarZQNIPWfSybjxyKD4jQJE8qTFnu0imn9r8Lmqjwz6xoz5JPt89dzk8ysR +et7Yv4q0aFDffSTnx2JbabR53TXm6JqTFkEBn66GGyq7ZTVU0yaHUsWsWRBsLFLY +F/gQp+l8uJlEi9MQh0gWPeIUJf+uGryOsOTEpFvYQ/9kaMHaHDQQ4FdKMDpGP4xi +YNvCzn4xQkEX8r5+Kff0Nr9dflsg6TMzPUJwPKqfi6s+mj5AkB2rAiBKO/Yvu5sb +ZLfYiRP73TIj2PSi0OxhADhtBwkhYFzhnNlAF76QieXsRMnwDG0oztTxMKIcIXaW +cCwB637h+BypnR22ye8ObzCRvh7CW841Xb/qNaPsHiviEtPsejeMIXcSwVbk3cU+ +zYPUYrg7+S6/BUQIcYX8sVuPHxmZlDe4Zt/wHn7PhvO7RkHKmEM+WH1snJ/0mxd7 +V++YDMxBdThi33cNsfBT6ug9xEcLydSD+Q+VqOTS+YsrnvpdKl2l/A9pODcCpazn +xR/LjDsbbsHgCP+90tM46ZIS7HV5uT6Gyek/UEergtaNjmA6gFU1C8BPI2NZjn+n +BsplATncX8RY04Q6e6x4FMxLEGCK +-----END CERTIFICATE----- diff --git a/internal/http/testkey.pem b/internal/http/testkey.pem new file mode 100644 index 00000000000..b39a1ae4585 --- /dev/null +++ b/internal/http/testkey.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +xCjEgdOkFCQ01FTbkkb4n7xPsleEas0= +-----END PRIVATE KEY----- diff --git a/pkg/config/config.go b/pkg/config/config.go index 2355cb3f4a6..5d834dd8964 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -864,6 +864,8 @@ type Upload struct { Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` Method string `yaml:"method,omitempty" json:"method,omitempty"` ChecksumHeader string `yaml:"checksum_header,omitempty" json:"checksum_header,omitempty"` + ClientX509Cert string `yaml:"client_x509_cert" json:"client_x509_cert"` + ClientX509Key string `yaml:"client_x509_key" json:"client_x509_key"` TrustedCerts string `yaml:"trusted_certificates,omitempty" json:"trusted_certificates,omitempty"` Checksum bool `yaml:"checksum,omitempty" json:"checksum,omitempty"` Signature bool `yaml:"signature,omitempty" json:"signature,omitempty"` diff --git a/www/README.md b/www/README.md new file mode 100644 index 00000000000..463757eece6 --- /dev/null +++ b/www/README.md @@ -0,0 +1,21 @@ +# Documentation + +Documentation is written in mkdocs and there are a few extensions that allow richer +authoring than markdown. + +To iterate with documentation, therefore, it is recommended to run the mkdocs server and view your pages in a browser. + +## Prerequisites + +- [Get Docker](https://docs.docker.com/get-docker/) +- [Get Task](https://taskfile.dev/installation/) + +### NOTE to M1/M2 mac owners + +If running on an arm64-based mac (M1 or M2, aka "Applie Silicon"), you may find this method quite slow. Until +multiarch docker images can be built and made available, you may wish to build your own via: + +```bash +git clone git@github.com:squidfunk/mkdocs-material.git +docker build -t docker.io/squidfunk/mkdocs-material . +``` diff --git a/www/docs/customization/artifactory.md b/www/docs/customization/artifactory.md index 7867a2c4e9f..20d492c239c 100644 --- a/www/docs/customization/artifactory.md +++ b/www/docs/customization/artifactory.md @@ -22,7 +22,7 @@ artifactories: Prerequisites: - A running Artifactory instances -- A user + password / API key with grants to upload an artifact +- A user + password / client x509 certificate / API key with grants to upload an artifact ### Target @@ -82,6 +82,22 @@ 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. +### Client authorization with x509 certificate (mTLS / mutual TLS) + +If your artifactory server supports authorization with mTLS (client certificates), you can provide them by specifying +the location of an x509 certificate/key pair of pem-encode files. + +```yaml +artifactories: + - name: production + target: http://:8081/artifactory/example-repo-local/{{ .ProjectName }}/{{ .Version }}/ + client_x509_cert: path/to/client.cert.pem + client_x509_key: path/to/client.key.pem +``` + +This will offer the client certificate during the TLS handshake, which your artifactory server may use to authenticate +and authorize you to upload. + ### Server authentication You can authenticate your Artifactory TLS server adding a trusted X.509 @@ -148,6 +164,10 @@ artifactories: # User that will be used for the deployment username: deployuser + # Client certificate and key (when provided, added as client cert to TLS connections) + client_x509_cert: /path/to/client.cert.pem + client_x509_key: /path/to/client.key.pem + # Upload checksums (defaults to false) checksum: true diff --git a/www/docs/customization/upload.md b/www/docs/customization/upload.md index 57cbc83264b..d8b80d53d73 100644 --- a/www/docs/customization/upload.md +++ b/www/docs/customization/upload.md @@ -20,7 +20,12 @@ uploads: Prerequisites: - An HTTP server accepting HTTP requests -- A user + password with grants to upload an artifact using HTTP requests for basic authentication (only if the server requires it) +- A user + password / client x509 certificate / API key with grants to upload an artifact + +!!! note + authentication is optional and may be provided if the server requires it + - user/pass is for Basic Authentication + - client x509 certificate is for mutual TLS authentication (aka "mTLS") ### Target @@ -89,6 +94,22 @@ The name will be transformed to uppercase. This field is optional and is used only for basic http authentication. +### Client authorization with x509 certificate (mTLS / mutual TLS) + +If your artifactory server supports authorization with mTLS (client certificates), you can provide them by specifying +the location of an x509 certificate/key pair of pem-encode files. + +```yaml +uploads: +- name: production + client_x509_cert: path/to/client.cert.pem + client_x509_key: path/to/client.key.pem + target: 'http://some.server/some/path/example-repo-local/{{ .ProjectName }}/{{ .Version }}/{{ .Os }}/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}' +``` + +This will offer the client certificate during the TLS handshake, which your artifactory server may use to authenticate +and authorize you to upload. + ### Server authentication You can authenticate your TLS server adding a trusted X.509 certificate chain @@ -166,6 +187,10 @@ uploads: # An optional username that will be used for the deployment for basic authn username: deployuser + # Client certificate and key (when provided, added as client cert to TLS connections) + client_x509_cert: /path/to/client.cert.pem + client_x509_key: /path/to/client.key.pem + # An optional header you can use to tell GoReleaser to pass the artifact's # SHA256 checksum within the upload request. # Default is empty. diff --git a/www/docs/static/schema.json b/www/docs/static/schema.json index c7a20ab5d6e..32098e51dcf 100644 --- a/www/docs/static/schema.json +++ b/www/docs/static/schema.json @@ -2508,6 +2508,12 @@ "checksum_header": { "type": "string" }, + "client_x509_cert": { + "type": "string" + }, + "client_x509_key": { + "type": "string" + }, "trusted_certificates": { "type": "string" }, @@ -2530,7 +2536,11 @@ } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "client_x509_cert", + "client_x509_key" + ] }, "Webhook": { "properties": {