├── .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 |

scertecd

210 | [metrics] 211 | 212 | {{range .Certs}} 213 | 214 | 219 | 220 | {{end}} 221 |
{{.Name}} 215 | {{.Status}} 216 | {{if .Log}}
{{.Log}}
{{end}} 217 | {{if .SHA256}}[censys]{{end}} 218 |
222 | `)) 223 | 224 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 225 | switch r.URL.Path { 226 | case "/": 227 | s.serveRoot(w, r) 228 | case "/metrics": 229 | s.serveMetrics(w, r) 230 | default: 231 | http.Error(w, "not found", http.StatusNotFound) 232 | } 233 | } 234 | 235 | func (s *Server) serveRoot(w http.ResponseWriter, r *http.Request) { 236 | type certData struct { 237 | Name string 238 | Status string 239 | Log string 240 | Version int 241 | SHA256 string 242 | } 243 | var data struct { 244 | Certs []certData 245 | } 246 | now := s.now() 247 | addRow := func(cu *certUpdateCheck) { 248 | if cu == nil { 249 | return 250 | } 251 | cu.mu.Lock() 252 | defer cu.mu.Unlock() 253 | 254 | cd := certData{ 255 | Name: cu.dt.SecretName(cu.s), 256 | } 257 | 258 | if cu.end.IsZero() { 259 | cd.Status = "in progress" 260 | cd.Log = cu.log.String() 261 | } else if cu.err != nil { 262 | cd.Status = "error: " + cu.err.Error() 263 | } else if cu.res == nil { 264 | cd.Status = "unexpected done with no error and no result" 265 | } else if cu.res.CertMeta == nil { 266 | cd.Status = "unexpected done with no error and no cert meta" 267 | } else { 268 | cd.Status = fmt.Sprintf("success; version %d; checked %v ago, good for %v", 269 | cu.res.SecretVersion, 270 | now.Sub(cu.end).Round(time.Second), 271 | formatDuration(cu.res.CertMeta.ValidEnd.Sub(now))) 272 | cd.Version = int(cu.res.SecretVersion) 273 | cd.SHA256 = fmt.Sprintf("%x", sha256.Sum256(cu.res.CertMeta.Leaf.Raw)) 274 | } 275 | data.Certs = append(data.Certs, cd) 276 | } 277 | 278 | for _, dt := range s.dts { 279 | s.mu.Lock() 280 | last := s.lastOrCurCheck[dt] 281 | s.mu.Unlock() 282 | addRow(last) 283 | } 284 | 285 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 286 | tmpls.ExecuteTemplate(w, "root", data) 287 | } 288 | 289 | // Consts for at least the broad error types, and anything referenced multiple 290 | // times, but not necessarily every little unlikely error path. String literals 291 | // in rare error cases are fine. 292 | const ( 293 | errTypeMakeRecord = "aws-make-record" 294 | errTypeSetecGet = "setec-get" 295 | errTypeSetecPut = "setec-put" 296 | errTypeSetecActivate = "setec-activate" 297 | errTypeACMEGetChallenge = "acme-get-challenge" 298 | errTypeACMEFinish = "acme-finish" 299 | errTypeCheckCertSetec = "check-cert-setec" 300 | ) 301 | 302 | func (s *Server) serveMetrics(w http.ResponseWriter, r *http.Request) { 303 | w.Header().Set("Content-Type", "text/plain; version=0.0.4") 304 | s.mu.Lock() 305 | defer s.mu.Unlock() 306 | 307 | now := s.Now() 308 | uptime := now.Sub(s.startTime) 309 | 310 | good := 0 311 | bad := 0 312 | 313 | fmt.Fprintf(w, "uptime %d\n", int64(uptime.Seconds())) 314 | add := func(s string, v int64) { 315 | fmt.Fprintf(w, "scertecd_%s %d\n", s, v) 316 | } 317 | add("managed_certs", int64(len(s.dts))) 318 | 319 | for _, dt := range s.dts { 320 | res := s.lastRes[dt] 321 | if res == nil || res.CertMeta == nil || res.CertMeta.ValidEnd.Before(now) { 322 | bad++ 323 | } else { 324 | renewalTime := res.CertMeta.RenewalTime() 325 | secRemain := int64(res.CertMeta.ValidEnd.Sub(now).Seconds()) 326 | secUntilRenew := int64(renewalTime.Sub(now).Seconds()) 327 | fmt.Fprintf(w, "scertecd_cert_seconds_remain{domain=%q} %v\n", dt, secRemain) 328 | fmt.Fprintf(w, "scertecd_cert_seconds_until_renewal{domain=%q} %v\n", dt, max(secUntilRenew, 0)) 329 | if now.After(renewalTime.Add(15 * time.Minute)) { 330 | bad++ 331 | } else { 332 | good++ 333 | } 334 | } 335 | } 336 | add("certs_cur_good", int64(good)) 337 | add("certs_cur_bad", int64(bad)) 338 | add("certs_renewals_started", s.metricRenewalsStarted.Value()) 339 | add("certs_cur_renewals", s.metricCurRenewals.Value()) 340 | add("setec_get", s.metricSetecGet.Value()) 341 | add("setec_get_no_change", s.metricSetecGetNoChange.Value()) 342 | add("made_dns_records", s.metricMadeDNSRecords.Value()) // including tests 343 | 344 | var dnsErrors int64 345 | if e, ok := s.metricErrorCount.Get(errTypeMakeRecord).(*expvar.Int); ok { 346 | dnsErrors = e.Value() 347 | } 348 | if uptime < 5*time.Minute && dnsErrors == 0 && s.metricMadeDNSRecords.Value() == 0 { 349 | // At startup, don't emit metrics related to DNS errors while the first 350 | // test is ongoing, 351 | } else { 352 | lastTime := s.lastMadeDNSRecord.Load() 353 | if lastTime == 0 { 354 | add("alert_dns_permission_problem_likely", 1) 355 | } else { 356 | add("last_dns_record_seconds_ago", now.Unix()-lastTime) 357 | } 358 | } 359 | 360 | s.metricErrorCount.Do(func(kv expvar.KeyValue) { 361 | fmt.Fprintf(w, "scertecd_error{type=%q} %d\n", kv.Key, kv.Value.(*expvar.Int).Value()) 362 | }) 363 | } 364 | 365 | func (s *Server) now() time.Time { 366 | if s.Now != nil { 367 | return s.Now() 368 | } 369 | return time.Now() 370 | } 371 | 372 | func (s *Server) newCertUpdateCheck(dt domainAndType) *certUpdateCheck { 373 | cu := &certUpdateCheck{ 374 | s: s, 375 | dt: dt, 376 | start: s.now(), 377 | } 378 | cu.lg = log.New(io.MultiWriter(cu, os.Stderr), fmt.Sprintf("%s: ", dt), log.LstdFlags|log.Lmsgprefix) 379 | return cu 380 | } 381 | 382 | func (s *Server) renewCertLoop(dt domainAndType) { 383 | for { 384 | cu := s.newCertUpdateCheck(dt) 385 | 386 | s.mu.Lock() 387 | prev := s.lastOrCurCheck[dt] 388 | s.lastOrCurCheck[dt] = cu 389 | s.mu.Unlock() 390 | 391 | var prevRes *certUpdateResult 392 | if prev != nil { 393 | prevRes = prev.res 394 | } 395 | 396 | res, err := cu.updateIfNeeded(context.Background(), prevRes) 397 | if err != nil { 398 | cu.lg.Printf("updateIfNeeded error: %v", err) 399 | // In case we violated some rate limit, sleep a bit. We should be 400 | // looking at acme response headers/errors more but in the meantime, 401 | // just conservatively sleep more than hopefully necessary. 402 | time.Sleep(5 * time.Minute) 403 | } else { 404 | s.mu.Lock() 405 | s.lastRes[dt] = res 406 | s.mu.Unlock() 407 | 408 | // In the happy path, we just keep checking regularly. The checks 409 | // are cheap: just an if-modified-since call to setec. 410 | cu.lg.Printf("success; %+v", res) 411 | time.Sleep(1 * time.Minute) 412 | } 413 | } 414 | } 415 | 416 | // domainAndType is a domain name and a cert algorithm type for that domain 417 | // name. It's a value type, for use as a map key. 418 | type domainAndType struct { 419 | domain string 420 | typ CertType 421 | } 422 | 423 | func (dt domainAndType) String() string { 424 | return dt.domain + "/" + string(dt.typ) 425 | } 426 | 427 | func (dt domainAndType) SecretName(s *Server) string { 428 | return s.Prefix + "domains/" + dt.domain + "/" + strings.ToLower(string(dt.typ)) 429 | } 430 | 431 | // certUpdateCheck is a single run of a cert update. 432 | // It might be in progress or finished. 433 | type certUpdateCheck struct { 434 | s *Server 435 | dt domainAndType // domain ("foo.com") and cert type (RSA vs ECDSA) 436 | start time.Time 437 | lg *log.Logger 438 | 439 | mu sync.Mutex 440 | log bytes.Buffer 441 | end time.Time // time check ended; non-zero if done 442 | err error // final error 443 | res *certUpdateResult 444 | } 445 | 446 | func (cu *certUpdateCheck) Logf(format string, args ...any) { 447 | cu.lg.Printf(format, args...) 448 | } 449 | 450 | func (c *certUpdateCheck) Write(p []byte) (n int, err error) { 451 | c.mu.Lock() 452 | defer c.mu.Unlock() 453 | return c.log.Write(p) 454 | } 455 | 456 | // CertType is the algorithm type for the cert, 457 | // either RSA or ECDSA. 458 | type CertType string 459 | 460 | const ( 461 | RSACert CertType = "RSA" 462 | ECDSACert CertType = "ECDSA" 463 | ) 464 | 465 | // getOrMakeACMEClient returns an acme.Client, making a new private key if 466 | // possible. It doesn't do an ACME register. 467 | func (s *Server) getOrMakeACMEClient(ctx context.Context) (*acme.Client, error) { 468 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 469 | defer cancel() 470 | 471 | secName := s.Prefix + "acme-key" 472 | 473 | sec, err := s.SetecClient.Get(ctx, secName) 474 | if err == nil { 475 | priv, rest := pem.Decode(sec.Value) 476 | if priv == nil { 477 | s.Logf("secret %q has non-PEM garbage; ignoring", secName) 478 | } else if len(bytes.TrimSpace(rest)) > 0 { 479 | s.Logf("secret %q has unexpected data after first PEM block; ignoring secret", secName) 480 | } else if !strings.Contains(priv.Type, "PRIVATE KEY") { 481 | s.Logf("secret %q has unexpected PEM type %q (not a PRIVATE KEY); ignoring secret", secName, priv.Type) 482 | } else { 483 | privKey, err := parsePrivateKey(priv.Bytes) 484 | if err != nil { 485 | s.Logf("secret %q has invalid private key; ignoring error: %v", secName, err) 486 | } else { 487 | s.Logf("using cached ACME key") 488 | return &acme.Client{ 489 | Key: privKey, 490 | UserAgent: "tailscale-scertec/1.0", 491 | }, nil 492 | } 493 | } 494 | } 495 | if err != nil && !errors.Is(err, api.ErrNotFound) { 496 | return nil, fmt.Errorf("could not get %q secret: %w", secName, err) 497 | } 498 | s.Logf("creating %q ...", secName) 499 | privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 500 | if err != nil { 501 | return nil, err 502 | } 503 | var pemBuf bytes.Buffer 504 | if err := encodeECDSAKey(&pemBuf, privKey); err != nil { 505 | return nil, err 506 | } 507 | if _, err := s.putAndActivateSecret(ctx, s.Logf, secName, pemBuf.Bytes()); err != nil { 508 | return nil, err 509 | } 510 | return &acme.Client{ 511 | Key: privKey, 512 | UserAgent: "tailscale-scertec/1.0", 513 | }, nil 514 | } 515 | 516 | func (s *Server) initACMEReg(ctx context.Context, ac *acme.Client) error { 517 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 518 | defer cancel() 519 | 520 | a, err := ac.GetReg(ctx, "" /* pre-RFC param */) 521 | switch { 522 | case err == nil: 523 | // Great, already registered. 524 | s.Logf("already had ACME account") 525 | case err == acme.ErrNoAccount: 526 | acct := &acme.Account{} 527 | if s.ACMEContact != "" { 528 | acct.Contact = append(acct.Contact, "mailto:"+s.ACMEContact) 529 | } 530 | a, err = ac.Register(ctx, acct, acme.AcceptTOS) 531 | if err == acme.ErrAccountAlreadyExists { 532 | // Potential race. Double check. 533 | a, err = ac.GetReg(ctx, "" /* pre-RFC param */) 534 | } 535 | if err != nil { 536 | return fmt.Errorf("acme.Register: %w", err) 537 | } 538 | s.Logf("registered ACME account") 539 | default: 540 | return fmt.Errorf("acme.GetReg: %w", err) 541 | 542 | } 543 | if a.Status != acme.StatusValid { 544 | return fmt.Errorf("unexpected ACME account status %q", a.Status) 545 | } 546 | s.Logf("ACME account: %+v", a) 547 | return nil 548 | } 549 | 550 | func (s *Server) addError(errType string) { 551 | s.metricErrorCount.Add(errType, 1) 552 | } 553 | 554 | func (s *Server) addError0(errType string) { 555 | s.metricErrorCount.Add(errType, 0) 556 | } 557 | 558 | // getSecret fetches a secret from setec, remembering any fetched value so 559 | // most calls end up doing a GetIfChanged called to setec which results in 560 | // fewer audit log entries. 561 | func (s *Server) getSecret(ctx context.Context, secName string) (*api.SecretValue, error) { 562 | s.mu.Lock() 563 | have := s.secCache[secName] 564 | s.mu.Unlock() 565 | 566 | var v *api.SecretValue 567 | var err error 568 | 569 | if have != nil { 570 | v, err = s.SetecClient.GetIfChanged(ctx, secName, have.Version) 571 | if errors.Is(err, api.ErrValueNotChanged) { 572 | s.metricSetecGetNoChange.Add(1) 573 | return have, nil 574 | } 575 | } else { 576 | v, err = s.SetecClient.Get(ctx, secName) 577 | } 578 | if v != nil { 579 | s.metricSetecGet.Add(1) 580 | 581 | s.mu.Lock() 582 | if s.secCache == nil { 583 | s.secCache = make(map[string]*api.SecretValue) 584 | } 585 | s.secCache[secName] = v 586 | s.mu.Unlock() 587 | } 588 | if err != nil { 589 | s.addError(errTypeSetecGet) 590 | } 591 | return v, err 592 | } 593 | 594 | type logf = func(format string, args ...any) 595 | 596 | func (s *Server) putAndActivateSecret(ctx context.Context, logf logf, secName string, secValue []byte) (api.SecretVersion, error) { 597 | v, err := s.SetecClient.Put(ctx, secName, secValue) 598 | if err != nil { 599 | s.addError(errTypeSetecPut) 600 | return 0, fmt.Errorf("could not create %q secret: %w", secName, err) 601 | } 602 | logf("created secret %q version %v", secName, v) 603 | err = s.SetecClient.Activate(ctx, secName, v) 604 | if err != nil { 605 | s.addError(errTypeSetecActivate) 606 | return 0, fmt.Errorf("could not activate %q version %v: %w", secName, v, err) 607 | } 608 | logf("activated secret %q version %v", secName, v) 609 | return v, nil 610 | } 611 | 612 | type certUpdateResult struct { 613 | Updated bool 614 | CheckedAt time.Time 615 | CertMeta *certMeta 616 | SecretName string 617 | SecretVersion api.SecretVersion 618 | } 619 | 620 | // updateIfNeeded checks if the cert for cu.dt needs updating and fetches a new 621 | // one from LetsEncrypt using ACME if so. 622 | // 623 | // prev is the previous cert update check, if any. It will be nil on the first 624 | // check. 625 | func (cu *certUpdateCheck) updateIfNeeded(ctx context.Context, prev *certUpdateResult) (_ *certUpdateResult, retErr error) { 626 | defer func() { 627 | cu.mu.Lock() 628 | defer cu.mu.Unlock() 629 | cu.end = cu.s.now() 630 | cu.err = retErr 631 | }() 632 | 633 | ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) // minute for Route 53 + plenty of slack for ACME exchanges + RSA/ECDSA being serialized 634 | defer cancel() 635 | 636 | secName := cu.dt.SecretName(cu.s) 637 | 638 | res := &certUpdateResult{ 639 | SecretName: secName, 640 | CheckedAt: cu.s.Now(), 641 | } 642 | cu.res = res 643 | 644 | // See if we have an existing cert in setec that still has sufficient 645 | // remaining expiry time. 646 | if sec, err := cu.s.getSecret(ctx, secName); err == nil { 647 | m, err := cu.s.parseCertMeta(sec.Value) 648 | switch { 649 | case err == nil: 650 | res.SecretVersion = sec.Version 651 | res.CertMeta = m 652 | return res, nil 653 | case err == errNeedNewCert: 654 | cu.Logf("insufficient remaining time; fetching a new one") 655 | default: 656 | cu.Logf("failed to parse cached cert: %v", err) 657 | } 658 | } else if !errors.Is(err, api.ErrNotFound) { 659 | cu.s.addError(errTypeCheckCertSetec) 660 | return nil, fmt.Errorf("could not get %q secret: %w", secName, err) 661 | } 662 | 663 | // We need a new cert. Start the ACME dns-01 dance and get a challenge. But 664 | // only permit one renewal per DNS name at a time, so our TXT records don't 665 | // fight (https://github.com/tailscale/scertec/issues/4). 666 | release, err := cu.s.acquireDomainRenewalLock(ctx, cu.Logf, cu.dt.domain) 667 | if err != nil { 668 | return nil, fmt.Errorf("acquireDomainRenewalLock: %w", err) 669 | } 670 | defer release() 671 | 672 | cu.s.metricCurRenewals.Add(1) 673 | defer cu.s.metricCurRenewals.Add(-1) 674 | cu.s.metricRenewalsStarted.Add(1) 675 | 676 | chal, err := cu.getACMEChallenge(ctx) 677 | if err != nil { 678 | cu.s.addError(errTypeACMEGetChallenge) 679 | return nil, fmt.Errorf("getACMEChallenge: %w", err) 680 | } 681 | 682 | // Make the DNS record we were told to make to prove we control the DNS. 683 | err = cu.s.makeRecord(ctx, cu.Logf, chal.dnsRecordName, chal.dnsRecordValue) 684 | if err != nil { 685 | cu.s.addError(errTypeMakeRecord) 686 | return nil, fmt.Errorf("makeRecord %q: %w", chal.dnsRecordName, err) 687 | } 688 | cu.Logf("made DNS record %q", chal.dnsRecordName) 689 | 690 | // Finish the ACME dance. 691 | allPEM, err := cu.finishACME(ctx, chal) 692 | if err != nil { 693 | cu.s.addError(errTypeACMEFinish) 694 | return nil, fmt.Errorf("finishACME: %w", err) 695 | } 696 | 697 | m, err := cu.s.parseCertMeta(allPEM) 698 | if err != nil { 699 | cu.s.addError("renewal-parse-new-cert") 700 | return nil, fmt.Errorf("failed to parse newly fetched cert: %w", err) 701 | } 702 | 703 | res.SecretVersion, err = cu.s.putAndActivateSecret(ctx, cu.Logf, secName, allPEM) 704 | if err != nil { 705 | return nil, err 706 | } 707 | res.Updated = true 708 | res.CertMeta = m 709 | return res, nil 710 | } 711 | 712 | func (cu *certUpdateCheck) finishACME(ctx context.Context, ci *acmeChallengeInfo) (allPEM []byte, err error) { 713 | ac, err := cu.s.getACMEClient(ctx) 714 | if err != nil { 715 | return nil, err 716 | } 717 | 718 | chal, err := ac.Accept(ctx, ci.challenge) 719 | if err != nil { 720 | return nil, fmt.Errorf("acme Accept: %w", err) 721 | } 722 | cu.traceACME(chal) 723 | 724 | order, err := ac.WaitOrder(ctx, ci.order.URI) 725 | if err != nil { 726 | if ctx.Err() != nil { 727 | return nil, ctx.Err() 728 | } 729 | if oe, ok := err.(*acme.OrderError); ok { 730 | cu.Logf("WaitOrder: OrderError status %q; err=%s", oe.Status, oe.Error()) 731 | } else { 732 | cu.Logf("WaitOrder error: %v", err) 733 | } 734 | return nil, err 735 | } 736 | cu.traceACME(order) 737 | 738 | certPrivKey, err := cu.genCertPrivateKey() 739 | if err != nil { 740 | return nil, err 741 | } 742 | 743 | var pemBuf bytes.Buffer 744 | if err := encodePrivateKeyPEM(&pemBuf, certPrivKey); err != nil { 745 | return nil, err 746 | } 747 | 748 | csr, err := certRequest(certPrivKey, cu.dt.domain, nil, cu.dt.domain) 749 | if err != nil { 750 | return nil, err 751 | } 752 | 753 | cu.Logf("requesting cert...") 754 | der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true) 755 | if err != nil { 756 | return nil, fmt.Errorf("CreateOrder: %v", err) 757 | } 758 | cu.Logf("got cert") 759 | 760 | for _, b := range der { 761 | pb := &pem.Block{Type: "CERTIFICATE", Bytes: b} 762 | if err := pem.Encode(&pemBuf, pb); err != nil { 763 | return nil, err 764 | } 765 | } 766 | 767 | return pemBuf.Bytes(), nil 768 | } 769 | 770 | func (cu *certUpdateCheck) genCertPrivateKey() (crypto.Signer, error) { 771 | switch cu.dt.typ { 772 | case RSACert: 773 | return rsa.GenerateKey(rand.Reader, 2048) 774 | case ECDSACert: 775 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 776 | default: 777 | return nil, fmt.Errorf("invalid cert type %q", cu.dt.typ) 778 | } 779 | } 780 | 781 | var errNeedNewCert = errors.New("need new cert") 782 | 783 | func (cu *certUpdateCheck) traceACME(v any) { 784 | cu.lg.Printf("acme: %T: %+v", v, v) 785 | } 786 | 787 | type acmeChallengeInfo struct { 788 | dnsRecordName string // "_acme-challenge." + domain 789 | dnsRecordValue string // the value to put in the TXT record 790 | 791 | order *acme.Order 792 | challenge *acme.Challenge 793 | } 794 | 795 | func (cu *certUpdateCheck) getACMEChallenge(ctx context.Context) (*acmeChallengeInfo, error) { 796 | ci := &acmeChallengeInfo{} 797 | 798 | ac, err := cu.s.getACMEClient(ctx) 799 | if err != nil { 800 | return nil, err 801 | } 802 | 803 | ci.order, err = ac.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: cu.dt.domain}}) 804 | if err != nil { 805 | return nil, err 806 | } 807 | cu.traceACME(ci.order) 808 | 809 | for _, aurl := range ci.order.AuthzURLs { 810 | az, err := ac.GetAuthorization(ctx, aurl) 811 | if err != nil { 812 | return nil, fmt.Errorf("GetAuthorization: %w", err) 813 | } 814 | cu.traceACME(az) 815 | for _, ch := range az.Challenges { 816 | if ch.Type != "dns-01" { 817 | continue 818 | } 819 | dnsRecordValue, err := ac.DNS01ChallengeRecord(ch.Token) 820 | if err != nil { 821 | return nil, err 822 | } 823 | ci.dnsRecordName = "_acme-challenge." + cu.dt.domain 824 | ci.dnsRecordValue = dnsRecordValue 825 | ci.challenge = ch 826 | return ci, nil 827 | } 828 | } 829 | return nil, errors.New("no dns-01 challenge returned") 830 | } 831 | 832 | type certMeta struct { 833 | ValidStart time.Time // NotBefore of the latest cert (the domain cert) 834 | ValidEnd time.Time // NotAfter of the soonest expiring cert (the domain cert) 835 | 836 | Leaf *x509.Certificate // the domain cert 837 | Issuer *x509.Certificate // the Let's Encrypt cert 838 | } 839 | 840 | // RenewalTime returns the time two thirds of the way between ValidStart and ValidEnd. 841 | func (m *certMeta) RenewalTime() time.Time { 842 | return m.ValidEnd.Add(-(m.ValidEnd.Sub(m.ValidStart) / 3)) 843 | } 844 | 845 | // parseCertMeta parses the PEM of a previously-stored key+cert(s) in setec 846 | // and returns metadata about the validity window of the cert(s). 847 | // If we're over 2/3rds of the way through its validity period, it returns 848 | // it returns (non-nil, errNeedNewCert). 849 | func (s *Server) parseCertMeta(p []byte) (*certMeta, error) { 850 | m := &certMeta{} 851 | var blocks []*pem.Block 852 | for { 853 | b, rest := pem.Decode(p) 854 | if b == nil { 855 | break 856 | } 857 | p = rest 858 | blocks = append(blocks, b) 859 | } 860 | if len(blocks) == 0 { 861 | return nil, errors.New("no PEM blocks found") 862 | } 863 | if len(blocks) < 3 { 864 | return nil, errors.New("not enough PEM blocks found") 865 | } 866 | if !strings.HasSuffix(blocks[0].Type, " PRIVATE KEY") { 867 | return nil, errors.New("first PEM block is not a private key") 868 | } 869 | certBlocks := blocks[1:] 870 | for i, cb := range certBlocks { 871 | if cb.Type != "CERTIFICATE" { 872 | return nil, fmt.Errorf("unexpected PEM block of type %q", cb.Type) 873 | } 874 | c, err := x509.ParseCertificate(cb.Bytes) 875 | if err != nil { 876 | return nil, fmt.Errorf("parsing cert: %w", err) 877 | } 878 | if i == 0 { 879 | m.Leaf = c 880 | } else { 881 | m.Issuer = c 882 | } 883 | if c.NotAfter.IsZero() { 884 | return nil, errors.New("cert has no NotAfter") 885 | } 886 | if c.NotBefore.IsZero() { 887 | return nil, errors.New("cert has no NotBefore") 888 | } 889 | if m.ValidEnd.IsZero() || c.NotAfter.Before(m.ValidEnd) { 890 | m.ValidEnd = c.NotAfter 891 | } 892 | if m.ValidStart.IsZero() || c.NotBefore.After(m.ValidStart) { 893 | m.ValidStart = c.NotBefore 894 | } 895 | } 896 | 897 | if s.Now().After(m.RenewalTime()) { 898 | return m, errNeedNewCert 899 | } 900 | return m, nil 901 | } 902 | 903 | // certRequest generates a CSR for the given common name cn and optional SANs. 904 | func certRequest(key crypto.Signer, cn string, ext []pkix.Extension, san ...string) ([]byte, error) { 905 | req := &x509.CertificateRequest{ 906 | Subject: pkix.Name{CommonName: cn}, 907 | DNSNames: san, 908 | ExtraExtensions: ext, 909 | } 910 | return x509.CreateCertificateRequest(rand.Reader, req, key) 911 | } 912 | 913 | // parsePrivateKey is a copy of x/crypto/acme's parsePrivateKey. 914 | // 915 | // Attempt to parse the given private key DER block. OpenSSL 0.9.8 generates 916 | // PKCS#1 private keys by default, while OpenSSL 1.0.0 generates PKCS#8 keys. 917 | // OpenSSL ecparam generates SEC1 EC private keys for ECDSA. We try all three. 918 | // 919 | // Inspired by parsePrivateKey in crypto/tls/tls.go. 920 | func parsePrivateKey(der []byte) (crypto.Signer, error) { 921 | if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { 922 | return key, nil 923 | } 924 | if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { 925 | switch key := key.(type) { 926 | case *rsa.PrivateKey: 927 | return key, nil 928 | case *ecdsa.PrivateKey: 929 | return key, nil 930 | default: 931 | return nil, errors.New("unknown private key type in PKCS#8 wrapping") 932 | } 933 | } 934 | if key, err := x509.ParseECPrivateKey(der); err == nil { 935 | return key, nil 936 | } 937 | 938 | return nil, errors.New("failed to parse private key") 939 | } 940 | 941 | func encodeECDSAKey(w io.Writer, key *ecdsa.PrivateKey) error { 942 | b, err := x509.MarshalECPrivateKey(key) 943 | if err != nil { 944 | return err 945 | } 946 | pb := &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} 947 | return pem.Encode(w, pb) 948 | } 949 | 950 | func encodePrivateKeyPEM(w io.Writer, key crypto.Signer) error { 951 | switch key := key.(type) { 952 | case *ecdsa.PrivateKey: 953 | if err := encodeECDSAKey(w, key); err != nil { 954 | return err 955 | } 956 | case *rsa.PrivateKey: 957 | b := x509.MarshalPKCS1PrivateKey(key) 958 | pb := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: b} 959 | if err := pem.Encode(w, pb); err != nil { 960 | return err 961 | } 962 | default: 963 | return errors.New("unknown private key type") 964 | } 965 | return nil 966 | } 967 | 968 | // makeRecord upserts a Route 53 TXT record and waits for it to be globally 969 | // synchronized. 970 | func (s *Server) makeRecord(ctx context.Context, logf logf, recordName, txtVal string) error { 971 | sess := session.Must(session.NewSessionWithOptions(session.Options{ 972 | SharedConfigState: session.SharedConfigEnable, 973 | })) 974 | svc := route53.New(sess) 975 | 976 | // Find the hosted zone for the recordName. 977 | // 978 | // TODO(bradfitz): cache these hosted zone lookups? they don't change often. 979 | // but we also don't get new cert often. But we could cache them and then 980 | // only reload on miss. 981 | var hostedZoneID string 982 | err := svc.ListHostedZonesPagesWithContext(ctx, &route53.ListHostedZonesInput{ 983 | MaxItems: aws.String("100"), 984 | }, func(page *route53.ListHostedZonesOutput, lastPage bool) bool { 985 | for _, hz := range page.HostedZones { 986 | if hz.Name == nil || *hz.Name == "local." { 987 | continue 988 | } 989 | if strings.HasSuffix(recordName, "."+strings.TrimSuffix(*hz.Name, ".")) { 990 | hostedZoneID = path.Base(*hz.Id) // map "/hostedzone/ZFOO" to "ZFOO" 991 | logf("matched hosted zone %q (%s)", *hz.Name, hostedZoneID) 992 | return false // stop 993 | } 994 | } 995 | return true // continue 996 | }) 997 | if err != nil { 998 | return err 999 | } 1000 | if hostedZoneID == "" { 1001 | return fmt.Errorf("unknown hosted zone for %q", recordName) 1002 | } 1003 | 1004 | const recordType = "TXT" 1005 | const ttlSec = 300 1006 | 1007 | // Look up the record first to see if it already exists and is of the 1008 | // right type (TXT) and value. If so, don't do anything. But if it exists 1009 | // with the wrong value, delete it first. 1010 | lookup := &route53.ListResourceRecordSetsInput{ 1011 | HostedZoneId: aws.String(hostedZoneID), 1012 | StartRecordName: aws.String(recordName), 1013 | MaxItems: aws.String("1"), 1014 | } 1015 | rrsOut, err := svc.ListResourceRecordSetsWithContext(ctx, lookup) 1016 | if err != nil { 1017 | return err 1018 | } 1019 | wantVal := fmt.Sprintf("%q", txtVal) 1020 | if len(rrsOut.ResourceRecordSets) > 0 { 1021 | rrs := rrsOut.ResourceRecordSets[0] 1022 | if strings.TrimSuffix(*rrs.Name, ".") == recordName && *rrs.Type == recordType && 1023 | len(rrs.ResourceRecords) == 1 && 1024 | *rrs.ResourceRecords[0].Value == wantVal { 1025 | logf("record %q already has value %q; skipping", recordName, txtVal) 1026 | return nil 1027 | } 1028 | } 1029 | 1030 | input := &route53.ChangeResourceRecordSetsInput{ 1031 | HostedZoneId: aws.String(hostedZoneID), 1032 | ChangeBatch: &route53.ChangeBatch{ 1033 | Changes: []*route53.Change{ 1034 | { 1035 | Action: aws.String("UPSERT"), 1036 | ResourceRecordSet: &route53.ResourceRecordSet{ 1037 | Name: aws.String(recordName), 1038 | Type: aws.String(recordType), 1039 | TTL: aws.Int64(ttlSec), 1040 | ResourceRecords: []*route53.ResourceRecord{ 1041 | { 1042 | Value: aws.String(wantVal), 1043 | }, 1044 | }, 1045 | }, 1046 | }, 1047 | }, 1048 | }, 1049 | } 1050 | 1051 | crsOut, err := svc.ChangeResourceRecordSetsWithContext(ctx, input) 1052 | if err != nil { 1053 | logf("ChangeResourceRecordSets error: %T, %#v", err, err) 1054 | return err 1055 | } 1056 | ci := crsOut.ChangeInfo 1057 | 1058 | // Wait for the change to be globally in sync. (there are only two states: 1059 | // pending and insync) 1060 | for ci.Status == nil || *ci.Status == route53.ChangeStatusPending { 1061 | logf("ChangeInfo: %+v", ci) 1062 | select { 1063 | case <-ctx.Done(): 1064 | return ctx.Err() 1065 | case <-time.After(5 * time.Second): 1066 | } 1067 | crsOut, err := svc.GetChangeWithContext(ctx, &route53.GetChangeInput{ 1068 | Id: ci.Id, 1069 | }) 1070 | if err != nil { 1071 | return err 1072 | } 1073 | ci = crsOut.ChangeInfo 1074 | } 1075 | logf("ChangeInfo: %+v", ci) 1076 | 1077 | if err := ctx.Err(); err != nil { 1078 | return err 1079 | } 1080 | 1081 | s.lastMadeDNSRecord.Store(time.Now().Unix()) 1082 | s.metricMadeDNSRecords.Add(1) 1083 | return nil 1084 | } 1085 | 1086 | // formatDuration is like time.Duration.String but 1087 | // omits seconds and adds days. 1088 | func formatDuration(d time.Duration) string { 1089 | d = d.Round(time.Minute) 1090 | const day = 24 * time.Hour 1091 | days := d / day 1092 | var s string 1093 | if days > 0 { 1094 | s = fmt.Sprintf("%dd%s", days, (d - days*day).String()) 1095 | } else { 1096 | s = d.String() 1097 | } 1098 | return strings.TrimSuffix(s, "0s") 1099 | } 1100 | 1101 | // checkAWSPermissionsLoop is a background goroutine that periodically checks 1102 | // whether our Route53 IAM permissions are still valid. This is meant to protect 1103 | // us from moving the certd server between VMs and not having the right roles on 1104 | // the new VM and then not noticing until certs fail to expire. 1105 | // 1106 | // On failure, this sets a metric on the server that we can then alert. 1107 | func (s *Server) checkAWSPermissionsLoop() { 1108 | for { 1109 | if err := s.checkAWSPermissions(); err != nil { 1110 | s.Logf("checkAWSPermissions error: %v", err) 1111 | s.addError(errTypeMakeRecord) 1112 | 1113 | // If we failed to make a record, try again in a few minutes. 1114 | // This lets us distinguish between a transient error and a more 1115 | // persistent issue in alerting. 1116 | time.Sleep(10 * time.Minute) 1117 | } else { 1118 | time.Sleep(1 * time.Hour) 1119 | } 1120 | } 1121 | } 1122 | 1123 | // checkAWSPermissions makes a test Route53 record to see if we have 1124 | // suitable permissions. 1125 | func (s *Server) checkAWSPermissions() error { 1126 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) 1127 | defer cancel() 1128 | 1129 | var buf bytes.Buffer 1130 | lg := log.New(&buf, "", log.LstdFlags|log.Lmsgprefix) 1131 | logf := lg.Printf 1132 | 1133 | domain := s.dts[0].domain // TODO(bradfitz): random? check each unique domain? 1134 | release, err := s.acquireDomainRenewalLock(ctx, logf, domain) 1135 | if err != nil { 1136 | return err 1137 | } 1138 | defer release() 1139 | 1140 | t0 := time.Now() 1141 | if err := s.makeRecord(ctx, logf, "_acme-challenge."+domain, fmt.Sprintf("permtest-%v", time.Now().Unix())); err != nil { 1142 | s.Logf("checkAWSPermissions makeRecord error: %v, %s", err, buf.Bytes()) 1143 | return err 1144 | } 1145 | s.Logf("checkAWSPermissions success; took %v", time.Since(t0).Round(time.Millisecond)) 1146 | return nil 1147 | } 1148 | --------------------------------------------------------------------------------