├── dns ├── go.mod ├── dns.go ├── cloudflare.go └── alidns.go ├── .gitignore ├── upcert ├── go.mod ├── go.sum └── main.go ├── getcert ├── go.mod ├── go.sum └── main.go ├── go.mod ├── Makefile ├── README.md ├── chkcert └── main.go ├── generate_key.go └── mkcert └── main.go /dns/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caiguanhao/certutils/dns 2 | 3 | go 1.15 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.cert 2 | *.key 3 | key.go 4 | chkcert/chkcert 5 | mkcert/mkcert 6 | getcert/getcert 7 | upcert/upcert 8 | -------------------------------------------------------------------------------- /upcert/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caiguanhao/certutils/upcert 2 | 3 | go 1.15 4 | 5 | require github.com/caiguanhao/ossslim v0.0.0-20201230035309-1cecd519243f 6 | -------------------------------------------------------------------------------- /getcert/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caiguanhao/certutils/getcert 2 | 3 | go 1.15 4 | 5 | require github.com/caiguanhao/ossslim v0.0.0-20201230035309-1cecd519243f 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caiguanhao/certutils 2 | 3 | go 1.16 4 | 5 | replace github.com/caiguanhao/certutils/dns => ./dns 6 | 7 | require github.com/caiguanhao/certutils/dns v0.0.0 8 | -------------------------------------------------------------------------------- /getcert/go.sum: -------------------------------------------------------------------------------- 1 | github.com/caiguanhao/ossslim v0.0.0-20201230035309-1cecd519243f h1:V3t9wls+v92kMJKy9AhwpiQqsh+N0+jcDnRkDyF5LEY= 2 | github.com/caiguanhao/ossslim v0.0.0-20201230035309-1cecd519243f/go.mod h1:mzkvaTc7JCgASl+JejtBlD3H3r7l674dJ1pawIT4/Mg= 3 | -------------------------------------------------------------------------------- /upcert/go.sum: -------------------------------------------------------------------------------- 1 | github.com/caiguanhao/ossslim v0.0.0-20201230035309-1cecd519243f h1:V3t9wls+v92kMJKy9AhwpiQqsh+N0+jcDnRkDyF5LEY= 2 | github.com/caiguanhao/ossslim v0.0.0-20201230035309-1cecd519243f/go.mod h1:mzkvaTc7JCgASl+JejtBlD3H3r7l674dJ1pawIT4/Mg= 3 | -------------------------------------------------------------------------------- /dns/dns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | type ( 4 | DNS interface { 5 | GetListOfDomains() []string 6 | GetRecords(domain string) []Record 7 | GetRecordIdsFor(domain, dname, dtype string) []string 8 | AddNewRecord(domain, dname, dtype, dvalue string) string 9 | DeleteRecord(domain, id string) 10 | } 11 | 12 | Record struct { 13 | Id string 14 | Type string 15 | Name string 16 | FullName string 17 | Content string 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: mkcert/mkcert upcert/upcert getcert/getcert 2 | 3 | mkcert/mkcert: mkcert/*.go 4 | (cd mkcert && go build -v -o mkcert) 5 | 6 | upcert/upcert: upcert/*.go 7 | (cd upcert && go build -v -o upcert) 8 | 9 | getcert/getcert: getcert/*.go 10 | (cd getcert && go build -v -o getcert) 11 | 12 | update_getcert: 13 | GOOS=linux GOARCH=amd64 go build -v -o getcert/getcert ./getcert 14 | read -p "Enter user@host: " HOST && rsync --rsync-path="sudo rsync" --chmod=u+rwX,go-rwX -vPz getcert/getcert $$HOST:/usr/bin/getcert 15 | 16 | clean: 17 | rm -f mkcert/mkcert upcert/upcert getcert/getcert 18 | -------------------------------------------------------------------------------- /upcert/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "flag" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "path/filepath" 13 | 14 | "github.com/caiguanhao/ossslim" 15 | ) 16 | 17 | var ( 18 | encryptionKey string 19 | ossAccessKeyId string 20 | ossAccessKeySecret string 21 | ossPrefix string 22 | ossBucket string 23 | ) 24 | 25 | func main() { 26 | flag.Parse() 27 | files := flag.Args() 28 | if len(files) == 0 { 29 | panic("no files") 30 | } 31 | client := ossslim.Client{ 32 | AccessKeyId: ossAccessKeyId, 33 | AccessKeySecret: ossAccessKeySecret, 34 | Prefix: ossPrefix, 35 | Bucket: ossBucket, 36 | } 37 | for _, file := range files { 38 | f, err := ioutil.ReadFile(file) 39 | if err != nil { 40 | panic(err) 41 | } 42 | b, err := encrypt(f) 43 | if err != nil { 44 | panic(err) 45 | } 46 | file = filepath.Base(file) 47 | _, err = client.Upload("/certs/"+file, bytes.NewReader(b), nil, "") 48 | if err != nil { 49 | panic(err) 50 | } 51 | log.Println("uploaded", file) 52 | } 53 | } 54 | 55 | func encrypt(content []byte) ([]byte, error) { 56 | block, err := aes.NewCipher([]byte(encryptionKey)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | aesgcm, err := cipher.NewGCM(block) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | nonce := make([]byte, aesgcm.NonceSize()) 67 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 68 | return nil, err 69 | } 70 | 71 | return aesgcm.Seal(nonce, nonce, content, nil), nil 72 | } 73 | -------------------------------------------------------------------------------- /dns/cloudflare.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | type ( 11 | Cloudflare struct{} 12 | ) 13 | 14 | var _ DNS = (*Cloudflare)(nil) 15 | 16 | func (_ Cloudflare) GetListOfDomains() []string { 17 | cmd := exec.Command("cloudflare", "--raw", "ls") 18 | out, err := cmd.Output() 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | var result []struct { 23 | Name string `json:"name"` 24 | } 25 | err = json.Unmarshal(out, &result) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | domains := []string{} 31 | for _, d := range result { 32 | domains = append(domains, d.Name) 33 | } 34 | return domains 35 | } 36 | 37 | func (_ Cloudflare) GetRecords(domain string) (records []Record) { 38 | cmd := exec.Command("cloudflare", "--raw", "records", domain) 39 | out, err := cmd.Output() 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | var result []struct { 44 | Id string `json:"id"` 45 | Type string `json:"type"` 46 | Name string `json:"name"` 47 | Content string `json:"content"` 48 | } 49 | err = json.Unmarshal(out, &result) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | for _, d := range result { 54 | name := strings.TrimSuffix(strings.TrimSuffix(d.Name, domain), ".") 55 | if name == "" { 56 | name = "@" 57 | } 58 | records = append(records, Record{ 59 | Id: d.Id, 60 | Type: d.Type, 61 | Name: name, 62 | FullName: d.Name, 63 | Content: d.Content, 64 | }) 65 | } 66 | return 67 | } 68 | 69 | func (_ Cloudflare) GetRecordIdsFor(domain, dname, dtype string) []string { 70 | cmd := exec.Command("cloudflare", "--raw", "records", domain) 71 | out, err := cmd.Output() 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | var result []struct { 76 | Id string `json:"id"` 77 | Type string `json:"type"` 78 | Name string `json:"name"` 79 | } 80 | err = json.Unmarshal(out, &result) 81 | if err != nil { 82 | log.Fatal(err) 83 | } 84 | ids := []string{} 85 | for _, d := range result { 86 | withoutRoot := strings.TrimSuffix(strings.TrimSuffix(d.Name, domain), ".") 87 | if withoutRoot == dname && d.Type == dtype { 88 | ids = append(ids, d.Id) 89 | } 90 | } 91 | return ids 92 | } 93 | 94 | func (_ Cloudflare) AddNewRecord(domain, dname, dtype, dvalue string) string { 95 | cmd := exec.Command("cloudflare", "--raw", "addrecord", domain, dname, dtype, dvalue) 96 | out, err := cmd.Output() 97 | if err != nil { 98 | log.Fatal(err) 99 | } 100 | var result struct { 101 | Result struct { 102 | Id string `json:"id"` 103 | } `json:"result"` 104 | } 105 | err = json.Unmarshal(out, &result) 106 | if err != nil { 107 | log.Fatal(err) 108 | } 109 | return result.Result.Id 110 | } 111 | 112 | func (_ Cloudflare) DeleteRecord(domain, id string) { 113 | cmd := exec.Command("cloudflare", "delrecord", domain, id) 114 | _, err := cmd.Output() 115 | if err != nil { 116 | log.Fatal(err) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # certutils 2 | 3 | - `mkcert` Generate wildcard SSL certificates automatically. It helps you set up TXT DNS records on Alidns or Cloudflare. 4 | - `upcert` Upload and encrypt cert files to Aliyun OSS. 5 | - `getcert` Download and decrypt encrypted cert files on Aliyun OSS. 6 | 7 | You can run `go run generate_key.go` to generate `key.go` for upcert and getcert. 8 | 9 | ## mkcert 10 | 11 | Make sure you have installed: 12 | 13 | - [aliyun-cli](https://github.com/aliyun/aliyun-cli) and/or [cloudflare](https://github.com/caiguanhao/cloudflare) 14 | - docker 15 | - docker pull certbot/certbot:v1.10.0 16 | 17 | Note: You may be [rate-limited](https://letsencrypt.org/docs/rate-limits/) if 18 | you are going to make many certs with the same IP address. 19 | 20 | If your have applied too many certs using the same account, then your account 21 | might be blocked. You can use `--email` option to use new account. 22 | 23 | To check for errors, run `docker logs` on the newly created container. 24 | 25 | ## Usage 26 | 27 | ![certutils](https://user-images.githubusercontent.com/1284703/112626352-0ca95180-8e6b-11eb-8eeb-c55930fc1efa.gif) 28 | 29 | ``` 30 | ➜ mkcert "*.example.com" 31 | 2021/01/04 02:21:39 root domain: example.com 32 | 2021/01/04 02:21:39 created container: e7378c26 33 | 2021/01/04 02:21:39 finding TXT records for _acme-challenge 34 | 2021/01/04 02:21:39 found 2 TXT records for _acme-challenge 35 | 2021/01/04 02:21:39 deleting TXT record with id 18862666777171968 36 | 2021/01/04 02:21:40 deleting TXT record with id 18862665550077952 37 | 2021/01/04 02:21:41 waiting acme challenge 38 | 2021/01/04 02:21:45 pressing enter to certbot, waiting for response... 39 | 2021/01/04 02:21:45 received certbot's acme challenge: ubRcq6JSoXynolCWf1TT2nhUlQwEok3Lmig1gryr65c 40 | 2021/01/04 02:21:45 creating new TXT record 41 | 2021/01/04 02:21:45 new record has been created, id: 21028086258404352 42 | 2021/01/04 02:21:45 received certbot's acme challenge: KNHNcYb6fWYw6SdvWTxxmP-ybPfYGt6iLi6jSLia26g 43 | 2021/01/04 02:21:45 creating new TXT record 44 | 2021/01/04 02:21:46 new record has been created, id: 21028086297198592 45 | 2021/01/04 02:21:46 wait 10 seconds for dns records to take effect 46 | 2021/01/04 02:21:56 waiting cert files 47 | 2021/01/04 02:21:56 pressing enter to certbot, waiting for response... 48 | 2021/01/04 02:22:04 copying /etc/letsencrypt/live/example.com/fullchain.pem from e7378c26 49 | 2021/01/04 02:22:04 written file example.com.cert 50 | 2021/01/04 02:22:04 copying /etc/letsencrypt/live/example.com/privkey.pem from e7378c26 51 | 2021/01/04 02:22:04 written file example.com.key 52 | 2021/01/04 02:22:04 successfully generated certificates 53 | 2021/01/04 02:22:04 removing container e7378c26 54 | 2021/01/04 02:22:04 done 55 | 56 | ➜ upcert example.com.* 57 | 2021/01/04 02:22:23 uploaded example.com.cert 58 | 2021/01/04 02:22:23 uploaded example.com.key 59 | 60 | ➜ getcert 61 | 2021/01/04 11:08:40 getting list of certs 62 | 1. example.com{.cert,.key} 4. foobar.com{.cert,.key} 7. helloworld.com{.cert,.key} 63 | 2. example.net{.cert,.key} 5. foobar.net{.cert,.key} 64 | 3. example.org{.cert,.key} 6. foobar.org{.cert,.key} 65 | Enter numbers (separated by comma) to choose files: 1 66 | 2021/01/04 11:08:58 downloading example.com.cert 67 | 2021/01/04 11:08:58 written example.com.cert 68 | 2021/01/04 11:08:58 downloading example.com.key 69 | 2021/01/04 11:08:58 written example.com.key 70 | ``` 71 | -------------------------------------------------------------------------------- /chkcert/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "strings" 10 | "time" 11 | 12 | "github.com/caiguanhao/certutils/dns" 13 | ) 14 | 15 | const ( 16 | ymdhmsFormat = "2006-01-02 15:04:05" 17 | 18 | textChecking = "checking..." 19 | 20 | colorReset = "\x1b[0m" 21 | ) 22 | 23 | const ( 24 | colorCyan = iota 25 | colorGreen 26 | colorRed 27 | colorYellow 28 | ) 29 | 30 | var ( 31 | dialer = &net.Dialer{Timeout: 10 * time.Second} 32 | config = &tls.Config{InsecureSkipVerify: true} 33 | 34 | colors = []string{ 35 | /* colorCyan */ "\x1b[96m", 36 | /* colorGreen */ "\x1b[92m", 37 | /* colorRed */ "\x1b[31m", 38 | /* colorYellow */ "\x1b[33m", 39 | } 40 | ) 41 | 42 | func main() { 43 | dnsType := flag.String("dns", "alidns", "can be alidns, cloudflare") 44 | flag.Usage = func() { 45 | fmt.Println("Usage of chkcert [OPTIONS] [PATTERNS...]") 46 | fmt.Println(` 47 | This utility makes TLS connections to all your domains, checks the 48 | certificates' expiration dates and lists how many days left until expiration 49 | date. 50 | 51 | PATTERNS: Optional. Only check domains contains one of specific strings. 52 | 53 | OPTIONS:`) 54 | flag.PrintDefaults() 55 | } 56 | flag.Parse() 57 | 58 | var client dns.DNS 59 | if *dnsType == "alidns" { 60 | client = dns.Alidns{} 61 | } else if *dnsType == "cloudflare" { 62 | client = dns.Cloudflare{} 63 | } else { 64 | log.Fatal("bad dns type") 65 | } 66 | 67 | patterns := flag.Args() 68 | match := func(name string) bool { 69 | if len(patterns) == 0 { 70 | return true 71 | } 72 | for _, pattern := range patterns { 73 | if strings.Contains(name, pattern) { 74 | return true 75 | } 76 | } 77 | return false 78 | } 79 | 80 | domains := client.GetListOfDomains() 81 | for _, domain := range domains { 82 | for _, record := range client.GetRecords(domain) { 83 | if !match(record.FullName) || record.Type != "A" { 84 | continue 85 | } 86 | fmt.Printf("%40s %s", record.FullName, colorize(textChecking, colorCyan)) 87 | result, color := getExpiry(record.FullName) 88 | if len(result) < len(textChecking) { 89 | result += strings.Repeat(" ", len(textChecking)-len(result)) 90 | } 91 | fmt.Print("\r") 92 | fmt.Printf("%40s %s", record.FullName, colorize(result, color)) 93 | time.Sleep(300 * time.Millisecond) 94 | fmt.Print("\n") 95 | } 96 | } 97 | } 98 | 99 | func getExpiry(host string) (string, int) { 100 | if strings.LastIndex(host, ":") == -1 { 101 | host = host + ":443" 102 | } 103 | conn, err := tls.DialWithDialer(dialer, "tcp", host, config) 104 | if err != nil { 105 | return err.Error(), colorYellow 106 | } 107 | defer conn.Close() 108 | certs := conn.ConnectionState().PeerCertificates 109 | now := time.Now() 110 | var daysMin *int 111 | for _, cert := range certs { 112 | if !now.Before(cert.NotAfter) || !now.After(cert.NotBefore) { 113 | return fmt.Sprintf("expired! (%s - %s)", 114 | cert.NotBefore.Format(ymdhmsFormat), 115 | cert.NotAfter.Format(ymdhmsFormat)), colorRed 116 | } 117 | days := int(time.Until(cert.NotAfter).Hours() / 24) 118 | if daysMin == nil || days < *daysMin { 119 | daysMin = &days 120 | } 121 | } 122 | if daysMin == nil { 123 | return "ok", colorGreen 124 | } 125 | return fmt.Sprintf("ok (%d days left)", *daysMin), colorGreen 126 | } 127 | 128 | func colorize(str string, color int) string { 129 | return colors[color] + string(str) + colorReset 130 | } 131 | -------------------------------------------------------------------------------- /generate_key.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | ) 15 | 16 | func main() { 17 | key, err := hex.DecodeString(os.Getenv("ENCRYPTION_KEY")) 18 | if err != nil { 19 | panic(err) 20 | } 21 | if len(key) == 0 { 22 | key = make([]byte, 32) 23 | _, err := rand.Read(key) 24 | if err != nil { 25 | panic(err) 26 | } 27 | } 28 | akid, aks, err := getAccessKey() 29 | if err != nil { 30 | akid = os.Getenv("OSS_ACCESS_KEY_ID") 31 | aks = os.Getenv("OSS_ACCESS_KEY_SECRET") 32 | } 33 | region := os.Getenv("OSS_REGION") 34 | if region == "" { 35 | reader := bufio.NewReader(os.Stdin) 36 | defaultRegion := "cn-hongkong" 37 | fmt.Print("Enter region (" + defaultRegion + "): ") 38 | region, err = reader.ReadString('\n') 39 | if err != nil { 40 | panic(err) 41 | } 42 | region = strings.TrimSpace(region) 43 | if region == "" { 44 | region = defaultRegion 45 | } 46 | } 47 | bucket := os.Getenv("OSS_BUCKET") 48 | if bucket == "" { 49 | reader := bufio.NewReader(os.Stdin) 50 | for bucket == "" { 51 | fmt.Print("Enter bucket: ") 52 | bucket, err = reader.ReadString('\n') 53 | bucket = strings.TrimSpace(bucket) 54 | if err != nil { 55 | panic(err) 56 | } 57 | } 58 | } 59 | content := `package main 60 | 61 | import ( 62 | "strings" 63 | ) 64 | 65 | func init() { 66 | encryptionKey = strings.Join([]string{` 67 | for i, c := range key { 68 | if i%8 == 0 { 69 | content += "\n\t\t" 70 | } else if i > 0 { 71 | content += " " 72 | } 73 | content += fmt.Sprintf(`"\x%02X",`, c) 74 | } 75 | content += ` 76 | }, "") 77 | 78 | ossAccessKeyId = strings.Join([]string{` 79 | for i, c := range akid { 80 | if i%8 == 0 { 81 | content += "\n\t\t" 82 | } else if i > 0 { 83 | content += " " 84 | } 85 | content += fmt.Sprintf(`"%c",`, c) 86 | } 87 | content += ` 88 | }, "") 89 | 90 | ossAccessKeySecret = strings.Join([]string{` 91 | for i, c := range aks { 92 | if i%8 == 0 { 93 | content += "\n\t\t" 94 | } else if i > 0 { 95 | content += " " 96 | } 97 | content += fmt.Sprintf(`"%c",`, c) 98 | } 99 | content += ` 100 | }, "") 101 | 102 | ossPrefix = "https://` + bucket + `.oss-` + region + `.aliyuncs.com" 103 | 104 | ossBucket = "` + bucket + `" 105 | } 106 | ` 107 | writeFile("upcert/key.go", content) 108 | writeFile("getcert/key.go", content) 109 | } 110 | 111 | func writeFile(file, content string) { 112 | err := ioutil.WriteFile(file, []byte(content), 0644) 113 | if err != nil { 114 | panic(err) 115 | } 116 | log.Println("written", file) 117 | } 118 | 119 | func getAccessKey() (akid string, aks string, err error) { 120 | var home string 121 | home, err = os.UserHomeDir() 122 | if err != nil { 123 | return 124 | } 125 | file := filepath.Join(home, ".aliyun", "config.json") 126 | var fileContent []byte 127 | fileContent, err = ioutil.ReadFile(file) 128 | if err != nil { 129 | return 130 | } 131 | var config struct { 132 | Profiles []struct { 133 | AccessKeyId string `json:"access_key_id"` 134 | AccessKeySecret string `json:"access_key_secret"` 135 | } `json:"profiles"` 136 | } 137 | err = json.Unmarshal(fileContent, &config) 138 | if err != nil { 139 | return 140 | } 141 | if len(config.Profiles) > 0 { 142 | akid = config.Profiles[0].AccessKeyId 143 | aks = config.Profiles[0].AccessKeySecret 144 | } 145 | return 146 | } 147 | -------------------------------------------------------------------------------- /dns/alidns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os/exec" 7 | "strconv" 8 | ) 9 | 10 | type ( 11 | Alidns struct{} 12 | ) 13 | 14 | var _ DNS = (*Alidns)(nil) 15 | 16 | func (_ Alidns) GetListOfDomains() []string { 17 | cmd := exec.Command("aliyun", "alidns", "DescribeDomains") 18 | out, err := cmd.Output() 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | var result struct { 23 | Domains struct { 24 | Domain []struct { 25 | DomainName string 26 | } 27 | } 28 | } 29 | err = json.Unmarshal(out, &result) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | 34 | domains := []string{} 35 | for _, d := range result.Domains.Domain { 36 | domains = append(domains, d.DomainName) 37 | } 38 | return domains 39 | } 40 | 41 | func (a Alidns) GetRecords(domain string) []Record { 42 | return a.getRecords(domain, 1) 43 | } 44 | 45 | func (a Alidns) getRecords(domain string, page int) (records []Record) { 46 | cmd := exec.Command("aliyun", "alidns", "DescribeDomainRecords", 47 | "--DomainName", domain, "--PageNumber", strconv.Itoa(page)) 48 | out, err := cmd.Output() 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | var result struct { 53 | DomainRecords struct { 54 | Record []struct { 55 | RecordId string 56 | RR string 57 | Type string 58 | Value string 59 | } 60 | } 61 | PageNumber int 62 | PageSize int 63 | TotalCount int 64 | } 65 | err = json.Unmarshal(out, &result) 66 | if err != nil { 67 | log.Fatal(err) 68 | } 69 | for _, d := range result.DomainRecords.Record { 70 | fullName := domain 71 | if d.RR != "@" { 72 | fullName = d.RR + "." + fullName 73 | } 74 | records = append(records, Record{ 75 | Id: d.RecordId, 76 | Type: d.Type, 77 | Name: d.RR, 78 | FullName: fullName, 79 | Content: d.Value, 80 | }) 81 | } 82 | totalPages := result.TotalCount/result.PageSize + 1 83 | if result.PageNumber < totalPages { 84 | records = append(records, a.getRecords(domain, result.PageNumber+1)...) 85 | } 86 | return 87 | } 88 | 89 | func (_ Alidns) GetRecordIdsFor(domain, dname, dtype string) []string { 90 | cmd := exec.Command("aliyun", "alidns", "DescribeDomainRecords", "--DomainName", domain) 91 | out, err := cmd.Output() 92 | if err != nil { 93 | log.Fatal(err) 94 | } 95 | var result struct { 96 | DomainRecords struct { 97 | Record []struct { 98 | RecordId string 99 | RR string 100 | Type string 101 | } 102 | } 103 | } 104 | err = json.Unmarshal(out, &result) 105 | if err != nil { 106 | log.Fatal(err) 107 | } 108 | ids := []string{} 109 | for _, d := range result.DomainRecords.Record { 110 | if d.RR == dname && d.Type == dtype { 111 | ids = append(ids, d.RecordId) 112 | } 113 | } 114 | return ids 115 | } 116 | 117 | func (_ Alidns) AddNewRecord(domain, dname, dtype, dvalue string) string { 118 | cmd := exec.Command("aliyun", "alidns", "AddDomainRecord", "--DomainName", domain, 119 | "--RR", dname, "--Type", dtype, "--Value", dvalue) 120 | out, err := cmd.Output() 121 | if err != nil { 122 | log.Fatal(err) 123 | } 124 | var result struct { 125 | RecordId string 126 | } 127 | err = json.Unmarshal(out, &result) 128 | if err != nil { 129 | log.Fatal(err) 130 | } 131 | return result.RecordId 132 | } 133 | 134 | func (_ Alidns) DeleteRecord(domain, id string) { 135 | cmd := exec.Command("aliyun", "alidns", "DeleteDomainRecord", "--RecordId", id) 136 | out, err := cmd.Output() 137 | if err != nil { 138 | log.Fatal(err) 139 | } 140 | var result struct { 141 | RecordId string 142 | } 143 | err = json.Unmarshal(out, &result) 144 | if err != nil { 145 | log.Fatal(err) 146 | } 147 | if result.RecordId != id { 148 | log.Fatal(string(out)) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /getcert/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/aes" 7 | "crypto/cipher" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "flag" 11 | "fmt" 12 | "io" 13 | "io/ioutil" 14 | "log" 15 | "os" 16 | "os/exec" 17 | "path/filepath" 18 | "sort" 19 | "strconv" 20 | "strings" 21 | "sync" 22 | "time" 23 | 24 | "github.com/caiguanhao/ossslim" 25 | ) 26 | 27 | var ( 28 | encryptionKey string 29 | ossAccessKeyId string 30 | ossAccessKeySecret string 31 | ossPrefix string 32 | ossBucket string 33 | 34 | force bool 35 | showDates bool 36 | 37 | suffixes = []string{".cert", ".key"} 38 | 39 | client ossslim.Client 40 | ) 41 | 42 | const ( 43 | certsDir = "certs/" 44 | ) 45 | 46 | func main() { 47 | flag.BoolVar(&force, "f", false, "overwrite existing file") 48 | flag.BoolVar(&showDates, "d", false, "display expiration dates") 49 | flag.Parse() 50 | client = ossslim.Client{ 51 | AccessKeyId: ossAccessKeyId, 52 | AccessKeySecret: ossAccessKeySecret, 53 | Prefix: ossPrefix, 54 | Bucket: ossBucket, 55 | } 56 | targets := flag.Args() 57 | if len(targets) == 0 { 58 | log.Println("getting list of certs") 59 | result, err := client.List(certsDir, false) 60 | if err != nil { 61 | panic(err) 62 | } 63 | names := []string{} 64 | combined := map[string][]string{} 65 | for _, f := range result.Files { 66 | for _, s := range suffixes { 67 | if strings.HasSuffix(f.Name, s) { 68 | name := strings.TrimSuffix(f.Name[len(certsDir):], s) 69 | if _, ok := combined[name]; !ok { 70 | names = append(names, name) 71 | } 72 | combined[name] = append(combined[name], s) 73 | } 74 | } 75 | } 76 | sort.Strings(names) 77 | 78 | var notAfters *sync.Map 79 | if showDates { 80 | notAfters = getNotAfters(names) 81 | } 82 | 83 | printTo := func(w io.Writer) { 84 | for i, name := range names { 85 | var extra string 86 | if notAfters != nil { 87 | if notAfter, ok := notAfters.Load(name); ok { 88 | extra = " - " + notAfter.(string) 89 | } 90 | } 91 | fmt.Fprintf(w, "%d. %s{%s}%s\n", i+1, name, strings.Join(combined[name], ","), extra) 92 | } 93 | } 94 | cmd := exec.Command("column") 95 | cmd.Stdout = os.Stdout 96 | stdin, err := cmd.StdinPipe() 97 | if err == nil { 98 | go func() { 99 | defer stdin.Close() 100 | printTo(stdin) 101 | }() 102 | err = cmd.Run() 103 | } 104 | if err != nil { 105 | printTo(os.Stdout) 106 | } 107 | var selected []int 108 | for len(selected) == 0 { 109 | reader := bufio.NewReader(os.Stdin) 110 | fmt.Print("Enter numbers (separated by comma) to choose files: ") 111 | input, err := reader.ReadString('\n') 112 | if err != nil { 113 | panic(err) 114 | } 115 | input = strings.TrimSpace(input) 116 | numbers := strings.Split(input, ",") 117 | a: 118 | for _, n := range numbers { 119 | num, err := strconv.Atoi(n) 120 | if err != nil { 121 | continue 122 | } 123 | if num < 1 || num > len(names) { 124 | continue 125 | } 126 | for _, s := range selected { 127 | if s == num { 128 | continue a 129 | } 130 | } 131 | selected = append(selected, num) 132 | } 133 | } 134 | for _, s := range selected { 135 | for _, suffix := range combined[names[s-1]] { 136 | targets = append(targets, names[s-1]+suffix) 137 | } 138 | } 139 | } 140 | for _, t := range targets { 141 | if !strings.HasPrefix(t, certsDir) { 142 | t = certsDir + t 143 | } 144 | file := filepath.Base(t) 145 | if !canWrite(file) { 146 | continue 147 | } 148 | log.Println("downloading", file) 149 | var buffer bytes.Buffer 150 | _, err := client.Download(t, &buffer) 151 | if err != nil { 152 | log.Println(err) 153 | continue 154 | } 155 | content, err := decrypt(buffer.Bytes()) 156 | if err != nil { 157 | log.Println(err) 158 | continue 159 | } 160 | err = ioutil.WriteFile(file, content, 0600) 161 | if err != nil { 162 | log.Println(err) 163 | } 164 | log.Println("written", file) 165 | } 166 | } 167 | 168 | func canWrite(path string) bool { 169 | if force { 170 | return true 171 | } 172 | _, err := os.Stat(path) 173 | if os.IsNotExist(err) { 174 | return true 175 | } 176 | if err != nil { 177 | return false 178 | } 179 | reader := bufio.NewReader(os.Stdin) 180 | var input string 181 | for input != "y" && input != "n" { 182 | fmt.Print(path, " already exists. Overwrite? (y/N): ") 183 | input, err = reader.ReadString('\n') 184 | if err != nil { 185 | return false 186 | } 187 | input = strings.ToLower(strings.TrimSpace(input)) 188 | if input == "" { 189 | input = "n" 190 | } 191 | } 192 | return input == "y" 193 | } 194 | 195 | func decrypt(content []byte) ([]byte, error) { 196 | block, err := aes.NewCipher([]byte(encryptionKey)) 197 | if err != nil { 198 | return nil, err 199 | } 200 | 201 | aesgcm, err := cipher.NewGCM(block) 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | nonceSize := aesgcm.NonceSize() 207 | nonce, ciphertext := content[:nonceSize], content[nonceSize:] 208 | 209 | return aesgcm.Open(nil, nonce, ciphertext, nil) 210 | } 211 | 212 | func getNotAfter(name string) (string, error) { 213 | var buffer bytes.Buffer 214 | _, err := client.Download(certsDir+name+".cert", &buffer) 215 | if err != nil { 216 | return "", err 217 | } 218 | content, err := decrypt(buffer.Bytes()) 219 | if err != nil { 220 | return "", err 221 | } 222 | block, _ := pem.Decode(content) 223 | cert, err := x509.ParseCertificate(block.Bytes) 224 | if err != nil { 225 | return "", err 226 | } 227 | days := int(time.Until(cert.NotAfter).Hours() / 24) 228 | return fmt.Sprintf("%s (%d days)", cert.NotAfter.Format("2006-01-02"), days), nil 229 | } 230 | 231 | func getNotAfters(names []string) *sync.Map { 232 | var notAfters sync.Map 233 | log.Println("getting expiration dates of certs") 234 | jobs := make(chan string) 235 | go func() { 236 | defer close(jobs) 237 | for _, name := range names { 238 | jobs <- name 239 | } 240 | }() 241 | concurrency := 5 242 | var wg sync.WaitGroup 243 | wg.Add(concurrency) 244 | for i := 0; i < concurrency; i++ { 245 | go func() { 246 | defer wg.Done() 247 | for name := range jobs { 248 | notAfter, err := getNotAfter(name) 249 | if err != nil { 250 | log.Println(err) 251 | continue 252 | } 253 | notAfters.Store(name, notAfter) 254 | } 255 | }() 256 | } 257 | wg.Wait() 258 | return ¬Afters 259 | } 260 | -------------------------------------------------------------------------------- /mkcert/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/tar" 5 | "bufio" 6 | "bytes" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "os" 13 | "os/exec" 14 | "strings" 15 | "time" 16 | 17 | "github.com/caiguanhao/certutils/dns" 18 | ) 19 | 20 | var ( 21 | debug bool 22 | dryRun bool 23 | email string 24 | 25 | secondsToWait int 26 | shouldClean bool 27 | ) 28 | 29 | func main() { 30 | flag.BoolVar(&debug, "debug", false, "show more info") 31 | dnsType := flag.String("dns", "alidns", "can be alidns, cloudflare") 32 | flag.IntVar(&secondsToWait, "wait", 10, "seconds to wait for dns record to take effect") 33 | flag.BoolVar(&dryRun, "dry-run", false, "dry-run certbot, but dns records will still be modified") 34 | flag.StringVar(&email, "email", "a@a.com", "email for certbot") 35 | flag.BoolVar(&shouldClean, "clean", false, "remove acme challenge txt records for domain and exit") 36 | flag.Usage = func() { 37 | fmt.Println("Usage of mkcert [OPTIONS] [NAMES...]") 38 | fmt.Println(` 39 | This utility obtains certbot's (Let's Encrypt) wildcard certificates by 40 | updating DNS TXT records and answering stupid certbot questions for you. 41 | 42 | NAMES: Provide at least one domain. All domain names must start with "*.". 43 | 44 | NOTE: You may be [rate-limited](https://letsencrypt.org/docs/rate-limits/) 45 | if you are going to make many certs with the same IP address. 46 | 47 | NOTE: Certbot runs in a Docker container, the certificate files 48 | ("example.com.cert" and "example.com.key") will be copied from the container to 49 | the working directory (will be overwritten without prompt if same file exists). 50 | If you still need your old certificate files, please backup first. 51 | 52 | OPTIONS:`) 53 | flag.PrintDefaults() 54 | } 55 | flag.Parse() 56 | 57 | var client dns.DNS 58 | if *dnsType == "alidns" { 59 | client = dns.Alidns{} 60 | } else if *dnsType == "cloudflare" { 61 | client = dns.Cloudflare{} 62 | } else { 63 | log.Fatal("Error: bad dns type") 64 | } 65 | 66 | targets := flag.Args() 67 | 68 | if len(targets) == 0 { 69 | log.Fatal("please provide wildcard domain name like this: *.example.com") 70 | } 71 | 72 | for i, target := range targets { 73 | target = strings.TrimRight(target, ".") 74 | if strings.Count(target, "*") == 0 { 75 | targets[i] = "*." + target 76 | fmt.Fprintf(os.Stderr, `Did you mean "%s"? (Y/n) `, targets[i]) 77 | var answer string 78 | fmt.Scanln(&answer) 79 | answer = strings.ToLower(strings.TrimSpace(answer)) 80 | if answer != "" && answer != "y" { 81 | log.Fatal("Aborted") 82 | return 83 | } 84 | } else if strings.Count(target, "*") > 1 || !strings.HasPrefix(target, "*.") { 85 | log.Fatalf("Error: domain name %s must start with one '*.'", target) 86 | } 87 | } 88 | 89 | for i, target := range targets { 90 | if i > 0 { 91 | log.Println(strings.Repeat("=", 40)) 92 | } 93 | get(client, target) 94 | } 95 | } 96 | 97 | func get(client dns.DNS, target string) { 98 | log.Println("processing", target) 99 | targetWithoutWildcard := strings.TrimPrefix(target, "*.") 100 | acme := strings.Replace(target, "*", "_acme-challenge", 1) 101 | domains := client.GetListOfDomains() 102 | root := "" 103 | for _, domain := range domains { 104 | if strings.HasSuffix(target, domain) { 105 | root = domain 106 | break 107 | } 108 | } 109 | if root == "" { 110 | log.Fatalln("Error: you don't have root domain for", target) 111 | } 112 | acmeWithoutRoot := strings.TrimSuffix(strings.TrimSuffix(acme, root), ".") 113 | 114 | log.Println("root domain:", root) 115 | 116 | if shouldClean { 117 | for _, id := range client.GetRecordIdsFor(root, acmeWithoutRoot, "TXT") { 118 | log.Println("deleting TXT record with id", id) 119 | client.DeleteRecord(root, id) 120 | } 121 | return 122 | } 123 | 124 | containerId := newContainer(target) 125 | containerId = containerId[:8] 126 | log.Println("created container:", containerId) 127 | 128 | log.Println("finding TXT records for", acmeWithoutRoot) 129 | ids := client.GetRecordIdsFor(root, acmeWithoutRoot, "TXT") 130 | if len(ids) == 0 { 131 | log.Println("no TXT records for", acmeWithoutRoot, "yet!") 132 | } else { 133 | log.Println("found", len(ids), "TXT records for", acmeWithoutRoot) 134 | for _, id := range ids { 135 | log.Println("deleting TXT record with id", id) 136 | client.DeleteRecord(root, id) 137 | } 138 | } 139 | 140 | c := newCertbot() 141 | go c.start(containerId, acme) 142 | 143 | log.Println("waiting acme challenge") 144 | challenges := []string{} 145 | for challenge := range c.acmeChallengeChan { 146 | challenges = append(challenges, challenge) 147 | } 148 | for _, challenge := range challenges { 149 | log.Println("received certbot's acme challenge:", challenge) 150 | log.Println("creating new TXT record") 151 | id := client.AddNewRecord(root, acmeWithoutRoot, "TXT", challenge) 152 | log.Println("new record has been created, id:", id) 153 | } 154 | log.Println("wait", secondsToWait, "seconds for dns records to take effect") 155 | time.Sleep(time.Duration(secondsToWait) * time.Second) 156 | c.continueChan <- true 157 | if !dryRun { 158 | log.Println("waiting cert files") 159 | cert := copyFileFromContainer(containerId, <-c.pemFileChan) 160 | writeFile(targetWithoutWildcard+".cert", cert) 161 | key := copyFileFromContainer(containerId, <-c.keyFileChan) 162 | writeFile(targetWithoutWildcard+".key", key) 163 | } 164 | <-c.doneChan 165 | removeContainer(containerId) 166 | log.Println("done:", target) 167 | } 168 | 169 | func writeFile(file string, content []byte) { 170 | if len(content) == 0 { 171 | log.Fatal(file, "is empty") 172 | } 173 | err := ioutil.WriteFile(file, content, 0644) 174 | if err != nil { 175 | log.Fatal(err) 176 | } 177 | log.Println("written file", file) 178 | } 179 | 180 | func newContainer(domain string) string { 181 | domainWithoutWildcard := strings.TrimPrefix(domain, "*.") 182 | command := []string{ 183 | "docker", "create", "-i", "--platform", "linux/amd64", 184 | "certbot/certbot:v1.10.0", "certonly", "--manual", 185 | "--preferred-challenges=dns", "--email", email, 186 | "--server", "https://acme-v02.api.letsencrypt.org/directory", 187 | "--agree-tos", "-d", domain, "-d", domainWithoutWildcard, 188 | } 189 | if dryRun { 190 | command = append(command, "--dry-run") 191 | } 192 | if debug { 193 | log.Println("running", command) 194 | } 195 | cmd := exec.Command(command[0], command[1:]...) 196 | out, err := cmd.CombinedOutput() 197 | if err != nil { 198 | log.Fatal(string(out)) 199 | } 200 | return strings.TrimSpace(string(out)) 201 | } 202 | 203 | func removeContainer(containerId string) { 204 | log.Println("removing container", containerId) 205 | cmd := exec.Command("docker", "rm", "-fv", containerId) 206 | err := cmd.Run() 207 | if err != nil { 208 | log.Fatal(err) 209 | } 210 | } 211 | 212 | func copyFileFromContainer(containerId, file string) []byte { 213 | log.Println("copying", file, "from", containerId) 214 | cmd := exec.Command("docker", "cp", "--follow-link", containerId+":"+file, "-") 215 | stdout, err := cmd.StdoutPipe() 216 | if err != nil { 217 | log.Fatal(err) 218 | } 219 | err = cmd.Start() 220 | if err != nil { 221 | log.Fatal(err) 222 | } 223 | tr := tar.NewReader(stdout) 224 | var buf bytes.Buffer 225 | for { 226 | _, err := tr.Next() 227 | if err == io.EOF { 228 | break // End of archive 229 | } 230 | if err != nil { 231 | log.Fatal(err) 232 | } 233 | if _, err := io.Copy(&buf, tr); err != nil { 234 | log.Fatal(err) 235 | } 236 | } 237 | err = cmd.Wait() 238 | if err != nil { 239 | log.Fatal(err) 240 | } 241 | return buf.Bytes() 242 | } 243 | 244 | type certbot struct { 245 | acmeChallengeChan, pemFileChan, keyFileChan chan string 246 | continueChan, doneChan chan bool 247 | } 248 | 249 | func newCertbot() *certbot { 250 | return &certbot{ 251 | acmeChallengeChan: make(chan string), 252 | pemFileChan: make(chan string), 253 | keyFileChan: make(chan string), 254 | continueChan: make(chan bool), 255 | doneChan: make(chan bool), 256 | } 257 | } 258 | 259 | func (c *certbot) start(containerId, acme string) { 260 | cmd := exec.Command("docker", "start", "-ai", containerId) 261 | stdin, err := cmd.StdinPipe() 262 | if err != nil { 263 | log.Fatal(err) 264 | } 265 | defer stdin.Close() 266 | go func() { 267 | for { 268 | select { 269 | case <-c.continueChan: 270 | log.Println("pressing enter to certbot, waiting for response...") 271 | io.WriteString(stdin, "\n") 272 | } 273 | } 274 | }() 275 | stdout, err := cmd.StdoutPipe() 276 | if err != nil { 277 | log.Fatal(err) 278 | } 279 | stderr, err := cmd.StderrPipe() 280 | if err != nil { 281 | log.Fatal(err) 282 | } 283 | err = cmd.Start() 284 | if err != nil { 285 | log.Fatal(err) 286 | } 287 | scanner := bufio.NewScanner(stdout) 288 | mode, success, acmeCount := 0, false, 0 289 | for scanner.Scan() { 290 | t := scanner.Text() 291 | if debug { 292 | log.Println("certbot:", t) 293 | } 294 | switch mode { 295 | case 1: 296 | if strings.Contains(t, acme) { 297 | mode = 2 298 | } 299 | case 2: 300 | if t == "" { 301 | continue 302 | } 303 | c.acmeChallengeChan <- t 304 | acmeCount += 1 305 | if acmeCount == 1 { 306 | c.continueChan <- true 307 | } else if acmeCount == 2 { 308 | close(c.acmeChallengeChan) 309 | } 310 | mode = 3 311 | default: 312 | if strings.Contains(t, "deploy a DNS TXT record") { 313 | mode = 1 314 | } else if strings.Contains(t, "successful") || strings.Contains(t, "Congratulations") { 315 | success = true 316 | } else if strings.Contains(t, "fullchain.pem") { 317 | c.pemFileChan <- strings.TrimSpace(t) 318 | } else if strings.Contains(t, "privkey.pem") { 319 | c.keyFileChan <- strings.TrimSpace(t) 320 | } 321 | } 322 | } 323 | stderrBytes, _ := io.ReadAll(stderr) 324 | err = cmd.Wait() 325 | if err != nil { 326 | stderr := strings.TrimSpace(string(stderrBytes)) 327 | if stderr != "" { 328 | lines := strings.Split(stderr, "\n") 329 | for _, line := range lines { 330 | log.Println("\x1b[31mSTDERR:", line, "\x1b[0m") 331 | } 332 | } 333 | log.Fatal(err) 334 | } 335 | if success { 336 | log.Println("successfully generated certificates") 337 | } else { 338 | log.Println("failed to generate certificates") 339 | } 340 | c.doneChan <- true 341 | } 342 | --------------------------------------------------------------------------------