Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ca_file settings for all backends #447

Merged
merged 17 commits into from
Mar 23, 2022
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Unreleased changes are available as `avenga/couper:edge` container.
* `saml` [error type](./docs/ERRORS.md#error-types) ([#424](https://github.com/avenga/couper/pull/424))
* `allowed_methods` attribute for the [API](./docs/REFERENCE.md#api-block) or [Endpoint Block](./docs/REFERENCE.md#endpoint-block) ([#444](https://github.com/avenga/couper/pull/444))
* new HCL functions: `contains()`, `join()`, `keys()`, `length()`, `lookup()`, `set_intersection()`, `to_number()` ([#455](https://github.com/avenga/couper/pull/455))
* `ca_file` option to `settings` (also as argument and environment option) ([#447](https://github.com/avenga/couper/pull/447))
* Option for adding the given PEM encoded ca-certificate to the existing system certificate pool for all outgoing connections.

* **Changed**
* Automatically add the `private` directive to the response `Cache-Control` HTTP header field value for all resources protected by [JWT](./docs/REFERENCE.md#jwt-block) ([#418](https://github.com/avenga/couper/pull/418))
Expand Down
3 changes: 2 additions & 1 deletion DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ docker run avenga/couper run -watch -p 8081
### Environment options

| Variable | Default | Description |
| :----------------------------------- | :------ | :---------- |
|:-------------------------------------| :------ | :---------- |
| COUPER_FILE | `couper.hcl` | Path to the configuration file. |
| COUPER_ACCEPT_FORWARDED_URL | `""` | Which `X-Forwarded-*` request headers should be accepted to change the [request variables](https://github.com/avenga/couper/blob/master/docs/REFERENCE.md#request) `url`, `origin`, `protocol`, `host`, `port`. Comma-separated list of values. Valid values: `proto`, `host`, `port`. |
| COUPER_DEFAULT_PORT | `8080` | Sets the default port to the given value and does not override explicit `[host:port]` configurations from file. |
Expand All @@ -43,6 +43,7 @@ docker run avenga/couper run -watch -p 8081
| COUPER_WATCH_RETRIES | `5` | Maximal retry count for configuration reloads which could not bind the configured port. |
| COUPER_WATCH_RETRY_DELAY | `500ms` | Delay duration before next attempt if an error occurs. |
| COUPER_XFH | `false` | Global configurations which uses the `Forwarded-Host` header instead of the request host. |
| COUPER_CA_FILE | `""` | Option for adding the given PEM encoded ca-certificate to the existing system certificate pool for all outgoing connections. |
| | | |
| COUPER_BETA_METRICS | `false` | Option to enable the prometheus [metrics](https://github.com/avenga/couper/blob/master/docs/METRICS.md) exporter. |
| COUPER_BETA_METRICS_PORT | `9090` | Prometheus exporter listen port. |
Expand Down
40 changes: 40 additions & 0 deletions command/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package command

import (
"context"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"io/ioutil"
"net"
"net/http"
"strings"
Expand Down Expand Up @@ -40,6 +43,7 @@ type Run struct {
func NewRun(ctx context.Context) *Run {
settings := config.DefaultSettings
set := flag.NewFlagSet("run", flag.ContinueOnError)
set.StringVar(&settings.CAFile, "ca-file", settings.CAFile, "-ca-file certificate.pem")
set.StringVar(&settings.HealthPath, "health-path", settings.HealthPath, "-health-path /healthz")
set.IntVar(&settings.DefaultPort, "p", settings.DefaultPort, "-p 8080")
set.BoolVar(&settings.XForwardedHost, "xfh", settings.XForwardedHost, "-xfh")
Expand Down Expand Up @@ -122,6 +126,11 @@ func (r *Run) Execute(args Args, config *config.Couper, logEntry *logrus.Entry)
timings := runtime.DefaultTimings
env.Decode(&timings)

if config.Settings.CAFile != "" {
config.Settings.Certificate, err = readCertificateFile(config.Settings.CAFile)
logEntry.Infof("configured with ca-certificate: %s", config.Settings.CAFile)
}

telemetry.InitExporter(r.context, &telemetry.Options{
MetricsCollectPeriod: time.Second * 2,
Metrics: r.settings.TelemetryMetrics,
Expand Down Expand Up @@ -194,6 +203,37 @@ func (r *Run) Execute(args Args, config *config.Couper, logEntry *logrus.Entry)
return nil
}

// readCertificateFile reads given file bytes and PEM decodes the certificates the
// same way x509.CertPool.AppendCertsFromPEM does.
// AppendCertsFromPEM method will be used on backend transport creation.
func readCertificateFile(file string) ([]byte, error) {
cert, err := ioutil.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("error reading ca-certificate: %v", err)
} else if len(cert) == 0 {
return nil, fmt.Errorf("error reading ca-certificate: empty file: %q", file)
}

pemCerts := cert[:]
for len(pemCerts) > 0 {
var block *pem.Block
block, pemCerts = pem.Decode(pemCerts)
if block == nil {
return nil, fmt.Errorf("error parsing pem ca-certificate: missing pem block")
}
if block.Type != "CERTIFICATE" || len(block.Headers) != 0 {
malud marked this conversation as resolved.
Show resolved Hide resolved
continue
}

certBytes := block.Bytes
if _, err = x509.ParseCertificate(certBytes); err != nil {
return nil, fmt.Errorf("error parsing pem ca-certificate: %q: %v", file, err)
}
}

return cert, nil
}

func (r *Run) Usage() {
r.flagSet.Usage()
}
142 changes: 142 additions & 0 deletions command/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package command

import (
"context"
"crypto/tls"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"testing"
"time"

"github.com/rs/xid"
logrustest "github.com/sirupsen/logrus/hooks/test"
Expand All @@ -17,6 +21,7 @@ import (
"github.com/avenga/couper/config/configload"
"github.com/avenga/couper/config/env"
"github.com/avenga/couper/internal/test"
"github.com/avenga/couper/server"
)

func TestNewRun(t *testing.T) {
Expand Down Expand Up @@ -225,3 +230,140 @@ func TestAcceptForwarded(t *testing.T) {
})
}
}

func TestArgs_CAFile(t *testing.T) {
helper := test.New(t)

log, hook := test.NewLogger()
defer func() {
if t.Failed() {
for _, entry := range hook.AllEntries() {
t.Log(entry.String())
}
}
}()

ctx, shutdown := context.WithCancel(context.Background())
defer shutdown()

runCmd := NewRun(ctx)
if runCmd == nil {
t.Error("create run cmd failed")
return
}

expiresIn := time.Second * 20
selfSigned, err := server.NewCertificate(expiresIn, nil, nil)
helper.Must(err)

expires := time.After(expiresIn)

tmpFile, err := ioutil.TempFile("", "ca.cert")
helper.Must(err)
_, err = tmpFile.Write(selfSigned.CA)
helper.Must(err)
helper.Must(tmpFile.Close())
defer os.Remove(tmpFile.Name())

srv := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNoContent)

// force close to trigger a new handshake
hj, ok := writer.(http.Hijacker)
if !ok {
t.Error("expected hijacker")
}

conn, _, herr := hj.Hijack()
if herr != nil {
t.Error(herr)
}

conn.Close()
}))

srv.TLS.Certificates = []tls.Certificate{*selfSigned.Server}

couperHCL := `server {
endpoint "/" {
request {
url = "` + srv.URL + `"
}
}
}`

couperFile, err := configload.LoadBytes([]byte(couperHCL), "ca-file-test.hcl")
helper.Must(err)

port := couperFile.Settings.DefaultPort

// ensure the previous tests aren't listening
test.WaitForClosedPort(port)
go func() {
execErr := runCmd.Execute(Args{"-ca-file=" + tmpFile.Name()}, couperFile, log.WithContext(ctx))
if execErr != nil {
helper.Must(execErr)
}
}()
test.WaitForOpenPort(port)

client := test.NewHTTPClient()

req, _ := http.NewRequest(http.MethodGet, "http://localhost:8080/", nil)

// ca before
res, err := client.Do(req)
helper.Must(err)

if res.StatusCode != http.StatusNoContent {
t.Error("unexpected status code")
}

// ca after
<-expires

// handshake error
res, err = client.Do(req)
helper.Must(err)

if res.StatusCode != http.StatusBadGateway {
t.Error("unexpected status code")
}
}

func TestReadCAFile(t *testing.T) {
helper := test.New(t)

_, err := readCertificateFile("/does/not/exist.cert")
if err == nil {
t.Error("expected file error")
} else if err.Error() != "error reading ca-certificate: open /does/not/exist.cert: no such file or directory" {
t.Error("expected no such file error")
}

tmpFile, err := ioutil.TempFile("", "empty.cert")
helper.Must(err)
defer os.Remove(tmpFile.Name())

_, err = readCertificateFile(tmpFile.Name())
if err == nil {
t.Error("expected empty file error")
} else if err.Error() != `error reading ca-certificate: empty file: "`+tmpFile.Name()+`"` {
t.Error("expected empty file error with file-name")
}

malformedFile, err := ioutil.TempFile("", "broken.cert")
helper.Must(err)
defer os.Remove(malformedFile.Name())

ssc, err := server.NewCertificate(time.Minute, nil, nil)
helper.Must(err)

_, err = malformedFile.Write(ssc.CA[:100]) // incomplete
helper.Must(err)

_, err = readCertificateFile(malformedFile.Name())
if err == nil || err.Error() != "error parsing pem ca-certificate: missing pem block" {
t.Error("expected: error parsing pem ca-certificate: missing pem block")
}
}
6 changes: 3 additions & 3 deletions config/runtime/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func newEndpointMap(srvConf *config.Server, serverOptions *server.Options) (endp
}

func newEndpointOptions(confCtx *hcl.EvalContext, endpointConf *config.Endpoint, apiConf *config.API,
serverOptions *server.Options, log *logrus.Entry, proxyEnv bool, memStore *cache.MemoryStore) (*handler.EndpointOptions, error) {
serverOptions *server.Options, log *logrus.Entry, proxyEnv bool, certificate []byte, memStore *cache.MemoryStore) (*handler.EndpointOptions, error) {
var errTpl *errors.Template

if endpointConf.ErrorFile != "" {
Expand Down Expand Up @@ -92,7 +92,7 @@ func newEndpointOptions(confCtx *hcl.EvalContext, endpointConf *config.Endpoint,

allProxies := make(map[string]*producer.Proxy)
for _, proxyConf := range endpointConf.Proxies {
backend, innerBody, berr := newBackend(confCtx, proxyConf.Backend, log, proxyEnv, memStore)
backend, innerBody, berr := newBackend(confCtx, proxyConf.Backend, log, proxyEnv, certificate, memStore)
if berr != nil {
return nil, berr
}
Expand All @@ -108,7 +108,7 @@ func newEndpointOptions(confCtx *hcl.EvalContext, endpointConf *config.Endpoint,

allRequests := make(map[string]*producer.Request)
for _, requestConf := range endpointConf.Requests {
backend, innerBody, berr := newBackend(confCtx, requestConf.Backend, log, proxyEnv, memStore)
backend, innerBody, berr := newBackend(confCtx, requestConf.Backend, log, proxyEnv, certificate, memStore)
if berr != nil {
return nil, berr
}
Expand Down
4 changes: 2 additions & 2 deletions config/runtime/error_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
)

func newErrorHandler(ctx *hcl.EvalContext, opts *protectedOptions, log *logrus.Entry,
defs ACDefinitions, references ...string) (http.Handler, error) {
defs ACDefinitions, certificate []byte, references ...string) (http.Handler, error) {
kindsHandler := map[string]http.Handler{}
for _, ref := range references {
definition, ok := defs[ref]
Expand Down Expand Up @@ -42,7 +42,7 @@ func newErrorHandler(ctx *hcl.EvalContext, opts *protectedOptions, log *logrus.E
epConf.Response = &config.Response{Remain: emptyBody}
}

epOpts, err := newEndpointOptions(ctx, epConf, nil, opts.srvOpts, log, opts.proxyFromEnv, opts.memStore)
epOpts, err := newEndpointOptions(ctx, epConf, nil, opts.srvOpts, log, opts.proxyFromEnv, certificate, opts.memStore)
if err != nil {
return nil, err
}
Expand Down
Loading