From 1007f6ac03a37bb1087d0e701ed5ba63c328a711 Mon Sep 17 00:00:00 2001 From: Nikolay Bystritskiy Date: Sun, 12 Dec 2021 09:48:27 +0100 Subject: [PATCH] added dns challenge logic --- .github/workflows/ci.yml | 1 + .gitignore | 1 + README.md | 23 +- app/acme/dns_challenge.go | 465 +++++++++++++++++++++ app/acme/dns_challenge_test.go | 457 ++++++++++++++++++++ app/acme/dnsprovider/README.md | 0 app/acme/dnsprovider/cloudns.go | 268 ++++++++++++ app/acme/dnsprovider/provider.go | 42 ++ app/acme/dnsprovider/provider_test.go | 1 + app/main.go | 24 +- go.mod | 4 +- go.sum | 4 + vendor/golang.org/x/crypto/acme/acme.go | 2 +- vendor/golang.org/x/crypto/acme/rfc8555.go | 26 ++ vendor/golang.org/x/net/idna/go118.go | 14 + vendor/golang.org/x/net/idna/idna10.0.0.go | 6 +- vendor/golang.org/x/net/idna/idna9.0.0.go | 4 +- vendor/golang.org/x/net/idna/pre_go118.go | 12 + vendor/golang.org/x/net/idna/punycode.go | 36 +- vendor/modules.txt | 4 +- 20 files changed, 1359 insertions(+), 35 deletions(-) create mode 100644 app/acme/dns_challenge.go create mode 100644 app/acme/dns_challenge_test.go create mode 100644 app/acme/dnsprovider/README.md create mode 100644 app/acme/dnsprovider/cloudns.go create mode 100644 app/acme/dnsprovider/provider.go create mode 100644 app/acme/dnsprovider/provider_test.go create mode 100644 vendor/golang.org/x/net/idna/go118.go create mode 100644 vendor/golang.org/x/net/idna/pre_go118.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26b407b9..a56c0054 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: env: GOFLAGS: "-mod=vendor" TZ: "America/Chicago" + DNS_CHALLENGE_TEST_ENABLED: "true" - name: install golangci-lint and goveralls run: | diff --git a/.gitignore b/.gitignore index 83f02e1f..c7824fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ docker-compose-private.yml .vscode .idea *.gpg +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 5d0cbe3d..2826c56f 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,12 @@ 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 +DNS challenge can be used to solve ACME/LE certificate issue. It is enabled by passing `--ssl.dns.enabled` flag. DNS provider is to specify with the flag `--ssl.dns-challenge.provider`. Provider specific parameters should be passed with environment variables. + +#### Providers +Full list of supported providers: see [DNS Providers](app/acme/dnsprovider/README.md) section. + ## 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` @@ -352,13 +358,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] 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.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: will be used available in enviroment /etc/resolv.conf) [$SSL_ACME_DNS_CHALLENGE_RESOLVERS] assets: -a, --assets.location= assets location [$ASSETS_LOCATION] diff --git a/app/acme/dns_challenge.go b/app/acme/dns_challenge.go new file mode 100644 index 00000000..e2fa55fe --- /dev/null +++ b/app/acme/dns_challenge.go @@ -0,0 +1,465 @@ +package acme + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "net" + "os" + "path/filepath" + "time" + + "github.com/umputun/reproxy/app/acme/dnsprovider" + "golang.org/x/crypto/acme" +) + +var defaultNameservers = []string{ + "google-public-dns-a.google.com", + "google-public-dns-b.google.com", +} + +var ( + acmeV2Enpoint = "https://acme-v02.api.letsencrypt.org/directory" +) + +// DNSChallenge represents an ACME DNS challenge +type DNSChallenge struct { + client *acme.Client + accountKey *rsa.PrivateKey + provider dnsprovider.Provider + nameservers []string +} + +// ScheduleCertificateRenewal schedules certificate renewal +func ScheduleCertificateRenewal(fqdns []string, provider string, nameservers []string) error { + log.Printf("Scheduling certificate renewal for %v", fqdns) + + DNSChallenge, err := NewDNSChallege(provider, nameservers) + if err != nil { + return err + } + + go func() { + err := DNSChallenge.solveDNSChallenge(fqdns) + if err != nil { + log.Printf("Failed to solve DNS challenge: %v", err) + return + } + + log.Printf("DNS challenge solved") + + for { + expiredAt, err := getCertExpiration() + if err != nil { + log.Printf("Failed to get certificate expiration: %v", err) + return + } + + <-time.After(expiredAt.Sub(expiredAt.Add(-168 * time.Hour))) + if err = DNSChallenge.solveDNSChallenge(fqdns); err != nil { + log.Printf("Failed to solve DNS challenge: %v", err) + } + } + }() + + return nil +} + +// NewDNSChallege creates new DNSChallenge +func NewDNSChallege(provider string, nameservers []string) (*DNSChallenge, error) { + p, err := dnsprovider.NewProvider(provider) + if err != nil { + return nil, err + } + + return &DNSChallenge{provider: p, nameservers: nameservers}, nil +} + +func (d *DNSChallenge) solveDNSChallenge(fqdns []string) error { + if err := d.register(); err != nil { + return err + } + + domains := make([]string, 0, len(fqdns)) + for _, fqdn := range fqdns { + domains = append(domains, removeTrainlingDot(fqdn)) + } + + order, records, err := d.prepareOrder(domains) + if err != nil { + return err + } + + err = d.waitDSNPropagation(records) + if err != nil { + return err + } + + err = d.acceptOrder(order, records) + if err != nil { + return err + } + + return d.pullCert(domains, order) +} + +func (d *DNSChallenge) register() error { + if d.client == nil { + var err error + d.accountKey, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + d.client = &acme.Client{ + DirectoryURL: acmeV2Enpoint, + Key: d.accountKey, + } + + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + if _, err := d.client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil { + return err + } + return nil +} + +func (d *DNSChallenge) acceptOrder(order *acme.Order, records []dnsprovider.Record) (err error) { + defer func() { + for _, r := range records { + if err := d.provider.RemoveRecord(r); err != nil { + log.Printf("cleanup failed to remove TXT record: %v", err) + } + } + }() + + for i := range order.AuthzURLs { + authz, err := d.client.GetAuthorization(context.Background(), order.AuthzURLs[i]) + if err != nil { + return err + } + if authz.Status != acme.StatusPending { + continue + } + + var chl *acme.Challenge + for i := range authz.Challenges { + if authz.Challenges[i].Type == "dns-01" { + chl = authz.Challenges[i] + break + } + } + + if chl == nil { + log.Printf("no DNS-01 challenge found for %v", authz.Identifier.Value) + continue + } + + _, err = d.client.Accept(context.Background(), chl) + if err != nil { + return err + } + } + + timeout, interval := d.provider.GetTimeout() + ticker := time.NewTicker(interval) + timer := time.NewTimer(timeout) + + for _, authURL := range order.AuthzURLs { + var lastErr error + + authLoop: + for { + select { + case <-timer.C: + return fmt.Errorf("timeout waiting for DNS-01 challenge to be accepted %s", lastErr) + case <-ticker.C: + _, err := d.client.WaitAuthorization(context.Background(), authURL) + if err == nil { + break authLoop + } + lastErr = err + } + } + } + + return nil +} + +func (d *DNSChallenge) prepareOrder(domains []string) (*acme.Order, []dnsprovider.Record, error) { + authIDs := make([]acme.AuthzID, len(domains)) + for i := range domains { + authIDs[i] = acme.AuthzID{Type: "dns", Value: domains[i]} + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + order, err := d.client.AuthorizeOrder(ctx, authIDs) + if err != nil { + return nil, nil, err + } + + records := make([]dnsprovider.Record, 0, len(order.AuthzURLs)) + + for i := range order.AuthzURLs { + authz, err := d.client.GetAuthorization(context.Background(), order.AuthzURLs[i]) + if err != nil { + return nil, nil, err + } + + // according to ACME spec, authorization objects are created in the "pending" state + if authz.Status != acme.StatusPending { + log.Printf("[ERROR] DNS-01 challenge for %v is not pending, with status %s", authz.Identifier.Value, authz.Status) + continue + } + + var chl *acme.Challenge + for i := range authz.Challenges { + if authz.Challenges[i].Type == "dns-01" { + chl = authz.Challenges[i] + break + } + } + + if chl == nil { + log.Printf("no DNS-01 challenge found for %v", authz.Identifier.Value) + continue + } + + record := dnsprovider.Record{ + Type: "TXT", + Host: "_acme-challenge", + Domain: authz.Identifier.Value, + } + + record.Value, err = d.client.DNS01ChallengeRecord(chl.Token) + if err != nil { + return nil, nil, err + } + + records = append(records, record) + + err = d.provider.AddRecord(record) + if err != nil { + return nil, nil, err + } + } + + return order, records, nil +} + +func (d *DNSChallenge) waitDSNPropagation(records []dnsprovider.Record) error { + + for _, r := range records { + if err := d.provider.WaitForPropagation(r); err != nil { + log.Printf("provider failed to wait for DNS propagation: %v", err) + } + } + + // try to check propagation using dns lookup + timeout, interval := d.provider.GetTimeout() + ticker := time.NewTicker(interval) + timer := time.NewTimer(timeout) + + for _, record := range records { + var lastErr error + nextNameserver := d.getNameserverFn() + + nameserver := nextNameserver() + if lastErr = checkTXTRecordPropagation(record, nameserver); lastErr == nil { + nameserver = nextNameserver() + } + nsLoop: + for { + select { + case <-timer.C: + return fmt.Errorf("timeout waiting for DNS-01 challenge to be accepted %s", lastErr) + case <-ticker.C: + // record propagated to all nameservers + if nameserver == "" { + break nsLoop + } + err := checkTXTRecordPropagation(record, nameserver) + if err == nil { + nameserver = nextNameserver() + continue + } + lastErr = err + } + } + } + + return nil +} + +func (d *DNSChallenge) pullCert(domains []string, order *acme.Order) error { + q := &x509.CertificateRequest{ + DNSNames: domains, + } + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + csr, err := x509.CreateCertificateRequest(rand.Reader, q, privateKey) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + ders, _, err := d.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true) + if err != nil { + return err + } + + certs := make([]*x509.Certificate, 0, len(ders)) + for i := range ders { + d := ders[i] + cert, err := x509.ParseCertificate(d) + if err != nil { + return err + } + certs = append(certs, cert) + } + + return d.writeCertificates(privateKey, certs) +} + +func (d *DNSChallenge) writeCertificates(privateKey *rsa.PrivateKey, certs []*x509.Certificate) error { + loc := getEnvOptional("SSL_ACME_LOCATION", "./var/acme/") + pkFileName := getEnvOptional("SSL_KEY", "key.pem") + certFileName := getEnvOptional("SSL_CERT", "cert.pem") + + keyOut, err := os.OpenFile(filepath.Join(loc, pkFileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + pkb, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return fmt.Errorf("unable to marshal private key: %v", err) + } + if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: pkb}); err != nil { + return fmt.Errorf("failed to write data to key.pem: %v", err) + } + if err := keyOut.Close(); err != nil { + return fmt.Errorf("error closing key.pem: %v", err) + } + + for i := range certs { + cert := certs[i] + + filename := filepath.Join(loc, certFileName) + if cert.IsCA { + filename = filepath.Join(loc, fmt.Sprintf("%s_%d.pem", "ca", i)) + } + + certOut, err := os.Create(filename) + if err != nil { + return err + } + + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}); err != nil { + return err + } + + if err := certOut.Close(); err != nil { + return err + } + } + + return nil +} + +func (d *DNSChallenge) getNameserverFn() func() string { + nameservers := make([]string, 0, len(d.nameservers)+2) + nameservers = append(nameservers, d.nameservers...) + nameservers = append(nameservers, defaultNameservers...) + + return func() string { + if len(nameservers) == 0 { + return "" + } + + ns := nameservers[0] + nameservers = nameservers[1:] + return ns + } +} + +func getCertExpiration() (time.Time, error) { + loc := os.Getenv("SSL_ACME_LOCATION") + if loc != "" { + return time.Time{}, fmt.Errorf("SSL_ACME_LOCATION is not defined") + } + + d, err := os.ReadFile(filepath.Join(loc, "cert.pem")) //nolint:gosec //read file with opts passed path + if err != nil { + return time.Time{}, err + } + + cert, err := x509.ParsePKIXPublicKey(d) + if err != nil { + return time.Time{}, err + } + + return cert.(*x509.Certificate).NotAfter, nil +} + +func removeTrainlingDot(name string) string { + n := len(name) + if n != 0 && name[n-1] == '.' { + return name[:n-1] + } + return name +} + +func checkTXTRecordPropagation(record dnsprovider.Record, nameserver string) error { + r := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, network, fmt.Sprintf("%s:53", nameserver)) + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + vals, err := r.LookupTXT(ctx, fmt.Sprintf("%s.%s", record.Host, record.Domain)) + if err != nil { + return fmt.Errorf("nameserver %s: error looking up TXT record %s: %s", + nameserver, record, err) + } + + for _, val := range vals { + if val == record.Value { + return nil + } + } + + maskedValue := "" + if len(record.Value) > 5 { + maskedValue = record.Value[len(record.Value)-4:] + } + return fmt.Errorf("nameserver %s: could not find TXT record %s with value ..%s", + nameserver, fmt.Sprintf("%s.%s", record.Host, record.Domain), maskedValue) +} + +func getEnvOptional(name, defaultValue string) string { + val := os.Getenv(name) + if val == "" { + return defaultValue + } + return val +} diff --git a/app/acme/dns_challenge_test.go b/app/acme/dns_challenge_test.go new file mode 100644 index 00000000..846c520d --- /dev/null +++ b/app/acme/dns_challenge_test.go @@ -0,0 +1,457 @@ +package acme + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/umputun/reproxy/app/acme/dnsprovider" + "golang.org/x/crypto/acme" +) + +var mockACMEServer *httptest.Server + +type mockDNSProvider struct { +} + +func (d *mockDNSProvider) AddRecord(record dnsprovider.Record) error { return nil } +func (d *mockDNSProvider) RemoveRecord(record dnsprovider.Record) error { return nil } +func (d *mockDNSProvider) WaitForPropagation(record dnsprovider.Record) error { return nil } +func (d *mockDNSProvider) GetTimeout() (timeout, interval time.Duration) { + return 1 * time.Minute, 10 * time.Second +} + +func TestMain(m *testing.M) { + if os.Getenv("DNS_CHALLENGE_TEST_ENABLED") != "" { + acmeV2Enpoint = "https://acme-staging-v02.api.letsencrypt.org/directory" + } + + // test using mock server + if os.Getenv("DNS_CHALLENGE_TEST_ENABLED") == "" { + setupMock() + acmeV2Enpoint = mockACMEServer.URL + } + + // use staging environment for testing + os.Exit(m.Run()) +} + +func setupMock() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + resp := struct { + RegURL string `json:"newAccount"` + AuthzURL string `json:"newAuthz"` + OrderURL string `json:"newOrder"` + }{ + RegURL: fmt.Sprintf("http://%s%s", r.Host, "/reg"), + AuthzURL: fmt.Sprintf("http://%s%s", r.Host, "/auth"), + OrderURL: fmt.Sprintf("http://%s%s", r.Host, "/order"), + } + + b, err := json.Marshal(resp) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Header().Add("Replay-Nonce", "12345") + w.WriteHeader(http.StatusOK) + w.Write(b) + }) + + mux.HandleFunc("/reg", func(w http.ResponseWriter, r *http.Request) { + resp := struct { + Status string + Contact []string + Orders string + }{ + Status: "Okay", + Contact: []string{"a", "b"}, + Orders: "haha", + } + d, err := json.Marshal(resp) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`error`)) + return + } + w.Header().Add("Replay-Nonce", "12345") + w.Header().Add("Location", fmt.Sprintf("http://%s", r.Host)) + + w.WriteHeader(http.StatusCreated) + w.Write(d) + }) + + mux.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) { + // types for request body + type authzID struct { + Type string `json:"type"` + Value string `json:"value"` + } + + type reqBody struct { + Protected string `json:"protected"` + Payload string `json:"payload"` + Sig string `json:"signature"` + } + + type payload struct { + Identifiers []authzID `json:"identifiers"` + NotBefore string `json:"notBefore,omitempty"` + NotAfter string `json:"notAfter,omitempty"` + } + + // types for response + type subproblem struct { + // "urn:acme:error:xxx" + Type string + // Detail is a human-readable explanation specific to this occurrence of the problem. + Detail string + Instance string + Identifier *authzID + } + + type wireError struct { + Status int + Type string + Detail string + Instance string + Subproblems []subproblem + } + + // parse request + defer r.Body.Close() + var rb reqBody + err := json.NewDecoder(r.Body).Decode(&rb) + if err != nil { + w.Write([]byte(`error`)) + w.WriteHeader(http.StatusBadRequest) + return + } + + pd, err := base64.RawStdEncoding.DecodeString(rb.Payload) + if err != nil { + w.Write([]byte(`error`)) + w.WriteHeader(http.StatusBadRequest) + return + } + + var p payload + if err = json.Unmarshal(pd, &p); err != nil { + w.Write([]byte(`error`)) + w.WriteHeader(http.StatusBadRequest) + return + } + + resp := struct { + Status string + Expires time.Time + Identifiers []authzID + NotBefore time.Time + NotAfter time.Time + Error *wireError + Authorizations []string + Finalize string + Certificate string + }{ + Status: "pending", + Expires: time.Now().Add(time.Hour), + Authorizations: make([]string, 0, len(p.Identifiers)), + Finalize: fmt.Sprintf("http://%s/finalize", r.Host), + Identifiers: make([]authzID, 0, len(p.Identifiers)), + } + + // answer according to test cases + for _, id := range p.Identifiers { + resp.Authorizations = append(resp.Authorizations, fmt.Sprintf("http://%s/auth?id=%s", r.Host, id.Value)) + resp.Identifiers = append(resp.Identifiers, id) + } + + d, err := json.Marshal(resp) + if err != nil { + w.Write([]byte(`error`)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + w.Write(d) + }) + + mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { + type wireAuthzID struct { + Type string `json:"type"` + Value string `json:"value"` + } + + type subproblem struct { + Type string + Detail string + Instance string + Identifier *wireAuthzID + } + + type wireError struct { + Status int + Type string + Detail string + Instance string + Subproblems []subproblem + } + + type wireChallenge struct { + URL string `json:"url"` // RFC + URI string `json:"uri"` // pre-RFC + Type string + Token string + Status string + Validated time.Time + Error *wireError + } + + type wireAuthz struct { + Identifier wireAuthzID + Status string + Expires time.Time + Wildcard bool + Challenges []wireChallenge + Combinations [][]int + Error *wireError + } + + // parse request + id := r.URL.Query().Get("id") + if id == "" { + w.Write([]byte(`error`)) + w.WriteHeader(http.StatusBadRequest) + return + } + + resp := wireAuthz{} + switch id { + case "mycompany-2.com": + resp.Status = "invalid" + resp.Identifier.Value = id + // correct cases + default: + resp.Status = "pending" + resp.Identifier.Value = id + resp.Challenges = []wireChallenge{ + { + URL: fmt.Sprintf("http://%s/challenge/%s", r.Host, id), + URI: fmt.Sprintf("http://%s/challenge/%s", r.Host, id), + Type: "dns-01", + Token: "token", + Status: "pending", + }, + } + resp.Wildcard = strings.Contains(id, "*") + } + + d, err := json.Marshal(resp) + if err != nil { + w.Write([]byte(`error`)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(d) + }) + + mockACMEServer = httptest.NewServer(mux) +} + +func TestDNSChallenge_register(t *testing.T) { + type fields struct { + client *acme.Client + } + type args struct { + domains []string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + {"success", fields{client: &acme.Client{}}, args{domains: []string{"example.com"}}, false}, + } + + d := &DNSChallenge{} + for _, tt := range tests { + if err := d.register(); (err != nil) != tt.wantErr { + t.Errorf("DNSChallenge.register() error = %v, wantErr %v", err, tt.wantErr) + } + } +} + +func TestDNSChallenge_prepareOrder(t *testing.T) { + type fields struct { + client *acme.Client + } + type args struct { + domains []string + } + + type expected struct { + status string + numIdentifiers int + numChallenges int + numRecords int + wildcard bool + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + expected expected + }{ + {"one domain", fields{client: &acme.Client{}}, + args{domains: []string{"mycompany-0.com"}}, false, expected{"pending", 1, 1, 1, false}}, + {"multiple domain, wildcards", fields{client: &acme.Client{}}, + args{domains: []string{"mycompany-1.com", "*.mycompany-1.com"}}, false, expected{"pending", 2, 2, 2, true}}, + {"auth status not pending", + fields{client: &acme.Client{}}, args{domains: []string{"mycompany-2.com"}}, false, + expected{"pending", 1, 0, 0, false}}, + } + + d := &DNSChallenge{provider: &mockDNSProvider{}} + if err := d.register(); err != nil { + t.Fatal(err) + } + for _, tt := range tests { + gotOrder, gotRecords, err := d.prepareOrder(tt.args.domains) + if (err != nil) != tt.wantErr { + t.Errorf("DNSChallenge.authorizeOrder() error = %v, wantErr %v", err, tt.wantErr) + continue + } + assert.Equal(t, gotOrder.Status, tt.expected.status, + fmt.Sprintf("%s: expected status %s, got %s", tt.name, tt.expected.status, gotOrder.Status)) + assert.Equal(t, len(gotOrder.Identifiers), tt.expected.numIdentifiers, + fmt.Sprintf("%s: expected %d identifiers, got %d", tt.name, tt.expected.numIdentifiers, len(gotOrder.Identifiers))) + assert.NotEmpty(t, gotOrder.FinalizeURL, + fmt.Sprintf("%s: expected FinalizeURL to be set", tt.name)) + assert.Equal(t, len(gotRecords), tt.expected.numRecords, + fmt.Sprintf("%s: expected %d records, got %d", tt.name, len(tt.args.domains), len(gotRecords))) + } +} + +func TestRemoveTrainlingDot(t *testing.T) { + testCases := []struct { + name string + fqdn string + expected string + }{ + { + name: "with dot", + fqdn: "my.example.com.", + expected: "my.example.com", + }, + { + name: "no trailing dot", + fqdn: "big.company.com", + expected: "big.company.com", + }, + { + name: "empty", + fqdn: "", + expected: "", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + domain := removeTrainlingDot(test.fqdn) + + assert.Equal(t, test.expected, domain) + }) + } +} + +func TestDNSChallenge_solveDNSChallenge(t *testing.T) { + type args struct { + fqdns []string + } + tests := []struct { + name string + args args + wantErr bool + }{ + {"success", args{fqdns: []string{"nbys.me"}}, false}, + } + + testDNSProvider, err := dnsprovider.NewProvider("cloudns") + if err != nil { + t.Fatal(err) + } + + d := &DNSChallenge{ + provider: testDNSProvider, + nameservers: []string{ + "pns41.cloudns.net", + "pns42.cloudns.net", + "pns43.cloudns.net", + "pns44.cloudns.net", + }, + } + + for _, tt := range tests { + if err := d.solveDNSChallenge(tt.args.fqdns); (err != nil) != tt.wantErr { + t.Errorf("DNSChallenge.solveDNSChallenge() error = %v, wantErr %v", err, tt.wantErr) + } + } +} + +func TestNewDNSChallege(t *testing.T) { + type args struct { + provider string + nameservers []string + } + tests := []struct { + name string + args args + want *DNSChallenge + wantErr bool + }{ + {name: "correct case", + args: args{provider: "cloudns", nameservers: []string{"ns1.com", "ns2.com"}}, + want: &DNSChallenge{nameservers: []string{"ns1.com", "ns2.com"}}, + }, + {name: "unknown dns provider", + args: args{provider: "ho-ho-ho", nameservers: []string{"ns1.com", "ns2.com"}}, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewDNSChallege(tt.args.provider, tt.args.nameservers) + if (err != nil) != tt.wantErr { + t.Errorf("NewDNSChallege() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil && tt.wantErr { + return + } + if !reflect.DeepEqual(got.nameservers, tt.want.nameservers) { + t.Errorf("NewDNSChallege() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/app/acme/dnsprovider/README.md b/app/acme/dnsprovider/README.md new file mode 100644 index 00000000..e69de29b diff --git a/app/acme/dnsprovider/cloudns.go b/app/acme/dnsprovider/cloudns.go new file mode 100644 index 00000000..24c819f1 --- /dev/null +++ b/app/acme/dnsprovider/cloudns.go @@ -0,0 +1,268 @@ +package dnsprovider + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +const ( + envPrefix = "CLOUDNS_" + + envAuthID = envPrefix + "AUTH_ID" + envSubAuthID = envPrefix + "SUB_AUTH_ID" + envAuthPassword = envPrefix + "AUTH_PASSWORD" + envTTL = envPrefix + "TTL" + envDNSPropagationTimeout = envPrefix + "DNS_PROPAGATION_TIMEOUT" + envDNSPropagationCheckInteval = envPrefix + "DNS_PROPAGATION_CHECK_INTERVAL" +) + +const ( + baseEndpointURL = "https://api.cloudns.net/dns/" + + addRecordURL = baseEndpointURL + "add-record.json" + updateStatusURL = baseEndpointURL + "update-status.json" + deleteRecordURL = baseEndpointURL + "delete-record.json" +) + +type recordWithID struct { + id int + record Record +} + +type cloudnsProvider struct { + authID string + subAuthID string + authPassword string + dnsPropagationTimeout time.Duration + dnsPropagationInterval time.Duration + client *http.Client + addedRecords []recordWithID +} + +// newCloudnsProvider creates a new CloudnsProvider DNS provider +func newCloudnsProvider() (Provider, error) { + authID := os.Getenv(envAuthID) + subAuthID := os.Getenv(envSubAuthID) + + if authID == "" && subAuthID == "" { + return nil, fmt.Errorf("%s or %s must be set", envAuthID, envSubAuthID) + } + + authPassword := os.Getenv(envAuthPassword) + if authPassword == "" { + return nil, fmt.Errorf("%s is not set", envAuthPassword) + } + + timeout := getEnvOptionalInt(envDNSPropagationTimeout, 180) + interval := getEnvOptionalInt(envDNSPropagationCheckInteval, 10) + + c := &cloudnsProvider{ + authID: authID, + subAuthID: subAuthID, + authPassword: authPassword, + dnsPropagationTimeout: time.Duration(timeout) * time.Second, + dnsPropagationInterval: time.Duration(interval) * time.Second, + client: &http.Client{}, + } + + return c, nil +} + +func (p *cloudnsProvider) AddRecord(record Record) error { + var ttl string + if ttl = os.Getenv(envTTL); ttl == "" { + ttl = "300" + } + + params := map[string]string{ + "record-type": "TXT", + "record": record.Value, + "domain-name": record.Domain, + "ttl": ttl, + "host": record.Host, + } + + b, err := p.doRequest("POST", addRecordURL, params) + if err != nil { + return err + } + + res := struct { + Status string `json:"status"` + StatusDescription string `json:"statusDescription"` + Data struct { + ID int `json:"id"` + } `json:"data"` + }{} + + if err := json.Unmarshal(b, &res); err != nil { + return err + } + + if res.Status != "Success" { + return fmt.Errorf("%s: %s", res.Status, res.StatusDescription) + } + + if p.addedRecords == nil { + p.addedRecords = make([]recordWithID, 0, 1) + } + p.addedRecords = append(p.addedRecords, recordWithID{id: res.Data.ID, record: record}) + + return nil +} + +func (p *cloudnsProvider) RemoveRecord(record Record) error { + var r *recordWithID + for i := range p.addedRecords { + r = &p.addedRecords[i] + if r.record.Host == record.Host && + r.record.Domain == record.Domain && + r.record.Value == record.Value { + break + } + } + + if r == nil { + return fmt.Errorf("remove record failed: record %s.%s not found", record.Host, record.Domain) + } + + params := map[string]string{ + "domain-name": record.Domain, + "record-id": strconv.Itoa(r.id), + } + + b, err := p.doRequest(http.MethodPost, deleteRecordURL, params) + if err != nil { + return fmt.Errorf("remove record failed: %s", err) + } + + res := struct { + Status string `json:"status"` + StatusDescription string `json:"statusDescription"` + }{} + + if err := json.Unmarshal(b, &res); err != nil { + return fmt.Errorf("remove record failed: %s", err) + } + + if res.Status != "Success" { + return fmt.Errorf("remove record failed: %s: %s", res.Status, res.StatusDescription) + } + + return nil +} + +func (p *cloudnsProvider) WaitForPropagation(record Record) error { + + timeout, interval := p.GetTimeout() + ticker := time.NewTicker(interval) + timer := time.NewTimer(timeout) + + for { + select { + case <-ticker.C: + updated, err := p.isUpdated(record.Domain) + if err != nil { + return err + } + if updated { + return nil + } + case <-timer.C: + return fmt.Errorf("timeout waiting for records update") + } + } +} + +func (p *cloudnsProvider) isUpdated(domain string) (bool, error) { + params := map[string]string{ + "domain-name": domain, + } + + b, err := p.doRequest("GET", updateStatusURL, params) + if err != nil { + return false, err + } + + servers := []struct { + Server string `json:"server"` + IP4 string `json:"ip4"` + IP6 string `json:"ip6"` + Updated bool `json:"updated"` + }{} + + if err := json.Unmarshal(b, &servers); err != nil { + return false, err + } + + updated := 0 + for _, server := range servers { + if server.Updated { + updated++ + } + } + return updated == len(servers), nil +} + +func (p *cloudnsProvider) doRequest(method, endpoint string, params map[string]string) (json.RawMessage, error) { + reqURL, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + q := reqURL.Query() + + for k, v := range params { + q.Set(k, v) + } + + // these should be set for all requests + q.Set("sub-auth-id", p.subAuthID) + q.Set("auth-id", p.authID) + q.Set("auth-password", p.authPassword) + + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequest(method, reqURL.String(), http.NoBody) + if err != nil { + return nil, err + } + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("invalid status code %v", resp.Status) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return body, nil +} + +func (p *cloudnsProvider) GetTimeout() (timeout, interval time.Duration) { + return p.dnsPropagationTimeout, 10 * time.Second +} + +func getEnvOptionalInt(key string, defaultValue int) int { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + + if valInt, err := strconv.Atoi(value); err == nil { + return valInt + } + + return defaultValue +} diff --git a/app/acme/dnsprovider/provider.go b/app/acme/dnsprovider/provider.go new file mode 100644 index 00000000..780536ce --- /dev/null +++ b/app/acme/dnsprovider/provider.go @@ -0,0 +1,42 @@ +package dnsprovider + +import ( + "fmt" + "time" +) + +// Record is a DNS record. +type Record struct { + Type string + Host string + Domain string + Value string +} + +// Provider is the interface that wraps the methods required to implement a +// DNS provider for the ACME DNS challenge. +type Provider interface { + // AddRecord creates TXT records for the specified FQDN and value. + AddRecord(record Record) error + + // RemoveRecord removes the TXT records matching the specified FQDN and value. + RemoveRecord(record Record) error + + // WaitForPropagation waits for the DNS records to propagate. + // The method will be called after creating TXT records. A provider API could be + // used to check propagation status. + WaitForPropagation(record Record) error + + // GetTimeout returns timeout and interval for the DNS propagation check. + GetTimeout() (timeout time.Duration, interval time.Duration) +} + +// NewProvider returns a provider that does nothing. +func NewProvider(providerName string) (Provider, error) { + switch providerName { + case "cloudns": + return newCloudnsProvider() + } + + return nil, fmt.Errorf("unsupported provider %s", providerName) +} diff --git a/app/acme/dnsprovider/provider_test.go b/app/acme/dnsprovider/provider_test.go new file mode 100644 index 00000000..a8eb9274 --- /dev/null +++ b/app/acme/dnsprovider/provider_test.go @@ -0,0 +1 @@ +package dnsprovider diff --git a/app/main.go b/app/main.go index 7113d81f..526395e8 100644 --- a/app/main.go +++ b/app/main.go @@ -20,6 +20,7 @@ import ( "github.com/umputun/go-flags" "gopkg.in/natefinch/lumberjack.v2" + "github.com/umputun/reproxy/app/acme" "github.com/umputun/reproxy/app/discovery" "github.com/umputun/reproxy/app/discovery/provider" "github.com/umputun/reproxy/app/discovery/provider/consulcatalog" @@ -39,13 +40,16 @@ var opts struct { LBType string `long:"lb-type" env:"LB_TYPE" description:"load balancer type" choice:"random" choice:"failover" default:"random"` //nolint SSL struct { - Type string `long:"type" env:"TYPE" description:"ssl (auto) support" choice:"none" choice:"static" choice:"auto" default:"none"` //nolint - Cert string `long:"cert" env:"CERT" description:"path to cert.pem file"` - Key string `long:"key" env:"KEY" description:"path to key.pem file"` - ACMELocation string `long:"acme-location" env:"ACME_LOCATION" description:"dir where certificates will be stored by autocert manager" default:"./var/acme"` - ACMEEmail string `long:"acme-email" env:"ACME_EMAIL" description:"admin email for certificate notifications"` - RedirHTTPPort int `long:"http-port" env:"HTTP_PORT" description:"http port for redirect to https and acme challenge test (default: 8080 under docker, 80 without)"` - FQDNs []string `long:"fqdn" env:"ACME_FQDN" env-delim:"," description:"FQDN(s) for ACME certificates"` + Type string `long:"type" env:"TYPE" description:"ssl (auto) support" choice:"none" choice:"static" choice:"auto" default:"none"` //nolint + Cert string `long:"cert" env:"CERT" description:"path to cert.pem file"` + Key string `long:"key" env:"KEY" description:"path to key.pem file"` + ACMELocation string `long:"acme-location" env:"ACME_LOCATION" description:"dir where certificates will be stored by autocert manager" default:"./var/acme"` + ACMEEmail string `long:"acme-email" env:"ACME_EMAIL" description:"admin email for certificate notifications"` + RedirHTTPPort int `long:"http-port" env:"HTTP_PORT" description:"http port for redirect to https and acme challenge test (default: 8080 under docker, 80 without)"` + FQDNs []string `long:"fqdn" env:"ACME_FQDN" env-delim:"," description:"FQDN(s) for ACME certificates"` + DNSChallengeEnabled bool `long:"dns-challenge-enabled" env:"ACME_DNS_CHALLENGE_ENABLED" description:"enable dns challenge"` + DNSProvider string `long:"dns-challenge-provider" env:"ACME_DNS_CHALLENGE_PROVIDER" description:"DNS provider" choice:"cloudns" choice:"cloudflare" choice:"route53" default:"cloudns"` //nolint + DNSResolvers []string `long:"dns-challenge-resolvers" env-delim:"," env:"ACME_DNS_CHALLENGE_RESOLVERS" description:"DNS resolvers" ` } `group:"ssl" namespace:"ssl" env-namespace:"SSL"` Assets struct { @@ -194,6 +198,12 @@ func run() error { return fmt.Errorf("failed to make config of ssl server params: %w", sslErr) } + if opts.SSL.DNSChallengeEnabled { + if err = acme.ScheduleCertificateRenewal(sslConfig.FQDNs, opts.SSL.DNSProvider, opts.SSL.DNSResolvers); err != nil { + log.Printf("[WARN] ACME: failed to schedule certificate renewal: %v", err) + } + } + accessLog, alErr := makeAccessLogWriter() if alErr != nil { return fmt.Errorf("failed to access log: %w", sslErr) diff --git a/go.mod b/go.mod index 49f19535..643f78f8 100644 --- a/go.mod +++ b/go.mod @@ -16,8 +16,8 @@ require ( github.com/prometheus/procfs v0.7.3 // indirect github.com/stretchr/testify v1.7.0 github.com/umputun/go-flags v1.5.1 - golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 - golang.org/x/net v0.0.0-20210908191846-a5e095526f91 // indirect + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 + golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect diff --git a/go.sum b/go.sum index 7853aefe..cd510855 100644 --- a/go.sum +++ b/go.sum @@ -224,6 +224,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -286,6 +288,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210908191846-a5e095526f91 h1:E8wdt+zBjoxD3MA65wEc3pl25BsTi7tbkpwc4ANThjc= golang.org/x/net v0.0.0-20210908191846-a5e095526f91/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/vendor/golang.org/x/crypto/acme/acme.go b/vendor/golang.org/x/crypto/acme/acme.go index 174cfe8b..73b19ef3 100644 --- a/vendor/golang.org/x/crypto/acme/acme.go +++ b/vendor/golang.org/x/crypto/acme/acme.go @@ -4,7 +4,7 @@ // Package acme provides an implementation of the // Automatic Certificate Management Environment (ACME) spec. -// The intial implementation was based on ACME draft-02 and +// The initial implementation was based on ACME draft-02 and // is now being extended to comply with RFC 8555. // See https://tools.ietf.org/html/draft-ietf-acme-acme-02 // and https://tools.ietf.org/html/rfc8555 for details. diff --git a/vendor/golang.org/x/crypto/acme/rfc8555.go b/vendor/golang.org/x/crypto/acme/rfc8555.go index 073cee58..f9d3011f 100644 --- a/vendor/golang.org/x/crypto/acme/rfc8555.go +++ b/vendor/golang.org/x/crypto/acme/rfc8555.go @@ -410,3 +410,29 @@ func isAlreadyRevoked(err error) bool { e, ok := err.(*Error) return ok && e.ProblemType == "urn:ietf:params:acme:error:alreadyRevoked" } + +// ListCertAlternates retrieves any alternate certificate chain URLs for the +// given certificate chain URL. These alternate URLs can be passed to FetchCert +// in order to retrieve the alternate certificate chains. +// +// If there are no alternate issuer certificate chains, a nil slice will be +// returned. +func (c *Client) ListCertAlternates(ctx context.Context, url string) ([]string, error) { + if _, err := c.Discover(ctx); err != nil { // required by c.accountKID + return nil, err + } + + res, err := c.postAsGet(ctx, url, wantStatus(http.StatusOK)) + if err != nil { + return nil, err + } + defer res.Body.Close() + + // We don't need the body but we need to discard it so we don't end up + // preventing keep-alive + if _, err := io.Copy(ioutil.Discard, res.Body); err != nil { + return nil, fmt.Errorf("acme: cert alternates response stream: %v", err) + } + alts := linkHeader(res.Header, "alternate") + return alts, nil +} diff --git a/vendor/golang.org/x/net/idna/go118.go b/vendor/golang.org/x/net/idna/go118.go new file mode 100644 index 00000000..c5c4338d --- /dev/null +++ b/vendor/golang.org/x/net/idna/go118.go @@ -0,0 +1,14 @@ +// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. + +// Copyright 2021 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. + +//go:build go1.18 +// +build go1.18 + +package idna + +// Transitional processing is disabled by default in Go 1.18. +// https://golang.org/issue/47510 +const transitionalLookup = false diff --git a/vendor/golang.org/x/net/idna/idna10.0.0.go b/vendor/golang.org/x/net/idna/idna10.0.0.go index 5208ba6c..64ccf85f 100644 --- a/vendor/golang.org/x/net/idna/idna10.0.0.go +++ b/vendor/golang.org/x/net/idna/idna10.0.0.go @@ -59,10 +59,10 @@ type Option func(*options) // Transitional sets a Profile to use the Transitional mapping as defined in UTS // #46. This will cause, for example, "ß" to be mapped to "ss". Using the // transitional mapping provides a compromise between IDNA2003 and IDNA2008 -// compatibility. It is used by most browsers when resolving domain names. This +// compatibility. It is used by some browsers when resolving domain names. This // option is only meaningful if combined with MapForLookup. func Transitional(transitional bool) Option { - return func(o *options) { o.transitional = true } + return func(o *options) { o.transitional = transitional } } // VerifyDNSLength sets whether a Profile should fail if any of the IDN parts @@ -284,7 +284,7 @@ var ( punycode = &Profile{} lookup = &Profile{options{ - transitional: true, + transitional: transitionalLookup, useSTD3Rules: true, checkHyphens: true, checkJoiners: true, diff --git a/vendor/golang.org/x/net/idna/idna9.0.0.go b/vendor/golang.org/x/net/idna/idna9.0.0.go index 55f718f1..aae6aac8 100644 --- a/vendor/golang.org/x/net/idna/idna9.0.0.go +++ b/vendor/golang.org/x/net/idna/idna9.0.0.go @@ -58,10 +58,10 @@ type Option func(*options) // Transitional sets a Profile to use the Transitional mapping as defined in UTS // #46. This will cause, for example, "ß" to be mapped to "ss". Using the // transitional mapping provides a compromise between IDNA2003 and IDNA2008 -// compatibility. It is used by most browsers when resolving domain names. This +// compatibility. It is used by some browsers when resolving domain names. This // option is only meaningful if combined with MapForLookup. func Transitional(transitional bool) Option { - return func(o *options) { o.transitional = true } + return func(o *options) { o.transitional = transitional } } // VerifyDNSLength sets whether a Profile should fail if any of the IDN parts diff --git a/vendor/golang.org/x/net/idna/pre_go118.go b/vendor/golang.org/x/net/idna/pre_go118.go new file mode 100644 index 00000000..3aaccab1 --- /dev/null +++ b/vendor/golang.org/x/net/idna/pre_go118.go @@ -0,0 +1,12 @@ +// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. + +// Copyright 2021 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. + +//go:build !go1.18 +// +build !go1.18 + +package idna + +const transitionalLookup = true diff --git a/vendor/golang.org/x/net/idna/punycode.go b/vendor/golang.org/x/net/idna/punycode.go index 02c7d59a..e8e3ac11 100644 --- a/vendor/golang.org/x/net/idna/punycode.go +++ b/vendor/golang.org/x/net/idna/punycode.go @@ -49,6 +49,7 @@ func decode(encoded string) (string, error) { } } i, n, bias := int32(0), initialN, initialBias + overflow := false for pos < len(encoded) { oldI, w := i, int32(1) for k := base; ; k += base { @@ -60,29 +61,32 @@ func decode(encoded string) (string, error) { return "", punyError(encoded) } pos++ - i += digit * w - if i < 0 { + i, overflow = madd(i, digit, w) + if overflow { return "", punyError(encoded) } t := k - bias - if t < tmin { + if k <= bias { t = tmin - } else if t > tmax { + } else if k >= bias+tmax { t = tmax } if digit < t { break } - w *= base - t - if w >= math.MaxInt32/base { + w, overflow = madd(0, w, base-t) + if overflow { return "", punyError(encoded) } } + if len(output) >= 1024 { + return "", punyError(encoded) + } x := int32(len(output) + 1) bias = adapt(i-oldI, x, oldI == 0) n += i / x i %= x - if n > utf8.MaxRune || len(output) >= 1024 { + if n < 0 || n > utf8.MaxRune { return "", punyError(encoded) } output = append(output, 0) @@ -115,6 +119,7 @@ func encode(prefix, s string) (string, error) { if b > 0 { output = append(output, '-') } + overflow := false for remaining != 0 { m := int32(0x7fffffff) for _, r := range s { @@ -122,8 +127,8 @@ func encode(prefix, s string) (string, error) { m = r } } - delta += (m - n) * (h + 1) - if delta < 0 { + delta, overflow = madd(delta, m-n, h+1) + if overflow { return "", punyError(s) } n = m @@ -141,9 +146,9 @@ func encode(prefix, s string) (string, error) { q := delta for k := base; ; k += base { t := k - bias - if t < tmin { + if k <= bias { t = tmin - } else if t > tmax { + } else if k >= bias+tmax { t = tmax } if q < t { @@ -164,6 +169,15 @@ func encode(prefix, s string) (string, error) { return string(output), nil } +// madd computes a + (b * c), detecting overflow. +func madd(a, b, c int32) (next int32, overflow bool) { + p := int64(b) * int64(c) + if p > math.MaxInt32-int64(a) { + return 0, true + } + return a + int32(p), false +} + func decodeDigit(x byte) (digit int32, ok bool) { switch { case '0' <= x && x <= '9': diff --git a/vendor/modules.txt b/vendor/modules.txt index 3d26241e..6d112a59 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -74,13 +74,13 @@ github.com/stretchr/testify/require # github.com/umputun/go-flags v1.5.1 ## explicit; go 1.12 github.com/umputun/go-flags -# golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 +# golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 ## explicit; go 1.17 golang.org/x/crypto/acme golang.org/x/crypto/acme/autocert golang.org/x/crypto/bcrypt golang.org/x/crypto/blowfish -# golang.org/x/net v0.0.0-20210908191846-a5e095526f91 +# golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 ## explicit; go 1.17 golang.org/x/net/idna # golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0