nginx-proxy-manager/backend/internal/acme/acmesh.go
2023-07-25 11:59:02 +10:00

211 lines
5.3 KiB
Go

package acme
// Some light reading:
// https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert
import (
"fmt"
"os"
"os/exec"
"strings"
"npm/internal/config"
"npm/internal/entity/certificateauthority"
"npm/internal/entity/dnsprovider"
"npm/internal/logger"
"github.com/rotisserie/eris"
)
func getAcmeShFilePath() (string, error) {
path, err := exec.LookPath("acme.sh")
if err != nil {
return path, eris.Wrapf(err, "Cannot find acme.sh execuatable script in PATH")
}
return path, nil
}
func getCommonEnvVars() []string {
return []string{
fmt.Sprintf("ACMESH_CONFIG_HOME=%s", os.Getenv("ACMESH_CONFIG_HOME")),
fmt.Sprintf("ACMESH_HOME=%s", os.Getenv("ACMESH_HOME")),
fmt.Sprintf("CERT_HOME=%s", os.Getenv("CERT_HOME")),
fmt.Sprintf("LE_CONFIG_HOME=%s", os.Getenv("LE_CONFIG_HOME")),
fmt.Sprintf("LE_WORKING_DIR=%s", os.Getenv("LE_WORKING_DIR")),
}
}
// GetAcmeShVersion will return the acme.sh script version
func GetAcmeShVersion() string {
if r, err := shExec([]string{"--version"}, nil); err == nil {
// modify the output
r = strings.Trim(r, "\n")
v := strings.Split(r, "\n")
return v[len(v)-1]
}
return ""
}
// CreateAccountKey is required for each server initially
func CreateAccountKey(ca *certificateauthority.Model) error {
args := []string{"--create-account-key", "--accountkeylength", "2048"}
if ca != nil {
logger.Info("Acme.sh CreateAccountKey for %s", ca.AcmeshServer)
args = append(args, "--server", ca.AcmeshServer)
if ca.CABundle != "" {
args = append(args, "--ca-bundle", ca.CABundle)
}
} else {
logger.Info("Acme.sh CreateAccountKey")
}
args = append(args, getCommonArgs()...)
ret, err := shExec(args, nil)
if err != nil {
return err
}
logger.Debug("CreateAccountKey returned:\n%+v", ret)
return nil
}
// RequestCert does all the heavy lifting
func RequestCert(domains []string, method, outputFullchainFile, outputKeyFile string, dnsProvider *dnsprovider.Model, ca *certificateauthority.Model, force bool) (string, error) {
args, err := buildCertRequestArgs(domains, method, outputFullchainFile, outputKeyFile, dnsProvider, ca, force)
if err != nil {
return err.Error(), err
}
envs := make([]string, 0)
if dnsProvider != nil {
envs, err = dnsProvider.GetAcmeShEnvVars()
if err != nil {
return err.Error(), err
}
}
ret, err := shExec(args, envs)
if err != nil {
return ret, err
}
return "", nil
}
// shExec executes the acme.sh with arguments
func shExec(args []string, envs []string) (string, error) {
acmeSh, err := getAcmeShFilePath()
if err != nil {
logger.Error("AcmeShError", err)
return "", err
}
logger.Debug("CMD: %s %v", acmeSh, args)
// nolint: gosec
c := exec.Command(acmeSh, args...)
c.Env = append(getCommonEnvVars(), envs...)
b, e := c.CombinedOutput()
if e != nil {
// logger.Error("AcmeShError", eris.Wrapf(e, "Command error: %s -- %v\n%+v", acmeSh, args, e))
logger.Warn(string(b))
}
return string(b), e
}
func getCommonArgs() []string {
args := make([]string, 0)
if config.Configuration.Acmesh.Home != "" {
args = append(args, "--home", config.Configuration.Acmesh.Home)
}
if config.Configuration.Acmesh.ConfigHome != "" {
args = append(args, "--config-home", config.Configuration.Acmesh.ConfigHome)
}
if config.Configuration.Acmesh.CertHome != "" {
args = append(args, "--cert-home", config.Configuration.Acmesh.CertHome)
}
args = append(args, "--log", "/data/logs/acme.sh.log")
args = append(args, "--debug", "2")
return args
}
// This is split out into it's own function so it's testable
func buildCertRequestArgs(
domains []string,
method,
outputFullchainFile,
outputKeyFile string,
dnsProvider *dnsprovider.Model,
ca *certificateauthority.Model,
force bool,
) ([]string, error) {
// The argument order matters.
// see https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert#3-multiple-domains-san-mode--hybrid-mode
// for multiple domains and note that the method of validation is required just after the domain arg, each time.
// TODO log file location configurable
args := []string{"--issue"}
if ca != nil {
args = append(args, "--server", ca.AcmeshServer)
if ca.CABundle != "" {
args = append(args, "--ca-bundle", ca.CABundle)
}
}
if outputFullchainFile != "" {
args = append(args, "--fullchain-file", outputFullchainFile)
}
if outputKeyFile != "" {
args = append(args, "--key-file", outputKeyFile)
}
methodArgs := make([]string, 0)
switch method {
case "dns":
if dnsProvider == nil {
return nil, ErrDNSNeedsDNSProvider
}
methodArgs = append(methodArgs, "--dns", dnsProvider.AcmeshName)
if dnsProvider.DNSSleep > 0 {
// See: https://github.com/acmesh-official/acme.sh/wiki/dnscheck
methodArgs = append(methodArgs, "--dnssleep", fmt.Sprintf("%d", dnsProvider.DNSSleep))
}
case "http":
if dnsProvider != nil {
return nil, ErrHTTPHasDNSProvider
}
methodArgs = append(methodArgs, "-w", config.Configuration.Acmesh.GetWellknown())
default:
return nil, ErrMethodNotSupported
}
hasMethod := false
// Add domains to args
for _, domain := range domains {
args = append(args, "-d", domain)
// Method has to appear after each domain
if !hasMethod {
args = append(args, methodArgs...)
hasMethod = true
}
}
if force {
args = append(args, "--force")
}
args = append(args, getCommonArgs()...)
return args, nil
}