├── .github └── workflows │ └── release.yml ├── LICENSE ├── README.md ├── domaincert.go ├── expiration_time ├── expiration_time.go └── expiration_time_test.go ├── go.mod ├── main.go ├── main_test.go └── scripts └── cron.sh /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | name: Release with GoReleaser 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v6 22 | with: 23 | distribution: goreleaser 24 | version: latest 25 | args: release --clean 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Lajos Koszti (https://ajnasz.hu) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tool to get domains of expiring certificates 2 | 3 | The tool helps to check if the certificates are expired or will expire soon. 4 | 5 | ## Usage 6 | 7 | ### Check remote domains 8 | 9 | Use the `-domains` flag to check remote domains 10 | In that case the tool won't check the filesystem, but the domains you provide. 11 | 12 | An example to check the expiration of the example.org and wikipedia.org domains: 13 | 14 | ```sh 15 | $ ./letsencrypt-expiring-certs -domains example.org,wikipedia.org -expire "$(date -Is --date 2 year)" -print-date -quit 16 | *.example.org 2026-01-15T23:59:59Z x509: certificate has expired or is not yet valid: current time 2027-03-28T09:42:53+02:00 is after 2026-01-15T23:59:59Z 17 | example.org 2026-01-15T23:59:59Z x509: certificate has expired or is not yet valid: current time 2027-03-28T09:42:53+02:00 is after 2026-01-15T23:59:59Z 18 | *.wikipedia.org 2025-10-17T23:59:59Z x509: certificate has expired or is not yet valid: current time 2027-03-28T09:42:53+02:00 is after 2025-10-17T23:59:59Z 19 | wikimedia.org 2025-10-17T23:59:59Z x509: certificate has expired or is not yet valid: current time 2027-03-28T09:42:53+02:00 is after 2025-10-17T23:59:59Z 20 | mediawiki.org 2025-10-17T23:59:59Z x509: certificate has expired or is not yet valid: current time 2027-03-28T09:42:53+02:00 is after 2025-10-17T23:59:59Z 21 | # ... 22 | ``` 23 | 24 | ### Check local letsencrypt certificates 25 | 26 | List domains with expired certificates 27 | ``` 28 | $ ./letsencrypt-expiring-certs -expire="`date`" 29 | ``` 30 | 31 | List domains of certificates which will expire in 2 weeks 32 | ``` 33 | $ ./letsencrypt-expiring-certs -expire="`date --date='2 weeks'`" 34 | ``` 35 | 36 | List domains of certificates which will expire at 03/05/2016 11:28 37 | ```sh 38 | $ ./letsencrypt-expiring-certs -expire="`date --date '03/05/2016 11:28'`" 39 | ``` 40 | 41 | Use the `-certs-path` option to change the default path of certificates (/etc/letsencrypt/live) 42 | 43 | Use the `-pem-name` if the nem of your pem files is different then the default (fullchain.pem) 44 | 45 | ## Build 46 | 47 | The tool is written in go, so you will need a go compiler. 48 | 49 | It's often shipped with Linux distribution, for example on debian you can install the _golang_ package: 50 | 51 | ```sh 52 | $ sudo apt-get install golang 53 | ``` 54 | 55 | Then to install the tool run the following command: 56 | 57 | ``` 58 | $ export GOPATH="$HOME/gocode"; 59 | $ go get github.com/Ajnasz/letsencrypt-expiring-certs 60 | ``` 61 | 62 | This will download and also compile the code: 63 | 64 | In the _$HOME/gocode/bin/_ folder you will find the binary. 65 | 66 | In the _gocode/src/github.com/Ajnasz/letsencrypt-expiring-certs/_ folder you will find the source code. 67 | 68 | In the folder of the source code you can build tool again by using the `go build` command. 69 | 70 | If you use other OS then Linux or other distribution, read the documenatition: https://golang.org/doc/install 71 | 72 | ## Renew certificates 73 | 74 | You can see an example cron script in the scripts folder, see the scritps/cron.sh 75 | Make sure every path is correct in the script. 76 | -------------------------------------------------------------------------------- /domaincert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "net" 8 | "time" 9 | ) 10 | 11 | type DomainCert struct { 12 | Domain string 13 | Certs []*x509.Certificate 14 | } 15 | 16 | func NewDomainCert(domain string, certs []*x509.Certificate) DomainCert { 17 | return DomainCert{ 18 | Domain: domain, 19 | Certs: certs, 20 | } 21 | } 22 | 23 | var _ CertVerifier = (*DomainCert)(nil) 24 | 25 | func (domainCert DomainCert) Verify(expire time.Time) error { 26 | verifyOptions := x509.VerifyOptions{ 27 | CurrentTime: expire, 28 | Intermediates: x509.NewCertPool(), 29 | Roots: x509.NewCertPool(), 30 | } 31 | 32 | for _, cert := range domainCert.Certs { 33 | verifyOptions.Intermediates.AddCert(cert) 34 | } 35 | 36 | systemRoots, err := x509.SystemCertPool() 37 | if err == nil { 38 | verifyOptions.Roots = systemRoots 39 | } 40 | 41 | for _, cert := range domainCert.Certs { 42 | _, err = cert.Verify(verifyOptions) 43 | if err != nil { 44 | return err 45 | } 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (domainCert DomainCert) GetCert() *x509.Certificate { 52 | return domainCert.Certs[0] 53 | } 54 | 55 | func downloadCert(domain string) ([]*x509.Certificate, error) { 56 | host, port, err := net.SplitHostPort(domain) 57 | 58 | if err != nil { 59 | host = domain 60 | port = "443" 61 | } 62 | 63 | conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%s", host, port), &tls.Config{}) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | defer conn.Close() 69 | return conn.ConnectionState().PeerCertificates, nil 70 | } 71 | 72 | func checkDomainCerts(domainList []string, expire time.Time) ([]ExpiringCert, error) { 73 | var expiredDomains []ExpiringCert 74 | for _, domain := range domainList { 75 | certs, err := downloadCert(domain) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | domainCert := NewDomainCert(domain, certs) 81 | 82 | // fmt.Println("Checking domain: ", cert.DNSNames, cert.NotAfter, expire) 83 | if err := domainCert.Verify(expire); err != nil { 84 | expiredDomains = append(expiredDomains, ExpiringCert{ 85 | Domains: certs[0].DNSNames, 86 | Expire: certs[0].NotAfter, 87 | Error: err, 88 | }) 89 | } 90 | 91 | } 92 | return expiredDomains, nil 93 | } 94 | -------------------------------------------------------------------------------- /expiration_time/expiration_time.go: -------------------------------------------------------------------------------- 1 | package expiration_time 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | func GetDefaultExpireTime() time.Time { 9 | return time.Now().AddDate(0, 0, 14).Truncate(time.Hour) 10 | } 11 | 12 | var dateFormats = []string{ 13 | time.UnixDate, 14 | time.RFC3339, 15 | time.RFC1123, 16 | time.RFC1123Z, 17 | time.RFC822, 18 | time.RFC822Z, 19 | time.RFC850, 20 | time.RFC1123Z, 21 | } 22 | 23 | func getUserDefinedExpireTime(expireTime string) (time.Time, error) { 24 | for _, format := range dateFormats { 25 | expire, err := time.Parse(format, expireTime) 26 | if err == nil { 27 | return expire, nil 28 | } 29 | } 30 | 31 | return time.Time{}, errors.New("Invalid date format") 32 | } 33 | 34 | func GetExpireTime(expireTime string) (time.Time, error) { 35 | if expireTime == "" { 36 | return GetDefaultExpireTime(), nil 37 | } else { 38 | return getUserDefinedExpireTime(expireTime) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /expiration_time/expiration_time_test.go: -------------------------------------------------------------------------------- 1 | package expiration_time 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestGetDefaultExpireTime(t *testing.T) { 9 | actual := GetDefaultExpireTime() 10 | 11 | now := time.Now() 12 | 13 | actual = actual.Truncate(time.Hour) 14 | 15 | expected := now.Truncate(time.Hour).AddDate(0, 0, 14) 16 | 17 | if actual != expected { 18 | t.Fatal("Default expire time should be 2 weeks", actual, expected) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Ajnasz/letsencrypt-expiring-certs 2 | 3 | go 1.16.0 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | "path" 11 | "strings" 12 | "time" 13 | 14 | "github.com/Ajnasz/letsencrypt-expiring-certs/expiration_time" 15 | ) 16 | 17 | var pemName string 18 | var certsRoot string 19 | var expireTime string 20 | var printDate bool 21 | var domains string 22 | var quit bool 23 | 24 | type CertVerifier interface { 25 | Verify(time.Time) error 26 | GetCert() *x509.Certificate 27 | } 28 | 29 | func NewCertPem(pem []byte) CertPem { 30 | certPem := CertPem{ 31 | Pem: pem, 32 | } 33 | 34 | certPem.ParseCert() 35 | 36 | return certPem 37 | } 38 | 39 | type CertPem struct { 40 | Cert *x509.Certificate 41 | Pem []byte 42 | } 43 | 44 | var _ CertVerifier = (*CertPem)(nil) 45 | 46 | func (certPem CertPem) getIntermediateCertPool() (*x509.CertPool, error) { 47 | pool := x509.NewCertPool() 48 | 49 | ok := pool.AppendCertsFromPEM(certPem.Pem) 50 | 51 | if !ok { 52 | return nil, fmt.Errorf("Failed to append certificate to pool") 53 | } 54 | 55 | return pool, nil 56 | } 57 | 58 | func (certPem CertPem) Verify(expire time.Time) error { 59 | pool, err := certPem.getIntermediateCertPool() 60 | if err != nil { 61 | return err 62 | } 63 | options := x509.VerifyOptions{ 64 | CurrentTime: expire, 65 | Intermediates: pool, 66 | } 67 | 68 | _, err = certPem.Cert.Verify(options) 69 | 70 | return err 71 | } 72 | 73 | func (certPem *CertPem) ParseCert() { 74 | block, _ := pem.Decode(certPem.Pem) 75 | 76 | cert, err := x509.ParseCertificate(block.Bytes) 77 | 78 | if err != nil { 79 | log.Fatal(err) 80 | } 81 | 82 | certPem.Cert = cert 83 | } 84 | 85 | func (certPem CertPem) GetCert() *x509.Certificate { 86 | return certPem.Cert 87 | } 88 | 89 | func isDir(name string) bool { 90 | f, err := os.Stat(name) 91 | 92 | if err != nil { 93 | log.Fatal(err) 94 | } 95 | 96 | return f.IsDir() 97 | } 98 | 99 | func getCertDirectoryNames(dir string) []string { 100 | f, err := os.Open(dir) 101 | 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | 106 | names, err := f.Readdirnames(0) 107 | 108 | if err != nil { 109 | log.Fatal(err) 110 | } 111 | 112 | var dirs []string 113 | 114 | for index, name := range names { 115 | if isDir(path.Join(dir, name)) { 116 | dirs = append(dirs, names[index]) 117 | } 118 | } 119 | 120 | return dirs 121 | } 122 | 123 | func readPem(dir string) []byte { 124 | f, err := os.ReadFile(path.Join(dir, pemName)) 125 | 126 | if err != nil { 127 | log.Fatal(err) 128 | } 129 | 130 | return f 131 | } 132 | 133 | func filterExpiringCerts(certs []CertPem, expire time.Time) ([]*x509.Certificate, error) { 134 | output := make([]*x509.Certificate, 0, len(certs)) 135 | 136 | for _, cert := range certs { 137 | if err := cert.Verify(expire); err != nil { 138 | output = append(output, cert.GetCert()) 139 | } 140 | } 141 | 142 | return output, nil 143 | } 144 | 145 | func getCertificates(dirs []string) []CertPem { 146 | certificates := make([]CertPem, len(dirs)) 147 | 148 | for index, dir := range dirs { 149 | certPath := path.Join(certsRoot, dir) 150 | pem := readPem(certPath) 151 | 152 | var certPem CertPem = NewCertPem(pem) 153 | 154 | certificates[index] = certPem 155 | } 156 | 157 | return certificates 158 | } 159 | 160 | type ExpiringCert struct { 161 | Domains []string 162 | Expire time.Time 163 | Error error 164 | } 165 | 166 | func collectExpirations(expiringCerts []*x509.Certificate) []ExpiringCert { 167 | expires := make([]ExpiringCert, 0, len(expiringCerts)) 168 | 169 | for _, cert := range expiringCerts { 170 | expiringCert := ExpiringCert{ 171 | Domains: cert.DNSNames, 172 | Expire: cert.NotAfter, 173 | } 174 | expires = append(expires, expiringCert) 175 | } 176 | 177 | return expires 178 | } 179 | 180 | func printDomains(domains [][]string) { 181 | for _, domain := range domains { 182 | fmt.Println(strings.Join(domain, " ")) 183 | } 184 | } 185 | 186 | func printExpiringCerts(expiringCerts []ExpiringCert, printDate bool) { 187 | for _, cert := range expiringCerts { 188 | for _, domain := range cert.Domains { 189 | if printDate { 190 | fmt.Printf("%s\t%s\t%s", domain, cert.Expire.Format(time.RFC3339), cert.Error) 191 | } else { 192 | fmt.Printf("%s", domain) 193 | } 194 | fmt.Println() 195 | } 196 | } 197 | } 198 | 199 | func checkFileCerts(certsRoot string, expire time.Time) ([]*x509.Certificate, error) { 200 | 201 | dirs := getCertDirectoryNames(certsRoot) 202 | 203 | certificates := getCertificates(dirs) 204 | 205 | return filterExpiringCerts(certificates, expire) 206 | } 207 | 208 | func init() { 209 | flag.StringVar(&pemName, "pem-name", "fullchain.pem", "The name of the pem file, usually fullchain.pem") 210 | flag.StringVar(&certsRoot, "certs-path", "/etc/letsencrypt/live", "The path to the directory which stores the certificates") 211 | flag.StringVar(&expireTime, "expire", "", "Expire time of the certificates (run date command \"$(date -Im --date='03/15/2016')\"), eg.: 2016-03-15T00+01:00. If empty, 2 weeks from now will be used") 212 | flag.BoolVar(&printDate, "print-date", false, "Print the expiration date of the certificates") 213 | flag.StringVar(&domains, "domains", "", "Comma separated list of domains to check") 214 | flag.BoolVar(&quit, "quit", false, "Quit the program with a non 0 exit code after printing the expiring certificates, if there are any") 215 | 216 | flag.Parse() 217 | } 218 | 219 | func getExpired(expire time.Time) ([]ExpiringCert, error) { 220 | if domains == "" { 221 | expiringCerts, err := checkFileCerts(certsRoot, expire) 222 | if err != nil { 223 | return nil, err 224 | } 225 | 226 | return collectExpirations(expiringCerts), nil 227 | } else { 228 | domainList := strings.Split(domains, ",") 229 | 230 | return checkDomainCerts(domainList, expire) 231 | } 232 | } 233 | 234 | func main() { 235 | expire, err := expiration_time.GetExpireTime(expireTime) 236 | 237 | if err != nil { 238 | log.Fatal(err) 239 | } 240 | 241 | expiredDomains, err := getExpired(expire) 242 | 243 | if err != nil { 244 | log.Fatal(err) 245 | } 246 | printExpiringCerts(expiredDomains, printDate) 247 | 248 | if quit && len(expiredDomains) > 0 { 249 | os.Exit(1) 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/x509" 5 | "testing" 6 | ) 7 | 8 | func TestCollectDomains(t *testing.T) { 9 | var data []*x509.Certificate 10 | 11 | data = append(data, &x509.Certificate{ 12 | DNSNames: []string{"foo.bar", "foo.baz"}, 13 | }) 14 | data = append(data, &x509.Certificate{ 15 | DNSNames: []string{"foo.qux", "foo.norf"}, 16 | }) 17 | 18 | collectedDomains := collectExpirations(data) 19 | 20 | actual := len(collectedDomains) 21 | expected := len(data) 22 | if actual != expected { 23 | t.Fatal("Domains length is different") 24 | } 25 | 26 | for index, expiringCert := range collectedDomains { 27 | expected := len(data[index].DNSNames) 28 | actual := len(expiringCert.Domains) 29 | 30 | if actual != expected { 31 | t.Fatal("Collected domains length is different than cert.DNSNames") 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /scripts/cron.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | EXPIRE_DATE=`date --date='1 week'` 4 | LETSENCRYPT="/path/to/letsencrypt/letsencrypt-auto" # TODO change to the path of letsencrypt-auto 5 | LETSENCRYPT_EXPIRING_CERTS="/usr/local/sbin/letsencrypt-expiring-certs" # TODO change to the path of letsencrypt-expiring-certs 6 | DRY=1 # TODO Change to 0 in production 7 | 8 | DIR=/tmp/letsencrypt-auto 9 | crete_temp_folder() { 10 | echo "Create temporary folder" 11 | if is_dry;then 12 | return 13 | fi 14 | 15 | mkdir -p $DIR 16 | 17 | MKDIRSTATUS=$? 18 | 19 | if [ "$MKDIRSTATUS" -ne "0" ];then 20 | exit $MKDIRSTATUS 21 | fi 22 | } 23 | 24 | reload_services() { 25 | echo "Reload nginx" 26 | if ! is_dry;then 27 | /usr/sbin/service nginx reload 28 | fi 29 | } 30 | 31 | line_to_domains() { 32 | LINE=$@ 33 | 34 | DOMAINS="" 35 | for DOMAIN in $LINE; do 36 | DOMAINS="$DOMAINS -d $DOMAIN" 37 | done 38 | 39 | echo $DOMAINS 40 | } 41 | 42 | is_dry() { 43 | [ "$DRY" -ne "0" ] 44 | } 45 | 46 | renew_domains() { 47 | DOMAINS=$@ 48 | 49 | if is_dry;then 50 | echo $LETSENCRYPT --renew certonly --server https://acme-v01.api.letsencrypt.org/directory -a webroot --webroot-path=$DIR --agree-tos $DOMAINS 51 | else 52 | $LETSENCRYPT --renew certonly --server https://acme-v01.api.letsencrypt.org/directory -a webroot --webroot-path=$DIR --agree-tos $DOMAINS 53 | fi 54 | 55 | RENEW_STATUS=$? 56 | 57 | if [ "$RENEW_STATUS" -ne "0" ];then 58 | >&2 echo "Renew failed" 59 | exit $RENEW_STATUS 60 | fi 61 | } 62 | 63 | check_letsencrypt() { 64 | if [ ! -x "$LETSENCRYPT_EXPIRING_CERTS" ];then 65 | >&2 echo "letsencrypt-expiring-certs not executable, set LETSENCRYPT_EXPIRING_CERTS variable to the correct path" 66 | exit 2; 67 | fi 68 | } 69 | 70 | get_lines() { 71 | LINES=`$LETSENCRYPT_EXPIRING_CERTS -expire "$EXPIRE_DATE"` 72 | 73 | GET_LINES_STATUS=$? 74 | 75 | if [ "$GET_LINES_STATUS" -ne "0" ];then 76 | exit $GET_LINES_STATUS 77 | fi 78 | 79 | echo $LINES 80 | } 81 | 82 | main() { 83 | if is_dry;then 84 | echo "Running in dry mode, change DRY to 0 in the script on live environment" 85 | fi 86 | 87 | check_letsencrypt 88 | LINES=`get_lines` || exit $? 89 | 90 | crete_temp_folder 91 | 92 | echo $LINES | while read LINE; do 93 | DOMAINS=`line_to_domains $LINE` 94 | 95 | if [ ! -z "$DOMAINS" ];then 96 | renew_domains $DOMAINS 97 | fi 98 | done 99 | 100 | reload_services 101 | } 102 | 103 | main 104 | --------------------------------------------------------------------------------