mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2025-08-28 11:40:04 +00:00
Work on acme.sh hander
and dns providers
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
package acme
|
||||
|
||||
// Some light reading:
|
||||
// https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
@@ -10,6 +13,7 @@ import (
|
||||
|
||||
"npm/embed"
|
||||
"npm/internal/config"
|
||||
"npm/internal/entity/dnsprovider"
|
||||
"npm/internal/logger"
|
||||
)
|
||||
|
||||
@@ -17,7 +21,7 @@ var acmeShFile string
|
||||
|
||||
// GetAcmeShVersion will return the acme.sh script version
|
||||
func GetAcmeShVersion() string {
|
||||
if r, err := shExec("--version"); err == nil {
|
||||
if r, err := shExec([]string{"--version"}, nil); err == nil {
|
||||
// modify the output
|
||||
r = strings.Trim(r, "\n")
|
||||
v := strings.Split(r, "\n")
|
||||
@@ -27,7 +31,7 @@ func GetAcmeShVersion() string {
|
||||
}
|
||||
|
||||
// shExec executes the acme.sh with arguments
|
||||
func shExec(args ...string) (string, error) {
|
||||
func shExec(args []string, envs []string) (string, error) {
|
||||
if _, err := os.Stat(acmeShFile); os.IsNotExist(err) {
|
||||
e := fmt.Errorf("%s does not exist", acmeShFile)
|
||||
logger.Error("AcmeShError", e)
|
||||
@@ -37,6 +41,8 @@ func shExec(args ...string) (string, error) {
|
||||
logger.Debug("CMD: %s %v", acmeShFile, args)
|
||||
// nolint: gosec
|
||||
c := exec.Command(acmeShFile, args...)
|
||||
c.Env = envs
|
||||
|
||||
b, e := c.Output()
|
||||
|
||||
if e != nil {
|
||||
@@ -65,26 +71,22 @@ func WriteAcmeSh() {
|
||||
}
|
||||
|
||||
// RequestCert does all the heavy lifting
|
||||
func RequestCert(domains []string, method string) error {
|
||||
args := []string{"--issue"}
|
||||
|
||||
webroot := "/home/wwwroot/example.com"
|
||||
|
||||
// Add domains to args
|
||||
for _, domain := range domains {
|
||||
args = append(args, "-d", domain)
|
||||
func RequestCert(domains []string, method, caBundle, outputFullchainFile, outputKeyFile string, dnsProvider *dnsprovider.Model) error {
|
||||
// TODO log file location configurable
|
||||
args, err := buildCertRequestArgs(domains, method, caBundle, outputFullchainFile, outputKeyFile, dnsProvider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch method {
|
||||
// case "dns":
|
||||
case "http":
|
||||
args = append(args, "-w", webroot)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("RequestCert method not supported: %s", method)
|
||||
envs := make([]string, 0)
|
||||
if dnsProvider != nil {
|
||||
envs, err = dnsProvider.GetAcmeShEnvVars()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ret, err := shExec(args...)
|
||||
ret, err := shExec(args, envs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,3 +95,56 @@ func RequestCert(domains []string, method string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// This is split out into it's own function so it's testable
|
||||
func buildCertRequestArgs(domains []string, method, caBundle, outputFullchainFile, outputKeyFile string, dnsProvider *dnsprovider.Model) ([]string, error) {
|
||||
// TODO log file location configurable
|
||||
args := []string{"--issue", "--log", "/data/logs/acme.sh.log"}
|
||||
|
||||
if caBundle != "" {
|
||||
args = append(args, "--ca-bundle", caBundle)
|
||||
}
|
||||
|
||||
if outputFullchainFile != "" {
|
||||
args = append(args, "--fullchain-file", outputFullchainFile)
|
||||
}
|
||||
|
||||
if outputKeyFile != "" {
|
||||
args = append(args, "--key-file", outputKeyFile)
|
||||
}
|
||||
|
||||
// TODO webroot location configurable
|
||||
webroot := "/data/acme/wellknown"
|
||||
|
||||
methodArgs := make([]string, 0)
|
||||
switch method {
|
||||
case "dns":
|
||||
if dnsProvider == nil {
|
||||
return nil, ErrDNSNeedsDNSProvider
|
||||
}
|
||||
methodArgs = append(methodArgs, "--dns", dnsProvider.AcmeShName)
|
||||
|
||||
case "http":
|
||||
if dnsProvider != nil {
|
||||
return nil, ErrHTTPHasDNSProvider
|
||||
}
|
||||
methodArgs = append(methodArgs, "-w", webroot)
|
||||
default:
|
||||
return nil, ErrMethodNotSupported
|
||||
}
|
||||
|
||||
hasMethod := false
|
||||
|
||||
// Add domains to args
|
||||
for _, domain := range domains {
|
||||
args = append(args, "-d", domain)
|
||||
// Method has to appear after first domain, but does not need to be repeated
|
||||
// for other domains.
|
||||
if !hasMethod {
|
||||
args = append(args, methodArgs...)
|
||||
hasMethod = true
|
||||
}
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
190
backend/internal/acme/acmesh_test.go
Normal file
190
backend/internal/acme/acmesh_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"npm/internal/entity/dnsprovider"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Tear up/down
|
||||
/*
|
||||
func TestMain(m *testing.M) {
|
||||
config.Init(&version, &commit, &sentryDSN)
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
*/
|
||||
|
||||
// TODO configurable
|
||||
const acmeLogFile = "/data/logs/acme.sh.log"
|
||||
const acmeWebroot = "/data/acme/wellknown"
|
||||
|
||||
func TestBuildCertRequestArgs(t *testing.T) {
|
||||
type want struct {
|
||||
args []string
|
||||
err error
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
domains []string
|
||||
method string
|
||||
caBundle string
|
||||
outputFullchainFile string
|
||||
outputKeyFile string
|
||||
dnsProvider *dnsprovider.Model
|
||||
want want
|
||||
}{
|
||||
{
|
||||
name: "http single domain",
|
||||
domains: []string{"example.com"},
|
||||
method: "http",
|
||||
caBundle: "",
|
||||
outputFullchainFile: "/data/acme/certs/a.crt",
|
||||
outputKeyFile: "/data/acme/certs/example.com.key",
|
||||
dnsProvider: nil,
|
||||
want: want{
|
||||
args: []string{
|
||||
"--issue",
|
||||
"--log",
|
||||
acmeLogFile,
|
||||
"--fullchain-file",
|
||||
"/data/acme/certs/a.crt",
|
||||
"--key-file",
|
||||
"/data/acme/certs/example.com.key",
|
||||
"-d",
|
||||
"example.com",
|
||||
"-w",
|
||||
acmeWebroot,
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http multiple domains",
|
||||
domains: []string{"example.com", "example-two.com", "example-three.com"},
|
||||
method: "http",
|
||||
caBundle: "",
|
||||
outputFullchainFile: "/data/acme/certs/a.crt",
|
||||
outputKeyFile: "/data/acme/certs/example.com.key",
|
||||
dnsProvider: nil,
|
||||
want: want{
|
||||
args: []string{
|
||||
"--issue",
|
||||
"--log",
|
||||
acmeLogFile,
|
||||
"--fullchain-file",
|
||||
"/data/acme/certs/a.crt",
|
||||
"--key-file",
|
||||
"/data/acme/certs/example.com.key",
|
||||
"-d",
|
||||
"example.com",
|
||||
"-w",
|
||||
acmeWebroot,
|
||||
"-d",
|
||||
"example-two.com",
|
||||
"-d",
|
||||
"example-three.com",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http single domain with dns provider",
|
||||
domains: []string{"example.com"},
|
||||
method: "http",
|
||||
caBundle: "",
|
||||
outputFullchainFile: "/data/acme/certs/a.crt",
|
||||
outputKeyFile: "/data/acme/certs/example.com.key",
|
||||
dnsProvider: &dnsprovider.Model{
|
||||
AcmeShName: "dns_cf",
|
||||
},
|
||||
want: want{
|
||||
args: nil,
|
||||
err: ErrHTTPHasDNSProvider,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dns single domain",
|
||||
domains: []string{"example.com"},
|
||||
method: "dns",
|
||||
caBundle: "",
|
||||
outputFullchainFile: "/data/acme/certs/a.crt",
|
||||
outputKeyFile: "/data/acme/certs/example.com.key",
|
||||
dnsProvider: &dnsprovider.Model{
|
||||
AcmeShName: "dns_cf",
|
||||
},
|
||||
want: want{
|
||||
args: []string{
|
||||
"--issue",
|
||||
"--log",
|
||||
acmeLogFile,
|
||||
"--fullchain-file",
|
||||
"/data/acme/certs/a.crt",
|
||||
"--key-file",
|
||||
"/data/acme/certs/example.com.key",
|
||||
"-d",
|
||||
"example.com",
|
||||
"--dns",
|
||||
"dns_cf",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dns multiple domains",
|
||||
domains: []string{"example.com", "example-two.com", "example-three.com"},
|
||||
method: "dns",
|
||||
caBundle: "",
|
||||
outputFullchainFile: "/data/acme/certs/a.crt",
|
||||
outputKeyFile: "/data/acme/certs/example.com.key",
|
||||
dnsProvider: &dnsprovider.Model{
|
||||
AcmeShName: "dns_cf",
|
||||
},
|
||||
want: want{
|
||||
args: []string{
|
||||
"--issue",
|
||||
"--log",
|
||||
acmeLogFile,
|
||||
"--fullchain-file",
|
||||
"/data/acme/certs/a.crt",
|
||||
"--key-file",
|
||||
"/data/acme/certs/example.com.key",
|
||||
"-d",
|
||||
"example.com",
|
||||
"--dns",
|
||||
"dns_cf",
|
||||
"-d",
|
||||
"example-two.com",
|
||||
"-d",
|
||||
"example-three.com",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dns single domain no provider",
|
||||
domains: []string{"example.com"},
|
||||
method: "dns",
|
||||
caBundle: "",
|
||||
outputFullchainFile: "/data/acme/certs/a.crt",
|
||||
outputKeyFile: "/data/acme/certs/example.com.key",
|
||||
dnsProvider: nil,
|
||||
want: want{
|
||||
args: nil,
|
||||
err: ErrDNSNeedsDNSProvider,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
args, err := buildCertRequestArgs(tt.domains, tt.method, tt.caBundle, tt.outputFullchainFile, tt.outputKeyFile, tt.dnsProvider)
|
||||
|
||||
assert.Equal(t, tt.want.args, args)
|
||||
assert.Equal(t, tt.want.err, err)
|
||||
})
|
||||
}
|
||||
}
|
10
backend/internal/acme/errors.go
Normal file
10
backend/internal/acme/errors.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package acme
|
||||
|
||||
import "errors"
|
||||
|
||||
// All errors relating to Acme.sh use
|
||||
var (
|
||||
ErrDNSNeedsDNSProvider = errors.New("RequestCert dns method requires a dns provider")
|
||||
ErrHTTPHasDNSProvider = errors.New("RequestCert http method does not need a dns provider")
|
||||
ErrMethodNotSupported = errors.New("RequestCert method not supported")
|
||||
)
|
@@ -9,17 +9,17 @@ func CreateDNSProvider() string {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"provider_key",
|
||||
"name",
|
||||
"acme_sh_name",
|
||||
"meta"
|
||||
],
|
||||
"properties": {
|
||||
"provider_key": %s,
|
||||
"name": %s,
|
||||
"acme_sh_name": %s,
|
||||
"meta": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
`, stringMinMax(2, 100), stringMinMax(1, 100))
|
||||
`, stringMinMax(1, 100), stringMinMax(4, 50))
|
||||
}
|
||||
|
@@ -41,6 +41,7 @@ func Create(certificate *Model) (int, error) {
|
||||
expires_on,
|
||||
status,
|
||||
meta,
|
||||
is_ecc,
|
||||
is_deleted
|
||||
) VALUES (
|
||||
:created_on,
|
||||
@@ -54,6 +55,7 @@ func Create(certificate *Model) (int, error) {
|
||||
:expires_on,
|
||||
:status,
|
||||
:meta,
|
||||
:is_ecc,
|
||||
:is_deleted
|
||||
)`, certificate)
|
||||
|
||||
@@ -91,6 +93,7 @@ func Update(certificate *Model) error {
|
||||
expires_on = :expires_on,
|
||||
status = :status,
|
||||
meta = :meta,
|
||||
is_ecc = :is_ecc,
|
||||
is_deleted = :is_deleted
|
||||
WHERE id = :id`, certificate)
|
||||
|
||||
|
@@ -50,6 +50,7 @@ type Model struct {
|
||||
Status string `json:"status" db:"status" filter:"status,string"`
|
||||
ErrorMessage string `json:"error_message,omitempty" db:"error_message" filter:"error_message,string"`
|
||||
Meta types.JSONB `json:"-" db:"meta"`
|
||||
IsECC int `json:"is_ecc" db:"is_ecc" filter:"is_ecc,integer"`
|
||||
IsDeleted bool `json:"is_deleted,omitempty" db:"is_deleted"`
|
||||
// Expansions:
|
||||
CertificateAuthority *certificateauthority.Model `json:"certificate_authority,omitempty"`
|
||||
@@ -201,7 +202,8 @@ func (m *Model) Request() error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = acme.RequestCert(domains, m.Type)
|
||||
// TODO: fill in blank params
|
||||
err = acme.RequestCert(domains, m.Type, "", "", "", nil)
|
||||
if err != nil {
|
||||
m.Status = StatusFailed
|
||||
m.ErrorMessage = err.Error()
|
||||
|
@@ -33,16 +33,16 @@ func Create(provider *Model) (int, error) {
|
||||
created_on,
|
||||
modified_on,
|
||||
user_id,
|
||||
provider_key,
|
||||
name,
|
||||
acme_sh_name,
|
||||
meta,
|
||||
is_deleted
|
||||
) VALUES (
|
||||
:created_on,
|
||||
:modified_on,
|
||||
:user_id,
|
||||
:provider_key,
|
||||
:name,
|
||||
:acme_sh_name,
|
||||
:meta,
|
||||
:is_deleted
|
||||
)`, provider)
|
||||
@@ -73,8 +73,8 @@ func Update(provider *Model) error {
|
||||
created_on = :created_on,
|
||||
modified_on = :modified_on,
|
||||
user_id = :user_id,
|
||||
provider_key = :provider_key,
|
||||
name = :name,
|
||||
acme_sh_name = :acme_sh_name,
|
||||
meta = :meta,
|
||||
is_deleted = :is_deleted
|
||||
WHERE id = :id`, provider)
|
||||
|
@@ -14,14 +14,14 @@ const (
|
||||
|
||||
// Model is the user model
|
||||
type Model struct {
|
||||
ID int `json:"id" db:"id" filter:"id,integer"`
|
||||
CreatedOn types.DBDate `json:"created_on" db:"created_on" filter:"created_on,integer"`
|
||||
ModifiedOn types.DBDate `json:"modified_on" db:"modified_on" filter:"modified_on,integer"`
|
||||
UserID int `json:"user_id" db:"user_id" filter:"user_id,integer"`
|
||||
ProviderKey string `json:"provider_key" db:"provider_key" filter:"provider_key,string"`
|
||||
Name string `json:"name" db:"name" filter:"name,string"`
|
||||
Meta types.JSONB `json:"meta" db:"meta"`
|
||||
IsDeleted bool `json:"is_deleted,omitempty" db:"is_deleted"`
|
||||
ID int `json:"id" db:"id" filter:"id,integer"`
|
||||
CreatedOn types.DBDate `json:"created_on" db:"created_on" filter:"created_on,integer"`
|
||||
ModifiedOn types.DBDate `json:"modified_on" db:"modified_on" filter:"modified_on,integer"`
|
||||
UserID int `json:"user_id" db:"user_id" filter:"user_id,integer"`
|
||||
Name string `json:"name" db:"name" filter:"name,string"`
|
||||
AcmeShName string `json:"acme_sh_name" db:"acme_sh_name" filter:"acme_sh_name,string"`
|
||||
Meta types.JSONB `json:"meta" db:"meta"`
|
||||
IsDeleted bool `json:"is_deleted,omitempty" db:"is_deleted"`
|
||||
}
|
||||
|
||||
func (m *Model) getByQuery(query string, params []interface{}) error {
|
||||
@@ -71,3 +71,41 @@ func (m *Model) Delete() bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAcmeShEnvVars returns the env vars required for acme.sh dns cert requests
|
||||
func (m *Model) GetAcmeShEnvVars() ([]string, error) {
|
||||
envs := make([]string, 0)
|
||||
switch m.AcmeShName {
|
||||
|
||||
// AWS
|
||||
case "dns_aws":
|
||||
envs = []string{
|
||||
"AWS_ACCESS_KEY_ID=\"sdfsdfsdfljlbjkljlkjsdfoiwje\"",
|
||||
"AWS_SECRET_ACCESS_KEY=\"xxxxxxx\"",
|
||||
}
|
||||
|
||||
// Cloudflare
|
||||
case "dns_cf":
|
||||
envs = []string{
|
||||
"CF_Key=\"sdfsdfsdfljlbjkljlkjsdfoiwje\"",
|
||||
"CF_Email=\"xxxx@sss.com\"",
|
||||
"CF_Token=\"xxxx\"",
|
||||
"CF_Account_ID=\"xxxx\"",
|
||||
"CF_Zone_ID=\"xxxx\"",
|
||||
}
|
||||
|
||||
// DuckDNS
|
||||
case "dns_duckdns":
|
||||
envs = []string{
|
||||
"DuckDNS_Token=\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"",
|
||||
}
|
||||
|
||||
// Njalla
|
||||
case "dns_njalla":
|
||||
envs = []string{
|
||||
"NJALLA_Token=\"sdfsdfsdfljlbjkljlkjsdfoiwje\"",
|
||||
}
|
||||
}
|
||||
|
||||
return envs, nil
|
||||
}
|
||||
|
70
backend/internal/entity/dnsprovider/model_test.go
Normal file
70
backend/internal/entity/dnsprovider/model_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestModelGetAcmeShEnvVars(t *testing.T) {
|
||||
type want struct {
|
||||
envs []string
|
||||
err error
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dnsProvider Model
|
||||
want want
|
||||
}{
|
||||
{
|
||||
name: "dns_aws",
|
||||
dnsProvider: Model{
|
||||
AcmeShName: "dns_aws",
|
||||
},
|
||||
want: want{
|
||||
envs: []string{
|
||||
"AWS_ACCESS_KEY_ID=\"sdfsdfsdfljlbjkljlkjsdfoiwje\"",
|
||||
"AWS_SECRET_ACCESS_KEY=\"xxxxxxx\"",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dns_cf",
|
||||
dnsProvider: Model{
|
||||
AcmeShName: "dns_cf",
|
||||
},
|
||||
want: want{
|
||||
envs: []string{
|
||||
"CF_Key=\"sdfsdfsdfljlbjkljlkjsdfoiwje\"",
|
||||
"CF_Email=\"xxxx@sss.com\"",
|
||||
"CF_Token=\"xxxx\"",
|
||||
"CF_Account_ID=\"xxxx\"",
|
||||
"CF_Zone_ID=\"xxxx\"",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dns_duckdns",
|
||||
dnsProvider: Model{
|
||||
AcmeShName: "dns_duckdns",
|
||||
},
|
||||
want: want{
|
||||
envs: []string{
|
||||
"DuckDNS_Token=\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
envs, err := tt.dnsProvider.GetAcmeShEnvVars()
|
||||
assert.Equal(t, tt.want.envs, envs)
|
||||
assert.Equal(t, tt.want.err, err)
|
||||
})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user