├── .gitignore ├── LICENSE ├── README.md ├── cmd └── scertecd │ └── scertecd-main.go ├── go.mod ├── go.sum ├── scertec.go ├── scertec_test.go └── scertecd └── scertecd.go /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | \#*# 3 | .#* 4 | *.test 5 | /result 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024 Tailscale Inc & AUTHORS. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scertec 2 | 3 | This is **scertec**, a Let's Encrypt ACME client that stores certs in [setec](https://github.com/tailscale/setec/) and a Go client library that reads those certs back out of setec at serving time via a `tls.Config.GetCertificate` hook. 4 | 5 | It only supports ACME DNS challenges using Amazon Route53. 6 | 7 | Directories involved: 8 | 9 | * `.` (package `scertec`): the client library that gets certs from setec 10 | * `scertecd` (package `scertecd`): the ACME client code that runs either in the foreground once or in the background as an HTTP server, keeping the certs refreshed in setec 11 | * `cmd/scertecd`: a little `package main` wrapper around the earlier item. 12 | -------------------------------------------------------------------------------- /cmd/scertecd/scertecd-main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // The scertecd command updates HTTPS certs in setec using Let's Encrypt 5 | // with AWS Route53 DNS challenges. 6 | // 7 | // It can run either as a long-running HTTP server that keeps the certs 8 | // refreshed or as a one-shot CLI command via the foreground mode. 9 | package main 10 | 11 | import ( 12 | "context" 13 | "flag" 14 | "log" 15 | "net" 16 | "net/http" 17 | "strings" 18 | 19 | "github.com/tailscale/scertec/scertecd" 20 | "github.com/tailscale/setec/client/setec" 21 | ) 22 | 23 | var ( 24 | setecURL = flag.String("setec-url", "", "URL of setec secrets server") 25 | acmeContact = flag.String("acme-contact", "", "ACME contact email address (optional)") 26 | prefix = flag.String("prefix", "dev/scertec/", "setec secret prefix to put certs under (with suffixes DOMAIN/rsa and DOMAIN/ecdsa); must end in a slash") 27 | domains = flag.String("domain-names", "", "Comma-separated list of domain names to get certs for") 28 | foreground = flag.Bool("foreground", false, "run in the foreground and update all the --domains if needed and exit but don't run an HTTP server") 29 | listen = flag.String("listen", ":8081", "address to listen on (if not in foreground mode)") 30 | ) 31 | 32 | func main() { 33 | flag.Parse() 34 | 35 | if *domains == "" { 36 | log.Fatalf("missing required --domains") 37 | } 38 | if *setecURL == "" { 39 | log.Fatalf("missing required --setec-url") 40 | } 41 | if !strings.HasSuffix(*prefix, "/") { 42 | log.Fatalf("--prefix must end in a slash") 43 | } 44 | 45 | s := &scertecd.Server{ 46 | SetecClient: setec.Client{Server: *setecURL}, 47 | Domains: strings.Split(*domains, ","), 48 | ACMEContact: *acmeContact, 49 | Prefix: *prefix, 50 | } 51 | if *foreground { 52 | if err := s.UpdateAll(); err != nil { 53 | log.Fatal(err) 54 | } 55 | return 56 | } 57 | 58 | ln, err := net.Listen("tcp", *listen) 59 | if err != nil { 60 | log.Fatal(err) 61 | } 62 | defer ln.Close() 63 | log.Printf("listening on %s ...", *listen) 64 | 65 | if err := s.Start(context.Background()); err != nil { 66 | log.Fatal(err) 67 | } 68 | 69 | log.Fatal(http.Serve(ln, s)) 70 | } 71 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale/scertec 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.44.267 7 | github.com/tailscale/setec v0.0.0-20240217033517-a4d9ef5afc5b 8 | golang.org/x/crypto v0.18.0 9 | ) 10 | 11 | require ( 12 | github.com/jmespath/go-jmespath v0.4.0 // indirect 13 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect 14 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect 15 | golang.org/x/net v0.20.0 // indirect 16 | golang.org/x/sync v0.6.0 // indirect 17 | golang.org/x/sys v0.16.0 // indirect 18 | golang.org/x/text v0.14.0 // indirect 19 | tailscale.com v1.1.1-0.20240212200800-c42a4e407a9f // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.44.267 h1:Asrp6EMqqRxZvjK0NjzkWcrOk15RnWtupuUrUuZMabk= 2 | github.com/aws/aws-sdk-go v1.44.267/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= 3 | github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= 4 | github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= 5 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= 6 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= 7 | github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw= 8 | github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU= 9 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= 10 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= 11 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= 12 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= 13 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= 14 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= 15 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= 16 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= 17 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= 18 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= 19 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= 20 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= 21 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= 22 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= 23 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= 24 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= 26 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= 27 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= 28 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= 29 | github.com/aws/aws-sdk-go-v2/service/s3 v1.47.7 h1:o0ASbVwUAIrfp/WcCac+6jioZt4Hd8k/1X8u7GJ/QeM= 30 | github.com/aws/aws-sdk-go-v2/service/s3 v1.47.7/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= 31 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= 32 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= 33 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= 34 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= 35 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= 36 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= 37 | github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= 38 | github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= 39 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 40 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 41 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 42 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 43 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 44 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 45 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 46 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 47 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 48 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 49 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 50 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 51 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 52 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 53 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 54 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 55 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 56 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 57 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 58 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 59 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 60 | github.com/tailscale/setec v0.0.0-20240217033517-a4d9ef5afc5b h1:aGVT9g02PmAV7I6idklzWeJVVV60FgDdGjbe6t2LMD0= 61 | github.com/tailscale/setec v0.0.0-20240217033517-a4d9ef5afc5b/go.mod h1:Mayqq+tl9DwM0g2aQ70B2SH6VFMRLZZJtLYcQllPbFA= 62 | github.com/tink-crypto/tink-go/v2 v2.1.0 h1:QXFBguwMwTIaU17EgZpEJWsUSc60b1BAGTzBIoMdmok= 63 | github.com/tink-crypto/tink-go/v2 v2.1.0/go.mod h1:y1TnYFt1i2eZVfx4OGc+C+EMp4CoKWAw2VSEuoicHHI= 64 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 65 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= 66 | go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 67 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 68 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 69 | golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= 70 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 71 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= 72 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= 73 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 74 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 75 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 76 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 77 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 78 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 79 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 80 | golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= 81 | golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 82 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 83 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 84 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 85 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 86 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 87 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 88 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 89 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 93 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 94 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 95 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 96 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 97 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 98 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 99 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 100 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 101 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 102 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 103 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 104 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 105 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 106 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 107 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 108 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 109 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 110 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 111 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 112 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 113 | tailscale.com v1.1.1-0.20240212200800-c42a4e407a9f h1:jqeK/N5vs8blwG3okBTOSkjvGaadut4j7d3+O9/Yx9c= 114 | tailscale.com v1.1.1-0.20240212200800-c42a4e407a9f/go.mod h1:qgxvJUlfOWeURBEORdcX4EhoCduFHeBW3FNIZBpmIHY= 115 | -------------------------------------------------------------------------------- /scertec.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package scertec provides a client for the TLS certs stored in setec as 5 | // placed there by the scertecd service. 6 | // 7 | // Think of it as a replacement for x/crypto/acme/autocert in that it provides 8 | // the tls.Config.GetCertificate hook that provides the cert. 9 | package scertec 10 | 11 | import ( 12 | "bytes" 13 | "context" 14 | "crypto/tls" 15 | "encoding/pem" 16 | "errors" 17 | "strings" 18 | "sync" 19 | "sync/atomic" 20 | "time" 21 | 22 | "github.com/tailscale/setec/client/setec" 23 | "golang.org/x/crypto/acme/autocert" 24 | ) 25 | 26 | // Client looks up TLS certs stored in setec by scertecd as a function of a tls.ClientHelloInfo. 27 | // 28 | // It does not connect to scertecd directly. (in fact, scertecd provides no 29 | // cert fetching service; scertecd only updates TLS cert secrets in setec.) 30 | type Client struct { 31 | sc setec.Client 32 | st *setec.Store 33 | prefix string 34 | allowed map[string]bool // domain name => true 35 | forceRSA atomic.Bool // for testing 36 | 37 | parsed sync.Map // secret name [string] => *parsedCert 38 | } 39 | 40 | // parsedCert is a cache of a previously used certificate. 41 | // It's used to avoid parsing the same certificate multiple times. 42 | // There is one parsedCert per secret name but its latest 43 | // pointer gets updated whenever the PEM from the secret changes. 44 | type parsedCert struct { 45 | latest atomic.Pointer[pemAndParsed] 46 | } 47 | 48 | // pemAndParsed are the PEM and parsed certificate for a particular 49 | // version of the secret. (We don't know the secret version but 50 | // we notice when the PEM bytes change). The pem and parsed values 51 | // are immutable once set. 52 | type pemAndParsed struct { 53 | pem []byte 54 | parsed *tls.Certificate 55 | hits atomic.Int64 // for tests 56 | } 57 | 58 | func secretName(prefix, domain, typ string) string { 59 | return prefix + "domains/" + domain + "/" + typ 60 | } 61 | 62 | // NewClient returns a new HTTPS cert client. It blocks until all the needed 63 | // secrets are available for retrieval by the Secret method, or ctx ends. The 64 | // context passed to NewStore is only used for initializing the store. 65 | func NewClient(ctx context.Context, c setec.Client, cache setec.Cache, prefix string, domains ...string) (*Client, error) { 66 | if len(domains) == 0 { 67 | return nil, errors.New("no domains provided") 68 | } 69 | allowed := make(map[string]bool, len(domains)) 70 | for _, d := range domains { 71 | allowed[d] = true 72 | } 73 | st, err := setec.NewStore(ctx, setec.StoreConfig{ 74 | Client: c, 75 | Cache: cache, 76 | AllowLookup: true, 77 | }) 78 | if err != nil { 79 | return nil, err 80 | } 81 | return &Client{ 82 | sc: c, 83 | st: st, 84 | prefix: prefix, 85 | allowed: allowed, 86 | }, nil 87 | } 88 | 89 | // GetCertificate returns the RSA or ECDSA certificate for hello.ServerName. 90 | // 91 | // It is the signature needed by tls.Config.GetCertificate. 92 | func (c *Client) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { 93 | if hello == nil { 94 | return nil, errors.New("nil ClientHelloInfo") 95 | } 96 | if !c.allowed[hello.ServerName] { 97 | return nil, errors.New("TLS ServerName not allowed") 98 | } 99 | typ := "rsa" 100 | canEC, err := supportsECDSA(hello) 101 | if err != nil { 102 | return nil, err 103 | } 104 | if canEC && !c.forceRSA.Load() { 105 | typ = "ecdsa" 106 | } 107 | secName := secretName(c.prefix, hello.ServerName, typ) 108 | 109 | sec := c.st.Secret(secName) 110 | 111 | // In the common case the above succeeds, once setec has seen it the first 112 | // time. But if it's not there, we'll look it up with a timeout. 113 | if sec == nil { 114 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 115 | defer cancel() 116 | sec, err = c.st.LookupSecret(ctx, secName) 117 | if err != nil { 118 | return nil, err 119 | } 120 | } 121 | 122 | return c.parsedCert(secName, sec.Get()) 123 | } 124 | 125 | func (c *Client) parsedCert(secName string, pems []byte) (*tls.Certificate, error) { 126 | pci, ok := c.parsed.Load(secName) 127 | if !ok { 128 | pci, _ = c.parsed.LoadOrStore(secName, &parsedCert{}) 129 | } 130 | pc := pci.(*parsedCert) 131 | 132 | latest := pc.latest.Load() 133 | if latest != nil && bytes.Equal(latest.pem, pems) { 134 | // Common case; the cert hasn't changed. 135 | latest.hits.Add(1) 136 | return latest.parsed, nil 137 | } 138 | 139 | b, certPEMBlock := pem.Decode(pems) 140 | if b == nil { 141 | return nil, errors.New("invalid PEM") 142 | } 143 | keyPEMSize := len(pems) - len(certPEMBlock) 144 | keyPEMBlock := pems[:keyPEMSize] 145 | 146 | cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) 147 | if err != nil { 148 | return nil, err 149 | } 150 | pc.latest.Store(&pemAndParsed{ 151 | pem: pems, 152 | parsed: &cert, 153 | }) 154 | return &cert, nil 155 | } 156 | 157 | // sniffKeyAutoCertCache is an x/crypto/acme/autocert.Cache implementation 158 | // as used by the common on supportsECDSA. 159 | type sniffKeyAutoCertCache chan<- string 160 | 161 | // errStopAutoCert is a sentinel error message we pass through acme/autocert 162 | // and expect to get back out to ourselves. It doesn't escape to scertec callers. 163 | var errStopAutoCert = errors.New("stop autocert") 164 | 165 | func (ch sniffKeyAutoCertCache) Get(ctx context.Context, key string) ([]byte, error) { 166 | select { 167 | case ch <- key: 168 | default: 169 | } 170 | return nil, errStopAutoCert 171 | } 172 | 173 | func (ch sniffKeyAutoCertCache) Put(ctx context.Context, key string, data []byte) error { 174 | panic("unreachable") 175 | } 176 | func (ch sniffKeyAutoCertCache) Delete(ctx context.Context, key string) error { 177 | panic("unreachable") 178 | } 179 | 180 | var autoCertManagerPool = &sync.Pool{ 181 | New: func() any { return &autocert.Manager{Prompt: autocert.AcceptTOS} }, 182 | } 183 | 184 | // supportsECDSA reports whether the given ClientHelloInfo supports ECDSA. 185 | // 186 | // Rather than copying acme/autocert's private implementation of this, we use 187 | // acme/autocert's own implementation indirectly by giving it a fake 188 | // autocert.Cache implementation and seeing which cache key autocert tries to 189 | // grab. It assumes that autocert fetches cache keys ending in "+rsa" for RSA 190 | // keys which in practice won't change (thanks, Hyrum!), but we also lock it 191 | // down in tests so we'll catch it if that behavior changes. Meanwhile, 192 | // discussions are underway in https://github.com/golang/go/issues/65727 193 | // of exporting that logic from acme/autocert somewhere. 194 | func supportsECDSA(hello *tls.ClientHelloInfo) (canEC bool, err error) { 195 | am := autoCertManagerPool.Get().(*autocert.Manager) 196 | defer autoCertManagerPool.Put(am) 197 | 198 | ch := make(chan string, 1) 199 | am.Cache = sniffKeyAutoCertCache(ch) 200 | _, err = am.GetCertificate(hello) 201 | if err == nil { 202 | return false, errors.New("unexpected success from autocert GetCertificate") 203 | } else if err != nil && !errors.Is(err, errStopAutoCert) { 204 | return false, err 205 | } 206 | var got string 207 | select { 208 | case got = <-ch: 209 | default: 210 | panic("unexpected lack of response from sniffKeyAutoCertCache.Get") 211 | } 212 | return !strings.HasSuffix(got, "+rsa"), nil 213 | } 214 | -------------------------------------------------------------------------------- /scertec_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package scertec 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | "flag" 10 | "io" 11 | "net" 12 | "net/http" 13 | "testing" 14 | 15 | "github.com/tailscale/setec/client/setec" 16 | ) 17 | 18 | var ( 19 | testServer = flag.String("server", "", "setec server URL for testing") 20 | testDomain = flag.String("domain", "", "domain name to test") 21 | testPrefix = flag.String("prefix", "dev/scertec/", "setec key prefix for testing") 22 | ) 23 | 24 | func TestDev(t *testing.T) { 25 | if *testServer == "" || *testDomain == "" { 26 | t.Skip("skipping test; set --server flag to run (e.g. https://secrets.your-tailnet.ts.net) as well as --domain") 27 | } 28 | 29 | ctx := context.Background() 30 | c, err := NewClient(ctx, setec.Client{ 31 | Server: *testServer, 32 | DoHTTP: http.DefaultClient.Do, 33 | }, nil, *testPrefix, *testDomain) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | ln, err := net.Listen("tcp", "localhost:0") 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | defer ln.Close() 43 | 44 | const msg = "hello from scertec client" 45 | s := &http.Server{ 46 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | io.WriteString(w, msg) 48 | }), 49 | TLSConfig: &tls.Config{ 50 | GetCertificate: c.GetCertificate, 51 | }, 52 | } 53 | go s.ServeTLS(ln, "", "") 54 | 55 | checkRes := func(t *testing.T, res *http.Response) { 56 | t.Helper() 57 | defer res.Body.Close() 58 | if res.StatusCode != 200 { 59 | t.Fatalf("got status %d; want 200", res.StatusCode) 60 | } 61 | all, err := io.ReadAll(res.Body) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | if string(all) != msg { 66 | t.Fatalf("got %q; want %q", all, msg) 67 | } 68 | } 69 | 70 | hc := &http.Client{ 71 | Transport: &http.Transport{ 72 | DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 73 | var d net.Dialer 74 | return d.DialContext(ctx, "tcp", ln.Addr().String()) 75 | }, 76 | DisableKeepAlives: true, 77 | }, 78 | } 79 | 80 | const numRequests = 3 81 | for i := 0; i < numRequests; i++ { 82 | res, err := hc.Get("https://" + *testDomain) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | checkRes(t, res) 87 | } 88 | 89 | // Verify we hit the fast path numRequests - 1 times 90 | secName := *testPrefix + "domains/" + *testDomain + "/ecdsa" 91 | pci, ok := c.parsed.Load(secName) 92 | if !ok { 93 | t.Fatalf("no parsedCert for %q", secName) 94 | } 95 | pc := pci.(*parsedCert) 96 | pp := pc.latest.Load() 97 | if pp == nil { 98 | t.Fatalf("no latest parsedCert for %q", secName) 99 | } 100 | if got, want := pp.hits.Load(), int64(numRequests-1); got != want { 101 | t.Fatalf("got %d hits; want %d", got, want) 102 | } 103 | 104 | // Test RSA mode 105 | secName = *testPrefix + "domains/" + *testDomain + "/rsa" 106 | if _, ok := c.parsed.Load(secName); ok { 107 | t.Fatalf("unexpected RSA already parsed before requested") 108 | } 109 | c.forceRSA.Store(true) 110 | res, err := hc.Get("https://" + *testDomain) 111 | if err != nil { 112 | t.Fatalf("with RSA: %v", err) 113 | } 114 | checkRes(t, res) 115 | if _, ok := c.parsed.Load(secName); !ok { 116 | t.Fatalf("no parsedCert for %q", secName) 117 | } 118 | } 119 | 120 | func TestSupportsECDSA(t *testing.T) { 121 | tests := []struct { 122 | name string 123 | h *tls.ClientHelloInfo 124 | want bool 125 | }{ 126 | { 127 | name: "ecdsa", 128 | h: &tls.ClientHelloInfo{ 129 | ServerName: "foo.com", 130 | SupportedCurves: []tls.CurveID{tls.CurveP256}, 131 | CipherSuites: []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, 132 | }, 133 | want: true, 134 | }, 135 | { 136 | name: "rsa", 137 | h: &tls.ClientHelloInfo{ServerName: "foo.com"}, 138 | want: false, 139 | }, 140 | } 141 | for _, tt := range tests { 142 | t.Run(tt.name, func(t *testing.T) { 143 | if got, err := supportsECDSA(tt.h); err != nil { 144 | t.Fatal(err) 145 | } else if got != tt.want { 146 | t.Errorf("supportsECDSA() = %v, want %v", got, tt.want) 147 | } 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /scertecd/scertecd.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // The scertecd package provides the code that fetches new TLS certs 5 | // from LetsEncrypt as needed and puts them in setec before they 6 | // expire. The code can run either in the foreground once, or most 7 | // commonly as an HTTP server daemon. 8 | // 9 | // It populates the following setec keys: 10 | // 11 | // - {prefix}acme-key: the private key for the ACME account, as a PEM-encoded ECDSA key 12 | // - {prefix}domains/{domain-name}/rsa: PEM of private key, domain cert, LetsEncrypt cert 13 | // - {prefix}domains/{domain-name}/ecdsa: PEM of private key, domain cert, LetsEncrypt cert 14 | package scertecd 15 | 16 | import ( 17 | "bytes" 18 | "context" 19 | "crypto" 20 | "crypto/ecdsa" 21 | "crypto/elliptic" 22 | "crypto/rand" 23 | "crypto/rsa" 24 | "crypto/sha256" 25 | "crypto/x509" 26 | "crypto/x509/pkix" 27 | "encoding/pem" 28 | "errors" 29 | "expvar" 30 | "fmt" 31 | "html/template" 32 | "io" 33 | "log" 34 | "net/http" 35 | "os" 36 | "path" 37 | "strings" 38 | "sync" 39 | "sync/atomic" 40 | "time" 41 | 42 | "github.com/aws/aws-sdk-go/aws" 43 | "github.com/aws/aws-sdk-go/aws/session" 44 | "github.com/aws/aws-sdk-go/service/route53" 45 | "github.com/tailscale/setec/client/setec" 46 | "github.com/tailscale/setec/types/api" 47 | "golang.org/x/crypto/acme" 48 | ) 49 | 50 | // Server is the scertec updater server. 51 | // 52 | // Despite the name "server", it can also be used in a single-shot 53 | // foreground mode via its UpdateAll method. 54 | // 55 | // All exported fields must be initialized before calling an exported 56 | // method on the Server: either UpdateAll or Start. 57 | type Server struct { 58 | SetecClient setec.Client // required client for setec 59 | Domains []string // domains to maintain certs for 60 | Now func() time.Time // if nil, initialized to time.Now 61 | ACMEContact string // optional email address for ACME registration 62 | Prefix string // setec secret prefix ("prod/scertec/") 63 | Logf func(format string, args ...any) // if nil, initialized to log.Printf 64 | 65 | lazyInitOnce sync.Once // guards dts and optional fields above 66 | dts []domainAndType 67 | startTime time.Time // time the server was started, for metrics 68 | 69 | metricErrorCount expvar.Map // error type => count 70 | metricRenewalsStarted expvar.Int // counter of renewal started 71 | metricCurRenewals expvar.Int // gauge of in-flight renewals 72 | metricSetecGet expvar.Int 73 | metricSetecGetNoChange expvar.Int 74 | metricMadeDNSRecords expvar.Int 75 | lastMadeDNSRecord atomic.Int64 // unix time of last successful DNS record made 76 | 77 | mu sync.Mutex 78 | acLazy *acme.Client // nil until needed via getACMEClient 79 | lastOrCurCheck map[domainAndType]*certUpdateCheck 80 | lastRes map[domainAndType]*certUpdateResult 81 | secCache map[string]*api.SecretValue // secret name => latest version 82 | domainLock map[string]chan struct{} // domain name => 1-buffered semaphore channel to limit concurrent renewals 83 | } 84 | 85 | func (s *Server) lazyInit() { 86 | s.lazyInitOnce.Do(func() { 87 | s.startTime = time.Now() 88 | for _, d := range s.Domains { 89 | for _, typ := range []CertType{RSACert, ECDSACert} { 90 | s.dts = append(s.dts, domainAndType{d, typ}) 91 | } 92 | } 93 | if s.Logf == nil { 94 | s.Logf = log.Printf 95 | } 96 | if s.Now == nil { 97 | s.Now = time.Now 98 | } 99 | s.lastOrCurCheck = make(map[domainAndType]*certUpdateCheck) 100 | s.lastRes = make(map[domainAndType]*certUpdateResult) 101 | s.addError0(errTypeSetecGet) 102 | s.addError0(errTypeSetecPut) 103 | s.addError0(errTypeSetecActivate) 104 | s.addError0(errTypeACMEGetChallenge) 105 | s.addError0(errTypeMakeRecord) 106 | s.addError0(errTypeACMEFinish) 107 | }) 108 | } 109 | 110 | // UpdateAll checks or updates all certs once and returns. 111 | // 112 | // If all certs are either fine or successfully updated, it returns nil. 113 | // 114 | // It is not necessary to call Start before UpdateAll. 115 | func (s *Server) UpdateAll() error { 116 | s.lazyInit() 117 | for _, dt := range s.dts { 118 | cu := s.newCertUpdateCheck(dt) 119 | st, err := cu.updateIfNeeded(context.Background(), nil) 120 | if err != nil { 121 | cu.lg.Printf("updateIfNeeded error: %v", err) 122 | return err 123 | } 124 | cu.lg.Printf("success; %+v", st) 125 | } 126 | return nil 127 | } 128 | 129 | // Start starts a background renewal goroutine for each cert domain and 130 | // algorithm type. The context is used only for the initial ACME registration 131 | // check and not used thereafter. 132 | func (s *Server) Start(ctx context.Context) error { 133 | s.lazyInit() 134 | 135 | if _, err := s.getACMEClient(ctx); err != nil { 136 | return err 137 | } 138 | 139 | go s.checkAWSPermissionsLoop() 140 | for _, dt := range s.dts { 141 | go s.renewCertLoop(dt) 142 | } 143 | return nil 144 | } 145 | 146 | // acquireDomainRenewalLock acquires a lock for the given domain name, 147 | // preventing RSA and ECDSA renewals from happening concurrently for the same 148 | // domain and fighting over TXT records. See 149 | // https://github.com/tailscale/scertec/issues/4. This isn't a perfect solution, 150 | // but it's a simple one. We could make it possible for both to run at the same 151 | // time later if we change how DNS records are managed. 152 | 153 | func (s *Server) acquireDomainRenewalLock(ctx context.Context, logf logf, domain string) (release func(), err error) { 154 | s.mu.Lock() 155 | if s.domainLock == nil { 156 | s.domainLock = make(map[string]chan struct{}) 157 | } 158 | sem, ok := s.domainLock[domain] 159 | if !ok { 160 | sem = make(chan struct{}, 1) 161 | s.domainLock[domain] = sem 162 | } 163 | s.mu.Unlock() 164 | 165 | release = func() { 166 | logf("release domain renewal lock for %q", domain) 167 | <-sem 168 | } 169 | 170 | select { 171 | case sem <- struct{}{}: 172 | logf("immediately acquired domain renewal lock for %q", domain) 173 | return release, nil 174 | default: 175 | logf("waiting for domain renewal lock for %q (currently held by other renewal)", domain) 176 | } 177 | t0 := s.Now() 178 | 179 | select { 180 | case sem <- struct{}{}: 181 | logf("acquired domain renewal lock for %q after waiting %v", domain, s.Now().Sub(t0).Round(time.Millisecond)) 182 | return release, nil 183 | case <-ctx.Done(): 184 | err := ctx.Err() 185 | logf("timeout waiting for domain renewal lock for %q: %v", domain, err) 186 | return nil, err 187 | } 188 | } 189 | 190 | func (s *Server) getACMEClient(ctx context.Context) (*acme.Client, error) { 191 | s.mu.Lock() 192 | defer s.mu.Unlock() 193 | if s.acLazy != nil { 194 | return s.acLazy, nil 195 | } 196 | 197 | ac, err := s.getOrMakeACMEClient(ctx) 198 | if err != nil { 199 | return nil, fmt.Errorf("getOrMakeACMEClient: %w", err) 200 | } 201 | if err := s.initACMEReg(ctx, ac); err != nil { 202 | return nil, fmt.Errorf("initACMEReg: %w", err) 203 | } 204 | s.acLazy = ac 205 | return ac, nil 206 | } 207 | 208 | var tmpls = template.Must(template.New("root").Parse(` 209 |
{{.Name}} 215 | | {{.Status}}
216 | {{if .Log}}{{.Log}}{{end}} 217 | {{if .SHA256}}[censys]{{end}} 218 | |
219 |