add ability to relay http-01 challenges

This commit is contained in:
AJ ONeal 2020-11-05 02:11:17 -07:00
parent d433b987cb
commit 1d71b24ccf
15 changed files with 410 additions and 141 deletions

View File

@ -61,6 +61,7 @@ ACME_AGREE=true
ACME_EMAIL=letsencrypt@example.com
# For Let's Encrypt / ACME challenges
ACME_HTTP_01_RELAY_URL=http://localhost:3010/api/http
ACME_RELAY_URL=http://localhost:3010/api/dns
SECRET=xxxxxxxxxxxxxxxx
GODADDY_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

View File

@ -5,9 +5,12 @@
"method": "GET",
"pathname": ""
},
"acme_dns_01_proxy": {
"_todo_acme_dns_01_proxy": {
"pathname": "dns"
},
"acme_http_01_proxy": {
"pathname": "http"
},
"pair_request": {
"method": "POST",
"pathname": "telebit.app/pair_request"

View File

@ -10,7 +10,7 @@ import (
"os"
"strings"
dns01 "git.rootprojects.org/root/telebit/dns01"
"git.rootprojects.org/root/telebit/internal/dns01"
jwt "github.com/dgrijalva/jwt-go"
"github.com/go-acme/lego/v3/challenge"

View File

@ -2,22 +2,40 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-acme/lego/v3/challenge"
"github.com/go-chi/chi"
)
// A Challenge has the data necessary to create an ACME DNS-01 Key Authorization Digest.
const (
tmpBase = "acme-tmp"
challengeDir = ".well-known/acme-challenge"
)
// Challenge is an ACME http-01 challenge
type Challenge struct {
Domain string `json:"domain"`
Type string `json:"type"`
Token string `json:"token"`
KeyAuth string `json:"key_authorization"`
Identifier Identifier `json:"identifier"`
// for the old one
Domain string `json:"domain"`
error chan error
}
// Identifier is restricted to DNS Domain Names for now
type Identifier struct {
Type string `json:"type"`
Value string `json:"value"`
}
type acmeProvider struct {
BaseURL string
provider challenge.Provider
@ -32,8 +50,19 @@ func (p *acmeProvider) CleanUp(domain, token, keyAuth string) error {
}
func handleDNSRoutes(r chi.Router) {
r.Route("/dns", func(r chi.Router) {
r.Post("/{domain}", func(w http.ResponseWriter, r *http.Request) {
handleACMEChallenges := func(r chi.Router) {
r.Post("/{domain}", createChallenge)
// TODO ugly Delete, but whatever
r.Delete("/{domain}/{token}/{keyAuth}", deleteChallenge)
r.Delete("/{domain}/{token}/{keyAuth}/{challengeType}", deleteChallenge)
}
r.Route("/dns", handleACMEChallenges)
r.Route("/http", handleACMEChallenges)
}
func createChallenge(w http.ResponseWriter, r *http.Request) {
domain := chi.URLParam(r, "domain")
ctx := r.Context()
@ -57,13 +86,24 @@ func handleDNSRoutes(r chi.Router) {
//domain := chi.URLParam(r, "*")
ch.Domain = domain
ch.Identifier.Value = domain
if "" == ch.Token || "" == ch.KeyAuth {
err = errors.New("missing token and/or key auth")
} else if strings.Contains(ch.Type, "http") {
challengeBase := filepath.Join(tmpBase, ch.Domain, ".well-known/acme-challenge")
_ = os.MkdirAll(challengeBase, 0700)
tokenPath := filepath.Join(challengeBase, ch.Token)
err = ioutil.WriteFile(tokenPath, []byte(ch.KeyAuth), 0600)
} else {
// TODO some additional error checking before the handoff
//ch.error = make(chan error, 1)
ch.error = make(chan error)
presenters <- &ch
err = <-ch.error
if nil != err || "" == ch.Token || "" == ch.KeyAuth {
}
if nil != err {
fmt.Println("presenter err", err, ch.Token, ch.KeyAuth)
msg := `{"error":"ACME dns-01 error", "code":"E_SERVER"}`
http.Error(w, msg, http.StatusUnprocessableEntity)
@ -71,10 +111,9 @@ func handleDNSRoutes(r chi.Router) {
}
w.Write([]byte("{\"success\":true}\n"))
})
}
// TODO ugly Delete, but whatever
r.Delete("/{domain}/{token}/{keyAuth}", func(w http.ResponseWriter, r *http.Request) {
func deleteChallenge(w http.ResponseWriter, r *http.Request) {
// TODO authenticate
ch := Challenge{
@ -85,15 +124,23 @@ func handleDNSRoutes(r chi.Router) {
//error: make(chan error, 1),
}
var err error
if "" == ch.Token || "" == ch.KeyAuth {
err = errors.New("missing token and/or key auth")
} else if strings.Contains(ch.Type, "http") {
// always try to remove, as there's no harm
tokenPath := filepath.Join(tmpBase, ch.Domain, challengeDir, ch.Token)
_ = os.Remove(tokenPath)
} else {
cleanups <- &ch
err := <-ch.error
if nil != err || "" == ch.Token || "" == ch.KeyAuth {
err = <-ch.error
}
if nil != err {
msg := `{"error":"expected json in the format {\"token\":\"xxx\",\"key_authorization\":\"yyy\"}", "code":"E_BAD_REQUEST"}`
http.Error(w, msg, http.StatusUnprocessableEntity)
return
}
w.Write([]byte("{\"success\":true}\n"))
})
})
}

View File

@ -27,6 +27,7 @@ var (
GitTimestamp = "0000-00-00T00:00:00+0000"
)
// MWKey is a type guard
type MWKey string
var store authstore.Store
@ -44,6 +45,7 @@ func main() {
addr := flag.String("address", "", "IPv4 or IPv6 bind address")
port := flag.String("port", "3000", "port to listen to")
challengesPort := flag.String("challenges-port", "80", "port to use to respond to .well-known/acme-challenge tokens")
dbURL := flag.String(
"db-url",
"postgres://postgres:postgres@localhost/postgres",
@ -101,6 +103,10 @@ func main() {
_ = store.SetMaster(secret)
defer store.Close()
go func() {
http.ListenAndServe(":"+challengesPort, routeStatic())
}()
bind := *addr + ":" + *port
fmt.Println("Listening on", bind)
fmt.Fprintf(os.Stderr, "failed: %s", http.ListenAndServe(bind, routeAll()))

View File

@ -7,6 +7,7 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@ -25,6 +26,29 @@ type MgmtClaims struct {
var presenters = make(chan *Challenge)
var cleanups = make(chan *Challenge)
func routeStatic() chi.Router {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Timeout(15 * time.Second))
r.Use(middleware.Recoverer)
r.Get("/.well-known/acme-challenge/{token}", func(w http.ResponseWriter, r *http.Request) {
//token := chi.URLParam(r, "token")
host := r.Header.Get("Host")
if strings.ContainsAny(host, "/:|\\") {
host = ""
}
tokenPath := filepath.Join(tmpBase, host)
fsrv := http.FileServer(http.Dir(tokenPath))
fsrv.ServeHTTP(w, r)
})
return r
}
func routeAll() chi.Router {
go func() {

View File

@ -7,7 +7,6 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
@ -21,14 +20,14 @@ import (
telebit "git.rootprojects.org/root/telebit"
"git.rootprojects.org/root/telebit/dbg"
tbDns01 "git.rootprojects.org/root/telebit/dns01"
tbDns01 "git.rootprojects.org/root/telebit/internal/dns01"
"git.rootprojects.org/root/telebit/internal/http01"
"git.rootprojects.org/root/telebit/iplist"
"git.rootprojects.org/root/telebit/mgmt"
"git.rootprojects.org/root/telebit/mgmt/authstore"
"git.rootprojects.org/root/telebit/table"
"git.rootprojects.org/root/telebit/tunnel"
legoDns01 "github.com/go-acme/lego/v3/challenge/dns01"
"github.com/mholt/acmez/acme"
"github.com/coolaj86/certmagic"
"github.com/denisbrodbeck/machineid"
@ -81,36 +80,6 @@ var VendorID string
// ClientSecret may be baked in, or supplied via ENVs or --args
var ClientSecret string
type legoWrapper struct {
provider challenge.Provider
//option legoDns01.ChallengeOption
dnsSolver certmagic.DNS01Solver
}
func (lw *legoWrapper) Present(ctx context.Context, ch acme.Challenge) error {
return lw.provider.Present(ch.Identifier.Value, ch.Token, ch.KeyAuthorization)
}
func (lw *legoWrapper) CleanUp(ctx context.Context, ch acme.Challenge) error {
c := make(chan error)
go func() {
c <- lw.provider.CleanUp(ch.Identifier.Value, ch.Token, ch.KeyAuthorization)
}()
select {
case err := <-c:
return err
case <-ctx.Done():
return errors.New("cancelled")
}
}
// Wait blocks until the TXT record created in Present() appears in
// authoritative lookups, i.e. until it has propagated, or until
// timeout, whichever is first.
func (lw *legoWrapper) Wait(ctx context.Context, challenge acme.Challenge) error {
return lw.dnsSolver.Wait(ctx, challenge)
}
func main() {
var domains []string
var forwards []Forward
@ -128,6 +97,7 @@ func main() {
enableHTTP01 := flag.Bool("acme-http-01", false, "enable HTTP-01 ACME challenges")
enableTLSALPN01 := flag.Bool("acme-tls-alpn-01", false, "enable TLS-ALPN-01 ACME challenges")
acmeRelay := flag.String("acme-relay-url", "", "the base url of the ACME DNS-01 relay, if not the same as the tunnel relay")
acmeHTTP01Relay := flag.String("acme-http-01-relay-url", "", "the base url of the ACME HTTP-01 relay, if not the same as the DNS-01 relay")
var dnsPropagationDelay time.Duration
flag.DurationVar(&dnsPropagationDelay, "dns-01-delay", 0, "add an extra delay after dns self-check to allow DNS-01 challenges to propagate")
resolverList := flag.String("dns-resolvers", "", "a list of resolvers in the format 8.8.8.8:53,8.8.4.4:53")
@ -181,6 +151,9 @@ func main() {
if 0 == len(*acmeRelay) {
*acmeRelay = os.Getenv("ACME_RELAY_URL")
}
if 0 == len(*acmeHTTP01Relay) {
*acmeHTTP01Relay = os.Getenv("ACME_HTTP_01_RELAY_URL")
}
if 0 == len(*email) {
*email = os.Getenv("ACME_EMAIL")
@ -428,6 +401,8 @@ func main() {
}
authorizer = NewAuthorizer(*authURL)
var dns01Solver *tbDns01.Solver
if len(*acmeRelay) > 0 {
provider, err := getACMEProvider(acmeRelay, token)
if nil != err {
fmt.Fprintf(os.Stderr, "%s\n", err)
@ -436,14 +411,31 @@ func main() {
os.Exit(exitBadArguments)
return
}
dns01Solver = tbDns01.NewSolver(provider)
}
var http01Solver *http01.Solver
if len(*acmeHTTP01Relay) > 0 {
endpoint, err := url.Parse(*acmeHTTP01Relay)
if nil != err {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(exitBadArguments)
return
}
http01Solver, err = http01.NewSolver(&http01.Config{
Endpoint: endpoint,
Token: *token,
})
}
fmt.Printf("Email: %q\n", *email)
acme := &telebit.ACME{
Email: *email,
StoragePath: *certpath,
Agree: *acmeAgree,
Directory: *acmeDirectory,
DNS01Solver: &legoWrapper{
provider: provider,
DNS01Solver: dns01Solver,
/*
options: legoDns01.WrapPreCheck(func(domain, fqdn, value string, orig legoDns01.PreCheckFunc) (bool, error) {
ok, err := orig(fqdn, value)
@ -454,8 +446,7 @@ func main() {
return ok, err
}),
*/
dnsSolver: certmagic.DNS01Solver{},
},
HTTP01Solver: http01Solver,
//DNSChallengeOption: legoDns01.DNSProviderOption,
EnableHTTPChallenge: *enableHTTP01,
EnableTLSALPNChallenge: *enableTLSALPN01,

View File

@ -12,16 +12,14 @@ import (
"os"
"strings"
"sync"
"time"
telebit "git.rootprojects.org/root/telebit"
tbDns01 "git.rootprojects.org/root/telebit/dns01"
tbDns01 "git.rootprojects.org/root/telebit/internal/dns01"
"git.rootprojects.org/root/telebit/table"
"github.com/coolaj86/certmagic"
"github.com/dgrijalva/jwt-go"
"github.com/go-acme/lego/v3/challenge"
legoDns01 "github.com/go-acme/lego/v3/challenge/dns01"
"github.com/go-acme/lego/v3/providers/dns/duckdns"
"github.com/go-acme/lego/v3/providers/dns/godaddy"
"github.com/go-chi/chi"
@ -89,7 +87,8 @@ func main() {
StoragePath: *certpath,
Agree: *acmeAgree,
Directory: *acmeDirectory,
DNSProvider: provider,
DNS01Solver: tbDns01.NewSolver(provider),
/*
//DNSChallengeOption: legoDns01.DNSProviderOption,
DNSChallengeOption: legoDns01.WrapPreCheck(func(domain, fqdn, value string, orig legoDns01.PreCheckFunc) (bool, error) {
ok, err := orig(fqdn, value)
@ -99,6 +98,7 @@ func main() {
}
return ok, err
}),
*/
EnableHTTPChallenge: *enableHTTP01,
EnableTLSALPNChallenge: *enableTLSALPN01,
}

View File

@ -2,5 +2,6 @@ CLIENT_SUBJECT=newbie
TUNNEL_RELAY_URL=https://devices.example.com/
CLIENT_SECRET=xxxxxxxxxxxxxxxx
LOCALS=https:$CLIENT_SUBJECT.devices.example.com:3000,https:*.$CLIENT_SUBJECT.devices.example.com:3000
#ACME_HTTP_01_RELAY_URL=http://localhost:4200/api/http
#PORT_FORWARDS=3443:3001,8443:3002
#DUCKDNS_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

View File

@ -5,6 +5,7 @@ package dns01
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@ -14,8 +15,11 @@ import (
"path"
"time"
"github.com/coolaj86/certmagic"
"github.com/go-acme/lego/v3/challenge"
"github.com/go-acme/lego/v3/challenge/dns01"
"github.com/go-acme/lego/v3/platform/config/env"
"github.com/mholt/acmez/acme"
)
// Environment variables names.
@ -180,3 +184,44 @@ func (d *DNSProvider) doRequest(method, uri string, msg interface{}) error {
return nil
}
// NewSolver creates a new Solver
func NewSolver(provider challenge.Provider) (lego *Solver) {
return &Solver{
provider: provider,
dnsChecker: certmagic.DNS01Solver{},
}
}
// Solver wraps a Lego DNS Provider for CertMagic
type Solver struct {
provider challenge.Provider
//option legoDns01.ChallengeOption
dnsChecker certmagic.DNS01Solver
}
// Present creates a DNS-01 Challenge Token
func (s *Solver) Present(ctx context.Context, ch acme.Challenge) error {
return s.provider.Present(ch.Identifier.Value, ch.Token, ch.KeyAuthorization)
}
// CleanUp deletes a DNS-01 Challenge Token
func (s *Solver) CleanUp(ctx context.Context, ch acme.Challenge) error {
c := make(chan error)
go func() {
c <- s.provider.CleanUp(ch.Identifier.Value, ch.Token, ch.KeyAuthorization)
}()
select {
case err := <-c:
return err
case <-ctx.Done():
return errors.New("cancelled")
}
}
// Wait blocks until the TXT record created in Present() appears in
// authoritative lookups, i.e. until it has propagated, or until
// timeout, whichever is first.
func (s *Solver) Wait(ctx context.Context, challenge acme.Challenge) error {
return s.dnsChecker.Wait(ctx, challenge)
}

142
internal/http01/http01.go Normal file
View File

@ -0,0 +1,142 @@
package http01
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"path"
"time"
"github.com/mholt/acmez/acme"
)
// Config is used to configure the creation of the HTTP-01 Solver.
type Config struct {
Endpoint *url.URL
Token string
HTTPClient *http.Client
}
// Solver implements the challenge.Provider interface.
type Solver struct {
config *Config
}
// Challenge is an ACME http-01 challenge
type Challenge struct {
Type string `json:"type"`
Token string `json:"token"`
KeyAuthorization string `json:"key_authorization"`
Identifier Identifier `json:"identifier"`
}
// Identifier is restricted to DNS Domain Names for now
type Identifier struct {
Type string `json:"type"`
Value string `json:"value"`
}
// NewSolver return a new HTTP-01 Solver.
func NewSolver(config *Config) (*Solver, error) {
if config == nil {
return nil, errors.New("api: the configuration of the DNS provider is nil")
}
if config.Endpoint == nil {
return nil, errors.New("api: the endpoint is missing")
}
if nil == config.HTTPClient {
config.HTTPClient = &http.Client{
Timeout: 5 * time.Second,
}
}
return &Solver{config: config}, nil
}
// Present creates a DNS-01 Challenge Token
func (s *Solver) Present(ctx context.Context, ch acme.Challenge) error {
msg := &Challenge{
Type: "http-01",
Token: ch.Token,
KeyAuthorization: ch.KeyAuthorization,
Identifier: Identifier{
Type: ch.Identifier.Type,
Value: ch.Identifier.Value,
},
}
err := s.doRequest(http.MethodPost, fmt.Sprintf("/%s", msg.Identifier.Value), msg)
if err != nil {
return fmt.Errorf("api: %w", err)
}
return nil
}
// CleanUp deletes an HTTP-01 Challenge Token
func (s *Solver) CleanUp(ctx context.Context, ch acme.Challenge) error {
msg := &Challenge{
Type: "http-01",
Token: ch.Token,
KeyAuthorization: ch.KeyAuthorization,
Identifier: Identifier{
Type: ch.Identifier.Type,
Value: ch.Identifier.Value,
},
}
err := s.doRequest(
http.MethodDelete,
fmt.Sprintf("/%s/%s/%s/%s", msg.Identifier.Value, msg.Token, msg.KeyAuthorization, msg.Type),
nil,
)
if err != nil {
return fmt.Errorf("api: %w", err)
}
return nil
}
func (s *Solver) doRequest(method, uri string, msg interface{}) error {
data, _ := json.MarshalIndent(msg, "", " ")
reqBody := bytes.NewBuffer(data)
newURI := path.Join(s.config.Endpoint.EscapedPath(), uri)
endpoint, err := s.config.Endpoint.Parse(newURI)
if err != nil {
return err
}
req, err := http.NewRequest(method, endpoint.String(), reqBody)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if len(s.config.Token) > 0 {
req.Header.Set("Authorization", "Bearer "+s.config.Token)
}
resp, err := s.config.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%d: failed to read response body: %w", resp.StatusCode, err)
}
return fmt.Errorf("%d: request failed: %v", resp.StatusCode, string(body))
}
return nil
}

View File

@ -1,9 +1,10 @@
// +build tools
// tools is a faux package for tracking dependencies that don't make it into the code
// Package tools is a faux package for tracking dependencies that don't make it into the code
package tools
import (
// these are binaries
_ "git.rootprojects.org/root/go-gitver"
_ "github.com/shurcooL/vfsgen"
_ "github.com/shurcooL/vfsgen/cmd/vfsgendev"

View File

@ -18,7 +18,9 @@ type Endpoints struct {
APIHost string `json:"api_host"`
Tunnel Endpoint `json:"tunnel"`
Authenticate Endpoint `json:"authn"`
ChallengeProxy Endpoint `json:"acme_challenge_proxy"`
DNS01Proxy Endpoint `json:"acme_dns_01_proxy"`
HTTP01Proxy Endpoint `json:"acme_http_01_proxy"`
/*
{
"terms_of_service": ":hostname/tos/",
@ -77,7 +79,13 @@ func Discover(relay string) (*Endpoints, error) {
}
directives.Authenticate.URL = endpointToURLString(directives.APIHost, directives.Authenticate)
if len(directives.ChallengeProxy.Pathname) > 0 {
directives.ChallengeProxy.URL = endpointToURLString(directives.APIHost, directives.ChallengeProxy)
}
directives.DNS01Proxy.URL = endpointToURLString(directives.APIHost, directives.DNS01Proxy)
if len(directives.HTTP01Proxy.Pathname) > 0 {
directives.HTTP01Proxy.URL = endpointToURLString(directives.APIHost, directives.HTTP01Proxy)
}
return directives, nil
}