Skip to content

Commit

Permalink
add dns challenge support
Browse files Browse the repository at this point in the history
  • Loading branch information
nbys committed Feb 4, 2022
1 parent cfe149c commit 5f9eb33
Show file tree
Hide file tree
Showing 14 changed files with 2,862 additions and 14 deletions.
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
25 changes: 18 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 is only enabled for SSL modes `auto` and `static` (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,16 @@ 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 (default: cloudflare) [$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]

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

import (
"context"
"fmt"
"log"
"time"

"github.com/go-pkgz/repeater"
)

var acmeOpTimeout = 5 * time.Minute

// 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(ctx context.Context) error
// Solve is called to present TXT record and accept challenge.
Solve(ctx context.Context) error
// PostSolve is called after obtaining the certificate.
PostSolve(ctx context.Context) error
// GetCertificateExpiration returns certificate expiration date
GetCertificateExpiration(certPath string) (time.Time, error)
}

// fqdns []string, provider string, nameservers []string

// ScheduleCertificateRenewal schedules certificate renewal
func ScheduleCertificateRenewal(solver Solver) {
certPath := getEnvOptionalString("SSL_CERT", "./var/acme/cert.pem")

go func(certPath string) {
var (
expiredAt time.Time
err error
)

dur := acmeOpTimeout >> 12
fmt.Println(dur.Milliseconds())

expiredAt, err = solver.GetCertificateExpiration(certPath)
if err != nil {
expiredAt = time.Now()
log.Printf("[INFO] failed to get certificate expiration date, probably not obtained yet: %v", err)
}

for {
<-time.After(time.Until(expiredAt.Add(time.Hour * 24 * -5)))

// add DNS record and wait for propagation
{
ctx, cancel := context.WithTimeout(context.Background(), acmeOpTimeout)
err = repeater.NewDefault(10, acmeOpTimeout>>12).Do(ctx, func() error {
if errc := solver.PreSolve(ctx); errc != nil {
log.Printf("[INFO] error in ACME DNS Challenge Presolve: %v", errc)
return errc
}
return nil
})
cancel()
if err != nil {
log.Printf("[ERROR] ACME DNS Challenge Presolve failed. Last error %v", err)
return
}
}

// present TXT record and accept challenge
{
ctx, cancel := context.WithTimeout(context.Background(), acmeOpTimeout)
err = repeater.NewDefault(10, acmeOpTimeout>>12).Do(ctx, func() error {
if errc := solver.Solve(ctx); errc != nil {
log.Printf("[INFO] error in ACME DNS Challenge Solve: %v", errc)
return errc
}
return nil
})
cancel()
if err != nil {
log.Printf("[ERROR] retry limit reached ACME DNS Challenge Solve failed. Last error: %v", err)
return
}
}

// pull the certificate
{
ctx, cancel := context.WithTimeout(context.Background(), acmeOpTimeout)
err = repeater.NewDefault(10, acmeOpTimeout>>12).Do(ctx, func() error {
if errc := solver.PostSolve(ctx); errc != nil {
log.Printf("[INFO] error in ACME DNS Challenge PostSolve: %v", errc)
return errc
}
return nil
})
cancel()
if err != nil {
log.Printf("[ERROR] retry limit reached, ACME DNS Challenge PostSolve failed. Last error: %v", err)
return
}
}

expiredAt, err = solver.GetCertificateExpiration(certPath)
if err != nil {
log.Printf("[ERROR] failed to get certificate expiration date: %v", err)
return
}
}
}(certPath)
}
110 changes: 110 additions & 0 deletions app/acme/acme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package acme

import (
"context"
"fmt"
"testing"
"time"

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

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

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

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

func (s *mockSolver) PostSolve(ctx context.Context) error {
s.postSolvedCalled++
switch s.domain {
case "mycompany3.com":
return fmt.Errorf("postSolved failed")
}
return nil
}

func (s *mockSolver) GetCertificateExpiration(certPath string) (time.Time, error) {
// check called before loop starts
if s.preSolvedCalled == 0 {
switch s.domain {
case "mycompany4.com":
return time.Now().Add(time.Hour * 24 * 670), nil
default:
return time.Time{}, fmt.Errorf("certificate does not exist")
}
}
return time.Now().Add(time.Hour * 24 * 365), nil
}

func TestScheduleCertificateRenewal(t *testing.T) {
acmeOpTimeout = 15 * time.Second

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

type expected struct {
preSolvedCalled int
solveCalled int
postSolvedCalled int
}

tests := []struct {
name string
args args
expected expected
}{
// {"certificate not existed before",
// args{"example.com", false, time.Now().Add(time.Hour * 100 * 24)},
// expected{1, 1, 1}},
// {"presolve failed",
// args{"mycompany1.com", false, time.Time{}},
// expected{10, 0, 0}},
// {"solve failed",
// args{"mycompany2.com", false, time.Time{}},
// expected{1, 10, 0}},
{"postsolve failed",
args{"mycompany3.com", false, time.Time{}},
expected{1, 1, 10}},
// {"certificate valid for a long time",
// args{"mycompany4.com", false, time.Time{}},
// expected{0, 0, 0}},
}

for _, tt := range tests {
s := &mockSolver{
domain: tt.args.domain,
expires: tt.args.expiryTime,
}

ScheduleCertificateRenewal(s)

time.Sleep(acmeOpTimeout)
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.postSolvedCalled, s.postSolvedCalled, fmt.Sprintf("[case %s] postSolvedCalled not match", tt.name))
}
}
Loading

0 comments on commit 5f9eb33

Please sign in to comment.