diff --git a/go.mod b/go.mod index 6aedf5e..624342c 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,11 @@ require ( github.com/go-acme/lego/v4 v4.19.2 gopkg.in/yaml.v3 v3.0.1 ) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/text v0.18.0 // indirect +) diff --git a/go.sum b/go.sum index 8150f26..9580bf8 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,23 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-acme/lego/v4 v4.19.2 h1:Y8hrmMvWETdqzzkRly7m98xtPJJivWFsgWi8fcvZo+Y= github.com/go-acme/lego/v4 v4.19.2/go.mod h1:wtDe3dDkmV4/oI2nydpNXSJpvV10J9RCyZ6MbYxNtlQ= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index 5992d3a..beaa632 100644 --- a/main.go +++ b/main.go @@ -3,17 +3,24 @@ package main import ( "acme-client/src" "bufio" - "encoding/json" - "fmt" "github.com/go-acme/lego/v4/log" - "io" - "net/http" "os" - "strconv" "strings" ) func main() { + validConf() + // 获取指令, 如果没有则提示添加 + args := os.Args + if len(args) < 2 { + log.Println("请输入命令; 如: add") + log.Println("或者输入 help 查看帮助") + return + } + src.OnCommand() +} + +func validConf() { // 读取server参数,如果没有提示添加 config := src.GetClientConfig() server := config.Server @@ -42,214 +49,4 @@ func main() { config.RsaPublicKey = rsaPublicKeyContent src.WriteConfig() } - - // 获取指令, 如果没有则提示添加 - args := os.Args - if len(args) < 2 { - log.Println("请输入命令; 如: add") - log.Println("或者输入 help 查看帮助") - return - } - onCommand() -} - -func onCommand() { - command := os.Args[1] - switch command { - case "help": - showHelp() - case "list": - showList() - case "add": - addConf() - case "del": - delConf() - case "edit": - editConf() - case "get": - getCert() - case "-s": - onServerCommand() - default: - log.Fatalf("不支持的指令: %s\n您可以使用 help 查看帮助", command) - } -} - -// 帮助 -func showHelp() { - fmt.Printf("help\t查看帮助\n") - fmt.Printf("list\t查看配置列表\n") - fmt.Printf("add\t添加配置\n") - fmt.Printf("del\t删除配置\n") - fmt.Printf("edit\t修改配置\n") - fmt.Printf("get\t从获取端获取证书,会自动判断有效期\n") - fmt.Printf("\t[-f]\t从获取端获取证书,不判断有效期\n") - fmt.Printf("-s list\t查看服务端已配置的Domain名称\n") -} - -// 显示配置列表 -func showList() { - domains := src.GetClientConfig().Domains - if len(domains) == 0 { - fmt.Println("暂无配置; 您可以使用 add 命令添加配置") - return - } - for i := range domains { - domain := domains[i] - fmt.Printf("%d. %s\n", i, domain.Name) - fmt.Printf(" - 证书文件: %s\n", domain.CertFile) - fmt.Printf(" - 密钥文件: %s\n", domain.KeyFile) - fmt.Printf(" - 详情文件: %s\n", domain.InfoFile) - fmt.Println() - } -} - -// 添加配置 -func addConf() { - domain := &src.Domain{} - name := scanConf("请输入服务端已配置名称;可以通过 -s list 查看", "名称不能为空") - domain.Name = name - domain.CertFile = scanConf("请输入证书文件存放全路径;如: /data/cert/fullchain.pem", "证书文件路径不能为空") - domain.KeyFile = scanConf("请输入私钥文件存放全路径;如: /data/cert/privkey.pem", "私钥文件路径不能为空") - domain.InfoFile = scanConf("请输入详情文件存放全路径;如: /data/cert/info.json", "详情文件路径不能为空") - config := src.GetClientConfig() - config.Domains = append(config.Domains, *domain) - src.WriteConfig() - fmt.Println("添加成功") - showList() -} - -// 删除配置 -func delConf() { - fmt.Println("当前配置: ") - showList() - indexInt, _ := selectDomain("请输入要删除的序号") - config := src.GetClientConfig() - config.Domains = append(config.Domains[:indexInt], config.Domains[indexInt+1:]...) - src.WriteConfig() - fmt.Println("删除成功") - showList() -} - -// 修改配置 -func editConf() { - fmt.Println("当前配置") - showList() - _, domain := selectDomain("请输入要修改的序号") - msg := fmt.Sprintf("请输入服务端已配置名称;可以通过 -s list 查看\n当前配置: %s\n", domain.Name) - domain.Name = scanConf(msg, "名称不能为空") - certFile := fmt.Sprintf("请输入证书文件存放全路径;如: /data/cert/fullchain.pem\n当前配置: %s\n", domain.CertFile) - domain.CertFile = scanConf(certFile, "证书文件路径不能为空") - msg = fmt.Sprintf("请输入私钥文件存放全路径;如: /data/cert/privkey.pem\n当前配置: %s\n", domain.KeyFile) - domain.KeyFile = scanConf(msg, "私钥文件路径不能为空") - msg = fmt.Sprintf("请输入详情文件存放全路径;如: /data/cert/info.json\n当前配置: %s\n", domain.InfoFile) - domain.InfoFile = scanConf(msg, "详情文件路径不能为空") - src.WriteConfig() - fmt.Println("修改成功") - showList() -} - -func getCert() { - _, domain := selectDomain("请选择要获取证书的配置编号") - args := os.Args - isDoGet := len(args) >= 3 && args[3] == "-f" - if !isDoGet { - infoFile := domain.InfoFile - os.Stat(infoFile) - //info, err := os.ReadFile(infoFile) - //keyFile := domain.KeyFile - } - - //config := src.GetClientConfig() - //domains := config.Domains - //config.FindDomain(args[2]) -} - -// 服务端命令 -func onServerCommand() { - args := os.Args - if len(args) < 3 { - log.Fatal("参数错误, 请检查") - } - command := args[2] - switch command { - case "list": - showServerList() - default: - log.Fatal("参数错误, 请检查") - } -} - -// 从服务端获取域名列表 -func showServerList() { - server := src.GetClientConfig().Server - token, encryptToken := src.GenToken() - url := server + "/api/v1/domain/list?token=" + encryptToken - resp, err := http.Get(url) - if err != nil { - log.Fatal("获取服务端数据失败, 请检查 server 地址是否正确") - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Fatal(err) - } - result := src.Result{} - err = json.Unmarshal(body, &result) - if err != nil { - log.Fatal(err) - } - if result.Code != 200 { - log.Fatal("读取数据出错; ", result.Msg) - } - data := result.Data - text := src.DecryptByToken(token, data) - d := &[]src.SDomain{} - err = json.Unmarshal([]byte(text), d) - if err != nil { - log.Fatal(err) - } - for i := range *d { - domain := (*d)[i] - fmt.Printf("- %s\n", domain.Name) - fmt.Printf("\t认证域名: [ ") - for j := range domain.Host { - host := domain.Host[j] - fmt.Printf("%s ", host) - } - fmt.Printf("]\n") - } -} - -// 读取用户输入 -func scanConf(msg string, errMsg string) string { - for { - log.Println(msg) - reader := bufio.NewReader(os.Stdin) - name, err := reader.ReadString('\n') - if err != nil { - fmt.Println("读取失败;", err) - continue - } - name = strings.Trim(name, "\r\n") - if name == "" { - fmt.Println(errMsg) - continue - } - return name - } -} - -// 选择域名 -func selectDomain(msg string) (int, *src.Domain) { - index := scanConf(msg, "序号不能为空") - indexInt, err := strconv.Atoi(index) - if err != nil { - log.Fatal("序号错误") - } - config := src.GetClientConfig() - if indexInt >= len(config.Domains) || indexInt < 0 { - log.Fatal("序号超出范围") - } - return indexInt, &config.Domains[indexInt] } diff --git a/src/command.go b/src/command.go new file mode 100644 index 0000000..f129668 --- /dev/null +++ b/src/command.go @@ -0,0 +1,334 @@ +package src + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "github.com/go-acme/lego/v4/log" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +func OnCommand() { + command := os.Args[1] + switch command { + case "help": + showHelp() + case "list": + showList() + case "add": + addConf() + case "del": + delConf() + case "edit": + editConf() + case "get": + getCert() + case "-s": + onServerCommand() + default: + log.Fatalf("不支持的指令: %s\n您可以使用 help 查看帮助", command) + } +} + +// 帮助 +func showHelp() { + fmt.Printf("help\t查看帮助\n") + fmt.Printf("list\t查看配置列表\n") + fmt.Printf("add\t添加配置\n") + fmt.Printf("del\t删除配置\n") + fmt.Printf("edit\t修改配置\n") + fmt.Printf("get\t从获取端获取证书,会自动判断有效期\n") + fmt.Printf("\t[-f]\t从获取端获取证书,不判断有效期\n") + fmt.Printf("-s list\t查看服务端已配置的Domain名称\n") +} + +// 显示配置列表 +func showList() { + domains := GetClientConfig().Domains + if len(domains) == 0 { + fmt.Println("暂无配置; 您可以使用 add 命令添加配置") + return + } + for i := range domains { + domain := domains[i] + fmt.Printf("%d. %s\n", i, domain.Name) + fmt.Printf(" - 证书文件: %s\n", domain.CertFile) + fmt.Printf(" - 密钥文件: %s\n", domain.KeyFile) + fmt.Printf(" - 详情文件: %s\n", domain.InfoFile) + fmt.Println() + } +} + +// 添加配置 +func addConf() { + domain := &Domain{} + name := scanConf("请输入服务端已配置名称;可以通过 -s list 查看", "名称不能为空") + domain.Name = name + domain.CertFile = scanConf("请输入证书文件存放全路径;如: /data/cert/fullchain.pem", "证书文件路径不能为空") + domain.KeyFile = scanConf("请输入私钥文件存放全路径;如: /data/cert/privkey.pem", "私钥文件路径不能为空") + domain.InfoFile = scanConf("请输入详情文件存放全路径;如: /data/cert/info.json", "详情文件路径不能为空") + config := GetClientConfig() + config.Domains = append(config.Domains, *domain) + WriteConfig() + fmt.Println("添加成功") + showList() +} + +// 删除配置 +func delConf() { + fmt.Println("当前配置: ") + showList() + indexInt, _ := selectDomain("请输入要删除的序号") + config := GetClientConfig() + config.Domains = append(config.Domains[:indexInt], config.Domains[indexInt+1:]...) + WriteConfig() + fmt.Println("删除成功") + showList() +} + +// 修改配置 +func editConf() { + fmt.Println("当前配置") + showList() + _, domain := selectDomain("请输入要修改的序号") + msg := fmt.Sprintf("请输入服务端已配置名称;可以通过 -s list 查看\n当前配置: %s\n", domain.Name) + domain.Name = scanConf(msg, "名称不能为空") + certFile := fmt.Sprintf("请输入证书文件存放全路径;如: /data/cert/fullchain.pem\n当前配置: %s\n", domain.CertFile) + domain.CertFile = scanConf(certFile, "证书文件路径不能为空") + msg = fmt.Sprintf("请输入私钥文件存放全路径;如: /data/cert/privkey.pem\n当前配置: %s\n", domain.KeyFile) + domain.KeyFile = scanConf(msg, "私钥文件路径不能为空") + msg = fmt.Sprintf("请输入详情文件存放全路径;如: /data/cert/info.json\n当前配置: %s\n", domain.InfoFile) + domain.InfoFile = scanConf(msg, "详情文件路径不能为空") + WriteConfig() + fmt.Println("修改成功") + showList() +} + +func getCert() { + fmt.Println("当前配置") + showList() + _, domain := selectDomain("请选择要获取证书的配置编号") + args := os.Args + isDoGet := len(args) >= 3 && args[3] == "-f" + + certFile := domain.CertFile + keyFile := domain.KeyFile + infoFile := domain.InfoFile + + isDoGet = isDoGet || !isExist(certFile) + isDoGet = isDoGet || !isExist(keyFile) + isDoGet = isDoGet || !isExist(infoFile) + + if !isDoGet { + info, _ := readInfoFile(infoFile) + if info == nil { + isDoGet = true + } + if !isDoGet { + if info.Info.NotAfter.Sub(time.Now()) < 7*24*time.Hour { + isDoGet = true + } + } + } + if !isDoGet { + log.Println("日志文件已存在, 无需更新.\n如果需要强制更新, 请使用命令: get -f") + return + } + domainData, err := doGetCert(domain.Name) + if err != nil { + fmt.Println("从服务端获取配置失败", err) + return + } + writeCert(domain, domainData) + fmt.Printf("证书相关文件写出成功,配置名称: %s\n", domain.Name) +} + +func doGetCert(name string) (*DomainData, error) { + config := GetClientConfig() + server := config.Server + token, encryptToken := GenToken() + url := server + "/api/v1/cert?name=" + name + "&token=" + encryptToken + data, err := httpGet(url, token) + if err != nil { + return nil, err + } + result := DomainData{} + err = json.Unmarshal([]byte(data), &result) + if err != nil { + return nil, err + } + return &result, nil +} + +// 服务端命令 +func onServerCommand() { + args := os.Args + if len(args) < 3 { + log.Fatal("参数错误, 请检查") + } + command := args[2] + switch command { + case "list": + showServerList() + default: + log.Fatal("参数错误, 请检查") + } +} + +// 从服务端获取域名列表 +func showServerList() { + server := GetClientConfig().Server + token, encryptToken := GenToken() + url := server + "/api/v1/domain/list?token=" + encryptToken + resp, err := http.Get(url) + if err != nil { + log.Fatal("获取服务端数据失败, 请检查 server 地址是否正确") + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + result := Result{} + err = json.Unmarshal(body, &result) + if err != nil { + log.Fatal(err) + } + if result.Code != 200 { + log.Fatal("读取数据出错; ", result.Msg) + } + data := result.Data + text := DecryptByToken(token, data) + d := &[]SDomain{} + err = json.Unmarshal([]byte(text), d) + if err != nil { + log.Fatal(err) + } + for i := range *d { + domain := (*d)[i] + fmt.Printf("- %s\n", domain.Name) + fmt.Printf("\t认证域名: [ ") + for j := range domain.Host { + host := domain.Host[j] + fmt.Printf("%s ", host) + } + fmt.Printf("]\n") + } +} + +// 读取用户输入 +func scanConf(msg string, errMsg string) string { + for { + log.Println(msg) + reader := bufio.NewReader(os.Stdin) + name, err := reader.ReadString('\n') + if err != nil { + fmt.Println("读取失败;", err) + continue + } + name = strings.Trim(name, "\r\n") + if name == "" { + fmt.Println(errMsg) + continue + } + return name + } +} + +// 选择域名 +func selectDomain(msg string) (int, *Domain) { + index := scanConf(msg, "序号不能为空") + indexInt, err := strconv.Atoi(index) + if err != nil { + log.Fatal("序号错误") + } + config := GetClientConfig() + if indexInt >= len(config.Domains) || indexInt < 0 { + log.Fatal("序号超出范围") + } + return indexInt, &config.Domains[indexInt] +} + +// 判断文件是否存在 +func isExist(file string) bool { + _, err := os.Stat(file) + return !os.IsNotExist(err) +} + +// 读取证书详情文件 +func readInfoFile(file string) (*CertInfo, error) { + readFile, err := os.ReadFile(file) + if err != nil { + return nil, err + } + var certInfo *CertInfo + err = json.Unmarshal(readFile, &certInfo) + return certInfo, err +} + +// http get +func httpGet(url string, token string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + result := Result{} + err = json.Unmarshal(body, &result) + if err != nil { + return "", err + } + if result.Code != 200 { + return "", errors.New(result.Msg) + } + data := result.Data + return DecryptByToken(token, data), nil +} + +// 写出证书 +func writeCert(domain *Domain, data *DomainData) { + certFile := domain.CertFile + keyFile := domain.KeyFile + infoFile := domain.InfoFile + + mkFileDir(certFile) + mkFileDir(keyFile) + mkFileDir(infoFile) + + fullchain := data.Fullchain + key := data.Key + info := data.Info + + if err := os.WriteFile(certFile, []byte(fullchain), 0755); err != nil { + log.Fatal("写出证书文件失败!", err) + } + if err := os.WriteFile(keyFile, []byte(key), 0755); err != nil { + log.Fatal("写出秘钥文件失败!", err) + } + if err := os.WriteFile(infoFile, []byte(info), 0755); err != nil { + log.Fatal("写出证书详情文件失败!", err) + } +} + +// 创建文件所在的目录 +func mkFileDir(file string) { + dir := filepath.Dir(file) + if _, err := os.Stat(dir); os.IsNotExist(err) { + fmt.Printf("证书文件夹 %s 不存在,自动创建...\n", dir) + err := os.MkdirAll(dir, 0755) + if err != nil { + log.Fatal("创建证书文件夹失败!", err) + } + } +} diff --git a/src/scommand.go b/src/scommand.go new file mode 100644 index 0000000..c6d5f54 --- /dev/null +++ b/src/scommand.go @@ -0,0 +1 @@ +package src diff --git a/src/variable.go b/src/variable.go index f5dbebf..4ea39e6 100644 --- a/src/variable.go +++ b/src/variable.go @@ -1,5 +1,10 @@ package src +import ( + "crypto/x509" + "github.com/go-acme/lego/v4/certificate" +) + var clientConfig ClientConfig = ReadConfig() func GetClientConfig() *ClientConfig { @@ -21,3 +26,14 @@ type SDomain struct { Email string Host []string } + +type CertInfo struct { + Cert certificate.Resource + Info x509.Certificate +} + +type DomainData struct { + Fullchain string `json:"fullchain"` + Key string `json:"key"` + Info string `json:"info"` +}