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

support DNS challenge for LE / ACME #120

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
env:
GOFLAGS: "-mod=vendor"
TZ: "America/Chicago"
DNS_CHALLENGE_TEST_ENABLED: "" # if true enables unittest for dns challenge against LE staging env

- name: install golangci-lint and goveralls
run: |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ docker-compose-private.yml
.vscode
.idea
*.gpg
.DS_Store
*.pem
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ In case if rules set as a part of docker compose environment, destination with t

SSL mode (by default none) can be set to `auto` (ACME/LE certificates), `static` (existing certificate) or `none`. If `auto` turned on SSL certificate will be issued automatically for all discovered server names. User can override it by setting `--ssl.fqdn` value(s)

### DNS Challenge

Reproxy supports automatic ACME DNS challenge. It checks whether the certificate is expiring or if it exists at all. If necessary reproxy initiate the DNS challenge, obtain or renew the certificates. It adds TXT record to a specified DNS provider and saves the LE certificate with a private key.
DNS Challenge can only be enabled for SSL mode `auto` (the flag`--ssl.type`)
DNS challenge is enabled by passing `--ssl.dns.enabled` flag. DNS provider is to specify with the flag `--ssl.dns-challenge.provider`. For full list of supported DNS providers: see [DNS Providers](app/acme/dnsprovider/README.md) section. Provider-specific parameters should be passed with environment variables. It is possible to specify DNS nameservers for record propagation check `--dns-challenge-resolvers`.


## Headers

Reproxy allows to sanitize (remove) incoming headers by passing `--drop-header` parameter (can be repeated). This parameter can be useful to make sure some of the headers, set internally by the services, can't be set/faked by the end user. For example if some of the services, responsible for the auth, sets `X-Auth-User` and `X-Auth-Token` it is likely makes sense to drop those headers from the incoming requests by passing `--drop-header=X-Auth-User --drop-header=X-Auth-Token` parameter or via environment `DROP_HEADERS=X-Auth-User,X-Auth-Token`
Expand Down Expand Up @@ -339,6 +346,7 @@ This is the list of all options supporting multiple elements:
- `static.rule` (`$STATIC_RULES`)
- `header` (`$HEADER`)
- `drop-header` (`$DROP_HEADERS`)
- `ssl.dns-challenge-resolvers` (`SSL_ACME_DNS_CHALLENGE_RESOLVERS`)

## All Application Options

Expand All @@ -354,13 +362,19 @@ This is the list of all options supporting multiple elements:
--dbg debug mode [$DEBUG]

ssl:
--ssl.type=[none|static|auto] ssl (auto) support (default: none) [$SSL_TYPE]
--ssl.cert= path to cert.pem file [$SSL_CERT]
--ssl.key= path to key.pem file [$SSL_KEY]
--ssl.acme-location= dir where certificates will be stored by autocert manager (default: ./var/acme) [$SSL_ACME_LOCATION]
--ssl.acme-email= admin email for certificate notifications [$SSL_ACME_EMAIL]
--ssl.http-port= http port for redirect to https and acme challenge test (default: 8080 under docker, 80 without) [$SSL_HTTP_PORT]
--ssl.fqdn= FQDN(s) for ACME certificates [$SSL_ACME_FQDN]
--ssl.type=[none|static|auto|dns] ssl (auto) support (default: none) [$SSL_TYPE]
--ssl.cert= path to cert.pem file [$SSL_CERT] (default: ./var/acme/cert.pem)
--ssl.key= path to key.pem file [$SSL_KEY] (default: ./var/acme/key.pem)
--ssl.acme-location= dir where certificates will be stored by autocert manager (default: ./var/acme) [$SSL_ACME_LOCATION]
--ssl.acme-email= admin email for certificate notifications [$SSL_ACME_EMAIL]
--ssl.http-port= http port for redirect to https and acme challenge test (default: 8080 under docker, 80 without) [$SSL_HTTP_PORT]
--ssl.fqdn= FQDN(s) for ACME certificates [$SSL_ACME_FQDN]
--ssl.dns-challenge-enabled enable dns challenge for ACME certificates [$SSL_ACME_DNS_CHALLENGE_ENABLED]
--ssl.dns-challenge-provider= dns challenge provider [$SSL_ACME_DNS_CHALLENGE_PROVIDER]
--ssl.dns-challenge-resolvers= dns resolvers for dns challenge (default: "google-public-dns-a.google.com", "google-public-dns-b.google.com",) [$SSL_ACME_DNS_CHALLENGE_RESOLVERS]
--ssl.dns-challenge-timeout= dns challenge timeout in seconds (default: 180) [$SSL_ACME_DNS_PROVIDER_TIMEOUT]
--ssl.dns-challenge-interval= dns challenge polling interval in seconds (default: 10) [$SSL_ACME_DNS_CHALLENGE_INTERVAL]
--ssl.dns-provider-config= path to dns provider config file [$SSL_ACME_DNS_PROVIDER_CONFIG]

assets:
-a, --assets.location= assets location [$ASSETS_LOCATION]
Expand Down
88 changes: 88 additions & 0 deletions app/acme/acme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package acme

import (
"context"
"time"

log "github.com/go-pkgz/lgr"
)

var (
attemptInterval = time.Minute * 1
maxAttemps = 5
)

// Solver is an interface for solving ACME DNS challenge
type Solver interface {
// PreSolve is called before solving the challenge. ACME Order will be created and DNS record will be added.
PreSolve() error

// Solve is called to accept the challenge and pull the certificate.
Solve() error

// ObtainCertificate is called to obtain the certificate.
// Certificate will be saved to the file path specified by flag.
ObtainCertificate() error
}

// ScheduleCertificateRenewal schedules certificate renewal
func ScheduleCertificateRenewal(ctx context.Context, solver Solver, certPath string) {
go func(certPath string) {
var nextAttemptAfter time.Duration

if expiredAt, err := getCertificateExpiration(certPath); err == nil {
nextAttemptAfter = time.Until(expiredAt.Add(time.Hour * 24 * -5))
log.Printf("[INFO] certificate will expire in %v, next attempt in %v", expiredAt, nextAttemptAfter)
}

attempted := 0
for {
select {
case <-ctx.Done():
return
case <-time.After(nextAttemptAfter):
}
attempted++

if attempted > maxAttemps {
log.Printf("[ERROR] maxium attempts (%d) reached, exiting", maxAttemps)
return
}
log.Printf("[INFO] renewing certificate attempt %d", attempted)

// create ACME order and add TXT record for the challenge
if err := solver.PreSolve(); err != nil {
nextAttemptAfter = time.Duration(attempted) * attemptInterval
log.Printf("[WARN] error during preparing ACME order: %v, next attempt in %v", err, nextAttemptAfter)
continue
}

// solve the challenge
log.Printf("[INFO] start solving ACME DNS challenge")
if err := solver.Solve(); err != nil {
nextAttemptAfter = time.Duration(attempted) * attemptInterval
log.Printf("[WARN] error during solving ACME DNS Challenge: %v, next attempt in %v", err, nextAttemptAfter)
continue
}

// obtain certificate
if err := solver.ObtainCertificate(); err != nil {
nextAttemptAfter = time.Duration(attempted) * attemptInterval
log.Printf("[WARN] error during certificate obtaining: %v, next attempt in %v", err, nextAttemptAfter)
continue
}

expiredAt, err := getCertificateExpiration(certPath)
if err == nil {
// 5 days earlier than the certificate expiration
nextAttemptAfter = time.Until(expiredAt.Add(time.Hour * 24 * -5))
log.Printf("[INFO] certificate will expire in %v, next attempt in %v", expiredAt, nextAttemptAfter)
attempted = 0
continue
}

log.Printf("[WARN] certificate expiration date, probably not obtained yet: %v", err)
nextAttemptAfter = time.Duration(attempted) * attemptInterval
}
}(certPath)
}
155 changes: 155 additions & 0 deletions app/acme/acme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package acme

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

const certPath = "./TestScheduleCertificateRenewal.pem"

type mockSolver struct {
domain string
expires time.Time
preSolvedCalled int
solveCalled int
obtainCertCalled int
}

func (s *mockSolver) PreSolve() error {
s.preSolvedCalled++
switch s.domain {
case "mycompany1.com":
return fmt.Errorf("preSolve failed")
}
return nil
}

func (s *mockSolver) Solve() error {
s.solveCalled++
switch s.domain {
case "mycompany2.com":
return fmt.Errorf("postSolved failed")
}
return nil
}

func (s *mockSolver) ObtainCertificate() error {
s.obtainCertCalled++
switch s.domain {
case "mycompany3.com":
return fmt.Errorf("obtainCertificate failed")
case "mycompany5.com":
return nil
default:
return createCert(time.Now().Add(time.Hour*24*365), s.domain)
}
}
func TestScheduleCertificateRenewal(t *testing.T) {
testMaxAttemps := 10
maxAttemps = testMaxAttemps

attemptInterval = time.Microsecond * 10

type args struct {
domain string
certExistedBefore bool
expiryTime time.Time
}

type expected struct {
preSolvedCalled int
solveCalled int
obtainCertCalled int
}

tests := []struct {
name string
args args
expected expected
}{
{"certificate not existed before",
args{"example.com", false, time.Time{}},
expected{1, 1, 1}},
{"presolve always fails",
args{"mycompany1.com", false, time.Time{}},
expected{testMaxAttemps, 0, 0}},
{"solve always fails",
args{"mycompany2.com", false, time.Time{}},
expected{testMaxAttemps, testMaxAttemps, 0}},
{"obtain cert failed",
args{"mycompany3.com", false, time.Time{}},
expected{maxAttemps, maxAttemps, maxAttemps}},
{"certificate valid for a long time",
args{"mycompany4.com", true, time.Now().Add(time.Hour * 100 * 24)},
expected{0, 0, 0}},
{"obtain cert success, but file not created",
args{"mycompany5.com", false, time.Time{}},
expected{maxAttemps, maxAttemps, maxAttemps}},
}

for _, tt := range tests {
if tt.args.certExistedBefore {
if err := createCert(tt.args.expiryTime, tt.args.domain); err != nil {
t.Fatal(err)
}
}

s := &mockSolver{
domain: tt.args.domain,
expires: tt.args.expiryTime,
}

ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
ScheduleCertificateRenewal(ctx, s, certPath)
time.Sleep(time.Second * 2)

assert.Equal(t, tt.expected.preSolvedCalled, s.preSolvedCalled, fmt.Sprintf("[case %s] preSolvedCalled not match", tt.name))
assert.Equal(t, tt.expected.solveCalled, s.solveCalled, fmt.Sprintf("[case %s] solveCalled not match", tt.name))
assert.Equal(t, tt.expected.obtainCertCalled, s.obtainCertCalled, fmt.Sprintf("[case %s] postSolvedCalled not match", tt.name))

os.Remove(certPath)
cancel()
}
}

func createCert(expireAt time.Time, domain string) error {
priv, _ := rsa.GenerateKey(rand.Reader, 2048)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Acme Co"},
},
NotBefore: time.Now(),
NotAfter: expireAt,

KeyUsage: x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{domain},
}
// write cert to file
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
return err
}
certFile, err := os.Create(certPath)
if err != nil {
return err
}

if _, err := certFile.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})); err != nil {
return err
}
return certFile.Close()
}
Loading