| // Copyright 2019 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // The acmeprober program runs against an actual ACME CA implementation. |
| // It spins up an HTTP server to fulfill authorization challenges |
| // or execute a DNS script to provision a response to dns-01 challenge. |
| // |
| // For http-01 and tls-alpn-01 challenge types this requires the ACME CA |
| // to be able to reach the HTTP server. |
| // |
| // A usage example: |
| // |
| // go run prober.go \ |
| // -d https://acme-staging-v02.api.letsencrypt.org/directory \ |
| // -f order \ |
| // -t http-01 \ |
| // -a :8080 \ |
| // -domain some.example.org |
| // |
| // The above assumes a TCP tunnel from some.example.org:80 to 0.0.0.0:8080 |
| // in order for the test to be able to fulfill http-01 challenge. |
| // To test tls-alpn-01 challenge, 443 port would need to be tunneled |
| // to 0.0.0.0:8080. |
| // When running with dns-01 challenge type, use -s argument instead of -a. |
| package main |
| |
| import ( |
| "context" |
| "crypto" |
| "crypto/ecdsa" |
| "crypto/elliptic" |
| "crypto/rand" |
| "crypto/tls" |
| "crypto/x509" |
| "encoding/pem" |
| "errors" |
| "flag" |
| "fmt" |
| "log" |
| "net" |
| "net/http" |
| "os" |
| "os/exec" |
| "strings" |
| "time" |
| |
| "golang.org/x/crypto/acme" |
| ) |
| |
| var ( |
| // ACME CA directory URL. |
| // Let's Encrypt v2 prod: https://acme-v02.api.letsencrypt.org/directory |
| // Let's Encrypt v2 staging: https://acme-staging-v02.api.letsencrypt.org/directory |
| // See the following for more CAs implementing ACME protocol: |
| // https://en.wikipedia.org/wiki/Automated_Certificate_Management_Environment#CAs_&_PKIs_that_offer_ACME_certificates |
| directory = flag.String("d", "", "ACME directory URL.") |
| reginfo = flag.String("r", "", "ACME account registration info.") |
| flow = flag.String("f", "", `Flow to run: "order" or "preauthz" (RFC8555).`) |
| chaltyp = flag.String("t", "", "Challenge type: tls-alpn-01, http-01 or dns-01.") |
| addr = flag.String("a", "", "Local server address for tls-alpn-01 and http-01.") |
| dnsscript = flag.String("s", "", "Script to run for provisioning dns-01 challenges.") |
| domain = flag.String("domain", "", "Space separate domain identifiers.") |
| ipaddr = flag.String("ip", "", "Space separate IP address identifiers.") |
| ) |
| |
| func main() { |
| flag.Usage = func() { |
| fmt.Fprintln(flag.CommandLine.Output(), ` |
| The prober program runs against an actual ACME CA implementation. |
| It spins up an HTTP server to fulfill authorization challenges |
| or execute a DNS script to provision a response to dns-01 challenge. |
| |
| For http-01 and tls-alpn-01 challenge types this requires the ACME CA |
| to be able to reach the HTTP server. |
| |
| A usage example: |
| |
| go run prober.go \ |
| -d https://acme-staging-v02.api.letsencrypt.org/directory \ |
| -f order \ |
| -t http-01 \ |
| -a :8080 \ |
| -domain some.example.org |
| |
| The above assumes a TCP tunnel from some.example.org:80 to 0.0.0.0:8080 |
| in order for the test to be able to fulfill http-01 challenge. |
| To test tls-alpn-01 challenge, 443 port would need to be tunneled |
| to 0.0.0.0:8080. |
| When running with dns-01 challenge type, use -s argument instead of -a. |
| `) |
| flag.PrintDefaults() |
| } |
| flag.Parse() |
| |
| identifiers := acme.DomainIDs(strings.Fields(*domain)...) |
| identifiers = append(identifiers, acme.IPIDs(strings.Fields(*ipaddr)...)...) |
| if len(identifiers) == 0 { |
| log.Fatal("at least one domain or IP addr identifier is required") |
| } |
| |
| // Duration of the whole run. |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) |
| defer cancel() |
| |
| // Create and register a new account. |
| akey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| if err != nil { |
| log.Fatal(err) |
| } |
| cl := &acme.Client{Key: akey, DirectoryURL: *directory} |
| a := &acme.Account{Contact: strings.Fields(*reginfo)} |
| if _, err := cl.Register(ctx, a, acme.AcceptTOS); err != nil { |
| log.Fatalf("Register: %v", err) |
| } |
| |
| // Run the desired flow test. |
| p := &prober{ |
| client: cl, |
| chalType: *chaltyp, |
| localAddr: *addr, |
| dnsScript: *dnsscript, |
| } |
| switch *flow { |
| case "order": |
| p.runOrder(ctx, identifiers) |
| case "preauthz": |
| p.runPreauthz(ctx, identifiers) |
| default: |
| log.Fatalf("unknown flow: %q", *flow) |
| } |
| if len(p.errors) > 0 { |
| os.Exit(1) |
| } |
| } |
| |
| type prober struct { |
| client *acme.Client |
| chalType string |
| localAddr string |
| dnsScript string |
| |
| errors []error |
| } |
| |
| func (p *prober) errorf(format string, a ...interface{}) { |
| err := fmt.Errorf(format, a...) |
| log.Print(err) |
| p.errors = append(p.errors, err) |
| } |
| |
| func (p *prober) runOrder(ctx context.Context, identifiers []acme.AuthzID) { |
| // Create a new order and pick a challenge. |
| // Note that Let's Encrypt will reply with 400 error:malformed |
| // "NotBefore and NotAfter are not supported" when providing a NotAfter |
| // value like WithOrderNotAfter(time.Now().Add(24 * time.Hour)). |
| o, err := p.client.AuthorizeOrder(ctx, identifiers) |
| if err != nil { |
| log.Fatalf("AuthorizeOrder: %v", err) |
| } |
| |
| var zurls []string |
| for _, u := range o.AuthzURLs { |
| z, err := p.client.GetAuthorization(ctx, u) |
| if err != nil { |
| log.Fatalf("GetAuthorization(%q): %v", u, err) |
| } |
| log.Printf("%+v", z) |
| if z.Status != acme.StatusPending { |
| log.Printf("authz status is %q; skipping", z.Status) |
| continue |
| } |
| if err := p.fulfill(ctx, z); err != nil { |
| log.Fatalf("fulfill(%s): %v", z.URI, err) |
| } |
| zurls = append(zurls, z.URI) |
| log.Printf("authorized for %+v", z.Identifier) |
| } |
| |
| log.Print("all challenges are done") |
| if _, err := p.client.WaitOrder(ctx, o.URI); err != nil { |
| log.Fatalf("WaitOrder(%q): %v", o.URI, err) |
| } |
| csr, certkey := newCSR(identifiers) |
| der, curl, err := p.client.CreateOrderCert(ctx, o.FinalizeURL, csr, true) |
| if err != nil { |
| log.Fatalf("CreateOrderCert: %v", err) |
| } |
| log.Printf("cert URL: %s", curl) |
| if err := checkCert(der, identifiers); err != nil { |
| p.errorf("invalid cert: %v", err) |
| } |
| |
| // Deactivate all authorizations we satisfied earlier. |
| for _, v := range zurls { |
| if err := p.client.RevokeAuthorization(ctx, v); err != nil { |
| p.errorf("RevokAuthorization(%q): %v", v, err) |
| continue |
| } |
| } |
| // Deactivate the account. We don't need it for any further calls. |
| if err := p.client.DeactivateReg(ctx); err != nil { |
| p.errorf("DeactivateReg: %v", err) |
| } |
| // Try revoking the issued cert using its private key. |
| if err := p.client.RevokeCert(ctx, certkey, der[0], acme.CRLReasonCessationOfOperation); err != nil { |
| p.errorf("RevokeCert: %v", err) |
| } |
| } |
| |
| func (p *prober) runPreauthz(ctx context.Context, identifiers []acme.AuthzID) { |
| dir, err := p.client.Discover(ctx) |
| if err != nil { |
| log.Fatalf("Discover: %v", err) |
| } |
| if dir.AuthzURL == "" { |
| log.Fatal("CA does not support pre-authorization") |
| } |
| |
| var zurls []string |
| for _, id := range identifiers { |
| z, err := authorize(ctx, p.client, id) |
| if err != nil { |
| log.Fatalf("AuthorizeID(%+v): %v", z, err) |
| } |
| if z.Status == acme.StatusValid { |
| log.Printf("authz %s is valid; skipping", z.URI) |
| continue |
| } |
| if err := p.fulfill(ctx, z); err != nil { |
| log.Fatalf("fulfill(%s): %v", z.URI, err) |
| } |
| zurls = append(zurls, z.URI) |
| log.Printf("authorized for %+v", id) |
| } |
| |
| // We should be all set now. |
| // Expect all authorizations to be satisfied. |
| log.Print("all challenges are done") |
| o, err := p.client.AuthorizeOrder(ctx, identifiers) |
| if err != nil { |
| log.Fatalf("AuthorizeOrder: %v", err) |
| } |
| waitCtx, cancel := context.WithTimeout(ctx, time.Minute) |
| defer cancel() |
| if _, err := p.client.WaitOrder(waitCtx, o.URI); err != nil { |
| log.Fatalf("WaitOrder(%q): %v", o.URI, err) |
| } |
| csr, certkey := newCSR(identifiers) |
| der, curl, err := p.client.CreateOrderCert(ctx, o.FinalizeURL, csr, true) |
| if err != nil { |
| log.Fatalf("CreateOrderCert: %v", err) |
| } |
| log.Printf("cert URL: %s", curl) |
| if err := checkCert(der, identifiers); err != nil { |
| p.errorf("invalid cert: %v", err) |
| } |
| |
| // Deactivate all authorizations we satisfied earlier. |
| for _, v := range zurls { |
| if err := p.client.RevokeAuthorization(ctx, v); err != nil { |
| p.errorf("RevokeAuthorization(%q): %v", v, err) |
| continue |
| } |
| } |
| // Deactivate the account. We don't need it for any further calls. |
| if err := p.client.DeactivateReg(ctx); err != nil { |
| p.errorf("DeactivateReg: %v", err) |
| } |
| // Try revoking the issued cert using its private key. |
| if err := p.client.RevokeCert(ctx, certkey, der[0], acme.CRLReasonCessationOfOperation); err != nil { |
| p.errorf("RevokeCert: %v", err) |
| } |
| } |
| |
| func (p *prober) fulfill(ctx context.Context, z *acme.Authorization) error { |
| var chal *acme.Challenge |
| for i, c := range z.Challenges { |
| log.Printf("challenge %d: %+v", i, c) |
| if c.Type == p.chalType { |
| log.Printf("picked %s for authz %s", c.URI, z.URI) |
| chal = c |
| } |
| } |
| if chal == nil { |
| return fmt.Errorf("challenge type %q wasn't offered for authz %s", p.chalType, z.URI) |
| } |
| |
| switch chal.Type { |
| case "tls-alpn-01": |
| return p.runTLSALPN01(ctx, z, chal) |
| case "http-01": |
| return p.runHTTP01(ctx, z, chal) |
| case "dns-01": |
| return p.runDNS01(ctx, z, chal) |
| default: |
| return fmt.Errorf("unknown challenge type %q", chal.Type) |
| } |
| } |
| |
| func (p *prober) runTLSALPN01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error { |
| tokenCert, err := p.client.TLSALPN01ChallengeCert(chal.Token, z.Identifier.Value) |
| if err != nil { |
| return fmt.Errorf("TLSALPN01ChallengeCert: %v", err) |
| } |
| s := &http.Server{ |
| Addr: p.localAddr, |
| TLSConfig: &tls.Config{ |
| NextProtos: []string{acme.ALPNProto}, |
| GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| log.Printf("hello: %+v", hello) |
| return &tokenCert, nil |
| }, |
| }, |
| Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| log.Printf("%s %s", r.Method, r.URL) |
| w.WriteHeader(http.StatusNotFound) |
| }), |
| } |
| go s.ListenAndServeTLS("", "") |
| defer s.Close() |
| |
| if _, err := p.client.Accept(ctx, chal); err != nil { |
| return fmt.Errorf("Accept(%q): %v", chal.URI, err) |
| } |
| _, zerr := p.client.WaitAuthorization(ctx, z.URI) |
| return zerr |
| } |
| |
| func (p *prober) runHTTP01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error { |
| body, err := p.client.HTTP01ChallengeResponse(chal.Token) |
| if err != nil { |
| return fmt.Errorf("HTTP01ChallengeResponse: %v", err) |
| } |
| s := &http.Server{ |
| Addr: p.localAddr, |
| Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| log.Printf("%s %s", r.Method, r.URL) |
| if r.URL.Path != p.client.HTTP01ChallengePath(chal.Token) { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| w.Write([]byte(body)) |
| }), |
| } |
| go s.ListenAndServe() |
| defer s.Close() |
| |
| if _, err := p.client.Accept(ctx, chal); err != nil { |
| return fmt.Errorf("Accept(%q): %v", chal.URI, err) |
| } |
| _, zerr := p.client.WaitAuthorization(ctx, z.URI) |
| return zerr |
| } |
| |
| func (p *prober) runDNS01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error { |
| token, err := p.client.DNS01ChallengeRecord(chal.Token) |
| if err != nil { |
| return fmt.Errorf("DNS01ChallengeRecord: %v", err) |
| } |
| |
| name := fmt.Sprintf("_acme-challenge.%s", z.Identifier.Value) |
| cmd := exec.CommandContext(ctx, p.dnsScript, name, token) |
| cmd.Stdin = os.Stdin |
| cmd.Stdout = os.Stdout |
| cmd.Stderr = os.Stderr |
| if err := cmd.Run(); err != nil { |
| return fmt.Errorf("%s: %v", p.dnsScript, err) |
| } |
| |
| if _, err := p.client.Accept(ctx, chal); err != nil { |
| return fmt.Errorf("Accept(%q): %v", chal.URI, err) |
| } |
| _, zerr := p.client.WaitAuthorization(ctx, z.URI) |
| return zerr |
| } |
| |
| func authorize(ctx context.Context, client *acme.Client, id acme.AuthzID) (*acme.Authorization, error) { |
| if id.Type == "ip" { |
| return client.AuthorizeIP(ctx, id.Value) |
| } |
| return client.Authorize(ctx, id.Value) |
| } |
| |
| func checkCert(derChain [][]byte, id []acme.AuthzID) error { |
| if len(derChain) == 0 { |
| return errors.New("cert chain is zero bytes") |
| } |
| for i, b := range derChain { |
| crt, err := x509.ParseCertificate(b) |
| if err != nil { |
| return fmt.Errorf("%d: ParseCertificate: %v", i, err) |
| } |
| log.Printf("%d: serial: 0x%s", i, crt.SerialNumber) |
| log.Printf("%d: subject: %s", i, crt.Subject) |
| log.Printf("%d: issuer: %s", i, crt.Issuer) |
| log.Printf("%d: expires in %.1f day(s)", i, time.Until(crt.NotAfter).Hours()/24) |
| if i > 0 { // not a leaf cert |
| continue |
| } |
| p := &pem.Block{Type: "CERTIFICATE", Bytes: b} |
| log.Printf("%d: leaf:\n%s", i, pem.EncodeToMemory(p)) |
| for _, v := range id { |
| if err := crt.VerifyHostname(v.Value); err != nil { |
| return err |
| } |
| } |
| } |
| return nil |
| } |
| |
| func newCSR(identifiers []acme.AuthzID) ([]byte, crypto.Signer) { |
| var csr x509.CertificateRequest |
| for _, id := range identifiers { |
| switch id.Type { |
| case "dns": |
| csr.DNSNames = append(csr.DNSNames, id.Value) |
| case "ip": |
| csr.IPAddresses = append(csr.IPAddresses, net.ParseIP(id.Value)) |
| default: |
| panic(fmt.Sprintf("newCSR: unknown identifier type %q", id.Type)) |
| } |
| } |
| k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| if err != nil { |
| panic(fmt.Sprintf("newCSR: ecdsa.GenerateKey for a cert: %v", err)) |
| } |
| b, err := x509.CreateCertificateRequest(rand.Reader, &csr, k) |
| if err != nil { |
| panic(fmt.Sprintf("newCSR: x509.CreateCertificateRequest: %v", err)) |
| } |
| return b, k |
| } |