1
0
mirror of https://github.com/go-acme/lego.git synced 2024-12-27 03:32:05 +02:00

Add DNS provider for nicmanager (#1473)

Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
This commit is contained in:
Jens L 2021-08-27 12:47:28 +02:00 committed by GitHub
parent 376e7bd78e
commit d2e526e8dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 978 additions and 8 deletions

View File

@ -62,14 +62,14 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
| [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) |
| [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) |
| [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) |
| [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [Nicmanager](https://go-acme.github.io/lego/dns/nicmanager/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) |
| [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) |
| [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) |
| [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) |
| [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) |
| [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) |
| [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) |
| [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) |
<!-- END DNS PROVIDERS LIST --> <!-- END DNS PROVIDERS LIST -->

View File

@ -78,6 +78,7 @@ func allDNSCodes() string {
"namesilo", "namesilo",
"netcup", "netcup",
"netlify", "netlify",
"nicmanager",
"nifcloud", "nifcloud",
"njalla", "njalla",
"ns1", "ns1",
@ -1472,6 +1473,31 @@ func displayDNSHelp(name string) error {
ew.writeln() ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/netlify`) ew.writeln(`More information: https://go-acme.github.io/lego/dns/netlify`)
case "nicmanager":
// generated from: providers/dns/nicmanager/nicmanager.toml
ew.writeln(`Configuration for Nicmanager.`)
ew.writeln(`Code: 'nicmanager'`)
ew.writeln(`Since: 'v4.5.0'`)
ew.writeln()
ew.writeln(`Credentials:`)
ew.writeln(` - "NICMANAGER_API_EMAIL": Email-based login`)
ew.writeln(` - "NICMANAGER_API_LOGIN": Login, used for Username-based login`)
ew.writeln(` - "NICMANAGER_API_PASSWORD": Password, always required`)
ew.writeln(` - "NICMANAGER_API_USERNAME": Username, used for Username-based login`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "NICMANAGER_API_MODE": mode: 'anycast' or 'zone' (default: 'anycast')`)
ew.writeln(` - "NICMANAGER_API_OTP": TOTP Secret (optional)`)
ew.writeln(` - "NICMANAGER_HTTP_TIMEOUT": API request timeout`)
ew.writeln(` - "NICMANAGER_POLLING_INTERVAL": Time between DNS propagation check`)
ew.writeln(` - "NICMANAGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
ew.writeln(` - "NICMANAGER_TTL": The TTL of the TXT record used for the DNS challenge`)
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicmanager`)
case "nifcloud": case "nifcloud":
// generated from: providers/dns/nifcloud/nifcloud.toml // generated from: providers/dns/nifcloud/nifcloud.toml
ew.writeln(`Configuration for NIFCloud.`) ew.writeln(`Configuration for NIFCloud.`)

View File

@ -0,0 +1,89 @@
---
title: "Nicmanager"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: nicmanager
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/nicmanager/nicmanager.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Since: v4.5.0
Configuration for [Nicmanager](https://www.nicmanager.com/).
<!--more-->
- Code: `nicmanager`
Here is an example bash command using the Nicmanager provider:
```bash
## Login using email
NICMANAGER_API_EMAIL = "foo@bar.baz" \
NICMANAGER_API_PASSWORD = "password" \
# Optionally, if your account has TOTP enabled, set the secret here
NICMANAGER_API_OTP = "long-secret" \
lego --email myemail@example.com --dns nicmanager --domains my.example.org run
## Login using account name + username
NICMANAGER_API_LOGIN = "myaccount" \
NICMANAGER_API_USERNAME = "myuser" \
NICMANAGER_API_PASSWORD = "password" \
# Optionally, if your account has TOTP enabled, set the secret here
NICMANAGER_API_OTP = "long-secret" \
lego --email myemail@example.com --dns nicmanager --domains my.example.org run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `NICMANAGER_API_EMAIL` | Email-based login |
| `NICMANAGER_API_LOGIN` | Login, used for Username-based login |
| `NICMANAGER_API_PASSWORD` | Password, always required |
| `NICMANAGER_API_USERNAME` | Username, used for Username-based login |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here](/lego/dns/#configuration-and-credentials).
## Additional Configuration
| Environment Variable Name | Description |
|--------------------------------|-------------|
| `NICMANAGER_API_MODE` | mode: 'anycast' or 'zone' (default: 'anycast') |
| `NICMANAGER_API_OTP` | TOTP Secret (optional) |
| `NICMANAGER_HTTP_TIMEOUT` | API request timeout |
| `NICMANAGER_POLLING_INTERVAL` | Time between DNS propagation check |
| `NICMANAGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
| `NICMANAGER_TTL` | The TTL of the TXT record used for the DNS challenge |
The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
More information [here](/lego/dns/#configuration-and-credentials).
## Description
You can login using your account name + username or using your email address.
Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`.
## More information
- [API documentation](https://api.nicmanager.com/docs/v1/)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/nicmanager/nicmanager.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View File

@ -69,6 +69,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/namesilo" "github.com/go-acme/lego/v4/providers/dns/namesilo"
"github.com/go-acme/lego/v4/providers/dns/netcup" "github.com/go-acme/lego/v4/providers/dns/netcup"
"github.com/go-acme/lego/v4/providers/dns/netlify" "github.com/go-acme/lego/v4/providers/dns/netlify"
"github.com/go-acme/lego/v4/providers/dns/nicmanager"
"github.com/go-acme/lego/v4/providers/dns/nifcloud" "github.com/go-acme/lego/v4/providers/dns/nifcloud"
"github.com/go-acme/lego/v4/providers/dns/njalla" "github.com/go-acme/lego/v4/providers/dns/njalla"
"github.com/go-acme/lego/v4/providers/dns/ns1" "github.com/go-acme/lego/v4/providers/dns/ns1"
@ -234,6 +235,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return netcup.NewDNSProvider() return netcup.NewDNSProvider()
case "netlify": case "netlify":
return netlify.NewDNSProvider() return netlify.NewDNSProvider()
case "nicmanager":
return nicmanager.NewDNSProvider()
case "nifcloud": case "nifcloud":
return nifcloud.NewDNSProvider() return nifcloud.NewDNSProvider()
case "njalla": case "njalla":

View File

@ -0,0 +1,185 @@
package internal
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"path"
"strconv"
"time"
"github.com/pquerna/otp/totp"
)
const (
defaultBaseURL = "https://api.nicmanager.com/v1"
headerTOTPToken = "X-Auth-Token"
)
// Modes.
const (
ModeAnycast = "anycast"
ModeZone = "zone"
)
// Options the Client options.
type Options struct {
Login string
Username string
Email string
Password string
OTP string
Mode string
}
// Client a nicmanager DNS client.
type Client struct {
HTTPClient *http.Client
baseURL *url.URL
username string
password string
otp string
mode string
}
// NewClient create a new Client.
func NewClient(opts Options) *Client {
c := &Client{
mode: ModeAnycast,
username: opts.Email,
password: opts.Password,
otp: opts.OTP,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
c.baseURL, _ = url.Parse(defaultBaseURL)
if opts.Mode != "" {
c.mode = opts.Mode
}
if opts.Login != "" && opts.Username != "" {
c.username = fmt.Sprintf("%s.%s", opts.Login, opts.Username)
}
return c
}
func (c Client) GetZone(name string) (*Zone, error) {
resp, err := c.do(http.MethodGet, name, nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= http.StatusBadRequest {
b, _ := ioutil.ReadAll(resp.Body)
msg := APIError{StatusCode: resp.StatusCode}
if err = json.Unmarshal(b, &msg); err != nil {
return nil, fmt.Errorf("failed to get zone info for %s", name)
}
return nil, msg
}
var zone Zone
err = json.NewDecoder(resp.Body).Decode(&zone)
if err != nil {
return nil, err
}
return &zone, nil
}
func (c Client) AddRecord(zone string, req RecordCreateUpdate) error {
resp, err := c.do(http.MethodPost, path.Join(zone, "records"), req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := ioutil.ReadAll(resp.Body)
msg := APIError{StatusCode: resp.StatusCode}
if err = json.Unmarshal(b, &msg); err != nil {
return fmt.Errorf("records create should've returned %d but returned %d", http.StatusAccepted, resp.StatusCode)
}
return msg
}
return nil
}
func (c Client) DeleteRecord(zone string, record int) error {
resp, err := c.do(http.MethodDelete, path.Join(zone, "records", strconv.Itoa(record)), nil)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := ioutil.ReadAll(resp.Body)
msg := APIError{StatusCode: resp.StatusCode}
if err = json.Unmarshal(b, &msg); err != nil {
return fmt.Errorf("records delete should've returned %d but returned %d", http.StatusAccepted, resp.StatusCode)
}
return msg
}
return nil
}
func (c Client) do(method, uri string, body interface{}) (*http.Response, error) {
var reqBody io.Reader
if body != nil {
jsonValue, err := json.Marshal(body)
if err != nil {
return nil, err
}
reqBody = bytes.NewBuffer(jsonValue)
}
endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, c.mode, uri))
if err != nil {
return nil, err
}
r, err := http.NewRequest(method, endpoint.String(), reqBody)
if err != nil {
return nil, err
}
r.Header.Set("Accept", "application/json")
r.Header.Set("Content-Type", "application/json")
r.SetBasicAuth(c.username, c.password)
if c.otp != "" {
tan, err := totp.GenerateCode(c.otp, time.Now())
if err != nil {
return nil, err
}
r.Header.Set(headerTOTPToken, tan)
}
return c.HTTPClient.Do(r)
}

View File

@ -0,0 +1,145 @@
package internal
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_GetZone(t *testing.T) {
client := setupTest(t, "/anycast/nicmanager-anycastdns4.net", testHandler(http.MethodGet, http.StatusOK, "zone.json"))
zone, err := client.GetZone("nicmanager-anycastdns4.net")
require.NoError(t, err)
expected := &Zone{
Name: "nicmanager-anycastdns4.net",
Active: true,
Records: []Record{
{
ID: 186,
Name: "nicmanager-anycastdns4.net",
Type: "A",
Content: "123.123.123.123",
TTL: 3600,
},
},
}
assert.Equal(t, expected, zone)
}
func TestClient_GetZone_error(t *testing.T) {
client := setupTest(t, "/anycast/foo", testHandler(http.MethodGet, http.StatusNotFound, "error.json"))
_, err := client.GetZone("foo")
require.Error(t, err)
}
func TestClient_AddRecord(t *testing.T) {
client := setupTest(t, "/anycast/zonedomain.tld/records", testHandler(http.MethodPost, http.StatusAccepted, "error.json"))
record := RecordCreateUpdate{
Type: "TXT",
Name: "lego",
Value: "content",
TTL: 3600,
}
err := client.AddRecord("zonedomain.tld", record)
require.NoError(t, err)
}
func TestClient_AddRecord_error(t *testing.T) {
client := setupTest(t, "/anycast/zonedomain.tld", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json"))
record := RecordCreateUpdate{
Type: "TXT",
Name: "zonedomain.tld",
Value: "content",
TTL: 3600,
}
err := client.AddRecord("zonedomain.tld", record)
require.Error(t, err)
}
func TestClient_DeleteRecord(t *testing.T) {
client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusAccepted, "error.json"))
err := client.DeleteRecord("zonedomain.tld", 6)
require.NoError(t, err)
}
func TestClient_DeleteRecord_error(t *testing.T) {
client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusNoContent, ""))
err := client.DeleteRecord("zonedomain.tld", 7)
require.Error(t, err)
}
func setupTest(t *testing.T, path string, handler http.Handler) *Client {
t.Helper()
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
mux.Handle(path, handler)
opts := Options{
Login: "foo",
Username: "bar",
Password: "foo",
OTP: "2hsn",
}
client := NewClient(opts)
client.HTTPClient = server.Client()
client.baseURL, _ = url.Parse(server.URL)
return client
}
func testHandler(method string, statusCode int, filename string) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
if req.Method != method {
http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
return
}
username, password, ok := req.BasicAuth()
if !ok || username != "foo.bar" || password != "foo" {
http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
return
}
rw.WriteHeader(statusCode)
if statusCode == http.StatusNoContent {
return
}
file, err := os.Open(filepath.Join("fixtures", filename))
if err != nil {
http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
return
}
defer func() { _ = file.Close() }()
_, err = io.Copy(rw, file)
if err != nil {
http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
return
}
}
}

View File

@ -0,0 +1,3 @@
{
"message": "Not Found"
}

View File

@ -0,0 +1,51 @@
{
"order_id": 9053,
"name": "nicmanager-anycastdns4.net",
"order_status": "active",
"event_status": "done",
"active": true,
"dnssec": "inactive",
"master1": null,
"master2": null,
"soa": {
"primary": "ns1.nic53.net",
"mail": "hostmaster.nicmanager.de",
"serial": 1481109046,
"refresh": 14400,
"retry": 1800,
"expire": 1209600,
"default": 3600,
"ttl": 86400
},
"updated_datetime": "2016-09-02T13:52:18Z",
"order_datetime": "2016-09-02T13:52:18Z",
"records": [
{
"id": 186,
"name": "nicmanager-anycastdns4.net",
"type": "A",
"content": "123.123.123.123",
"ttl": 3600,
"priority": 0,
"active": true,
"updated_datetime": "2016-09-02T13:52:18Z"
}
],
"redirects": [
{
"id": 10,
"name": "test.nicmanager-anycastdns4.net",
"target": "https:\/\/www.nicmanager.com\/",
"type": "frame",
"updated_datetime": "2016-12-05T14:40:47Z",
"request_uri": true,
"ssl": false,
"meta": {
"title": "My frame",
"keywords": "foo,bar",
"description": "Just a Test"
},
"subdomain": "test"
}
]
}

View File

@ -0,0 +1,34 @@
package internal
import "fmt"
type Record struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL int `json:"ttl"`
}
type Zone struct {
Name string `json:"name"`
Active bool `json:"active"`
Records []Record `json:"records"`
}
type RecordCreateUpdate struct {
Name string `json:"name"`
Value string `json:"value"`
TTL int `json:"ttl"`
Type string `json:"type"`
}
type APIError struct {
Message string `json:"message"`
StatusCode int `json:"-"`
}
func (a APIError) Error() string {
return fmt.Sprintf("%d: %s", a.StatusCode, a.Message)
}

View File

@ -0,0 +1,200 @@
// Package nicmanager implements a DNS provider for solving the DNS-01 challenge using nicmanager DNS.
package nicmanager
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/nicmanager/internal"
)
// Environment variables names.
const (
envNamespace = "NICMANAGER_"
EnvLogin = envNamespace + "API_LOGIN"
EnvUsername = envNamespace + "API_USERNAME"
EnvEmail = envNamespace + "API_EMAIL"
EnvPassword = envNamespace + "API_PASSWORD"
EnvOTP = envNamespace + "API_OTP"
EnvMode = envNamespace + "MODE"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
const minTTL = 900
// Config is used to configure the creation of the DNSProvider.
type Config struct {
Login string
Username string
Email string
Password string
OTPSecret string
Mode string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
},
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
client *internal.Client
config *Config
}
// NewDNSProvider returns a DNSProvider instance configured for nicmanager.
// Credentials must be passed in the environment variables:
// NICMANAGER_API_LOGIN, NICMANAGER_API_USERNAME
// NICMANAGER_API_EMAIL
// NICMANAGER_API_PASSWORD
// NICMANAGER_API_OTP
// NICMANAGER_API_MODE.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvPassword)
if err != nil {
return nil, fmt.Errorf("nicmanager: %w", err)
}
config := NewDefaultConfig()
config.Password = values[EnvPassword]
config.Mode = env.GetOrDefaultString(EnvMode, internal.ModeAnycast)
config.Username = env.GetOrFile(EnvUsername)
config.Login = env.GetOrFile(EnvLogin)
config.Email = env.GetOrFile(EnvEmail)
config.OTPSecret = env.GetOrFile(EnvOTP)
if config.TTL < minTTL {
return nil, fmt.Errorf("TTL must be higher than %d: %d", minTTL, config.TTL)
}
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for nicmanager.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("nicmanager: the configuration of the DNS provider is nil")
}
opts := internal.Options{
Password: config.Password,
OTP: config.OTPSecret,
Mode: config.Mode,
}
switch {
case config.Password == "":
return nil, errors.New("nicmanager: credentials missing")
case config.Email != "":
opts.Email = config.Email
case config.Login != "" && config.Username != "":
opts.Login = config.Login
opts.Username = config.Username
default:
return nil, errors.New("nicmanager: credentials missing")
}
client := internal.NewClient(opts)
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
return &DNSProvider{client: client, config: config}, nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
// Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
// Present creates a TXT record to fulfill the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value := dns01.GetRecord(domain, keyAuth)
rootDomain, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
if err != nil {
return fmt.Errorf("nicmanager: could not determine zone for domain %q: %w", domain, err)
}
zone, err := d.client.GetZone(dns01.UnFqdn(rootDomain))
if err != nil {
return fmt.Errorf("nicmanager: failed to get zone %q: %w", rootDomain, err)
}
// The way nic manager deals with record with multiple values is that they are completely different records with unique ids
// Hence we don't check for an existing record here, but rather just create one
record := internal.RecordCreateUpdate{
Name: fqdn,
Type: "TXT",
TTL: d.config.TTL,
Value: value,
}
err = d.client.AddRecord(zone.Name, record)
if err != nil {
return fmt.Errorf("nicmanager: failed to create record [zone: %q, fqdn: %q]: %w", zone.Name, fqdn, err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value := dns01.GetRecord(domain, keyAuth)
rootDomain, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
if err != nil {
return fmt.Errorf("nicmanager: could not determine zone for domain %q: %w", domain, err)
}
zone, err := d.client.GetZone(dns01.UnFqdn(rootDomain))
if err != nil {
return fmt.Errorf("nicmanager: failed to get zone %q: %w", rootDomain, err)
}
name := dns01.UnFqdn(fqdn)
var existingRecord internal.Record
var existingRecordFound bool
for _, record := range zone.Records {
if strings.EqualFold(record.Type, "TXT") && strings.EqualFold(record.Name, name) && record.Content == value {
existingRecord = record
existingRecordFound = true
}
}
if existingRecordFound {
err = d.client.DeleteRecord(zone.Name, existingRecord.ID)
if err != nil {
return fmt.Errorf("nicmanager: failed to delete record [zone: %q, domain: %q]: %w", zone.Name, name, err)
}
}
return fmt.Errorf("nicmanager: no record found to cleanup")
}

View File

@ -0,0 +1,52 @@
Name = "Nicmanager"
Description = ''''''
URL = "https://www.nicmanager.com/"
Code = "nicmanager"
Since = "v4.5.0"
Example = '''
## Login using email
NICMANAGER_API_EMAIL = "foo@bar.baz" \
NICMANAGER_API_PASSWORD = "password" \
# Optionally, if your account has TOTP enabled, set the secret here
NICMANAGER_API_OTP = "long-secret" \
lego --email myemail@example.com --dns nicmanager --domains my.example.org run
## Login using account name + username
NICMANAGER_API_LOGIN = "myaccount" \
NICMANAGER_API_USERNAME = "myuser" \
NICMANAGER_API_PASSWORD = "password" \
# Optionally, if your account has TOTP enabled, set the secret here
NICMANAGER_API_OTP = "long-secret" \
lego --email myemail@example.com --dns nicmanager --domains my.example.org run
'''
Additional = '''
## Description
You can login using your account name + username or using your email address.
Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`.
'''
[Configuration]
[Configuration.Credentials]
NICMANAGER_API_LOGIN = "Login, used for Username-based login"
NICMANAGER_API_USERNAME = "Username, used for Username-based login"
NICMANAGER_API_EMAIL = "Email-based login"
NICMANAGER_API_PASSWORD = "Password, always required"
[Configuration.Additional]
NICMANAGER_API_OTP = "TOTP Secret (optional)"
NICMANAGER_API_MODE = "mode: 'anycast' or 'zone' (default: 'anycast')"
NICMANAGER_POLLING_INTERVAL = "Time between DNS propagation check"
NICMANAGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
NICMANAGER_TTL = "The TTL of the TXT record used for the DNS challenge"
NICMANAGER_HTTP_TIMEOUT = "API request timeout"
[Links]
API = "https://api.nicmanager.com/docs/v1/"

View File

@ -0,0 +1,182 @@
package nicmanager
import (
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/stretchr/testify/require"
)
const envDomain = envNamespace + "DOMAIN"
var envTest = tester.NewEnvTest(EnvUsername, EnvLogin, EnvEmail, EnvPassword, EnvOTP).
WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
expected string
}{
{
desc: "success (email)",
envVars: map[string]string{
EnvEmail: "foo@example.com",
EnvPassword: "secret",
},
},
{
desc: "success (login.username)",
envVars: map[string]string{
EnvLogin: "foo",
EnvUsername: "bar",
EnvPassword: "secret",
},
},
{
desc: "missing credentials",
expected: "nicmanager: some credentials information are missing: NICMANAGER_API_PASSWORD",
},
{
desc: "missing password",
envVars: map[string]string{
EnvEmail: "foo@example.com",
},
expected: "nicmanager: some credentials information are missing: NICMANAGER_API_PASSWORD",
},
{
desc: "missing username",
envVars: map[string]string{
EnvLogin: "foo",
EnvPassword: "secret",
},
expected: "nicmanager: credentials missing",
},
{
desc: "missing login",
envVars: map[string]string{
EnvUsername: "bar",
EnvPassword: "secret",
},
expected: "nicmanager: credentials missing",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
envTest.ClearEnv()
envTest.Apply(test.envVars)
p, err := NewDNSProvider()
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
login string
username string
email string
password string
otpSecret string
expected string
}{
{
desc: "success (email)",
email: "foo@example.com",
password: "secret",
},
{
desc: "success (login.username)",
login: "john",
username: "doe",
password: "secret",
},
{
desc: "missing credentials",
expected: "nicmanager: credentials missing",
},
{
desc: "missing password",
email: "foo@example.com",
expected: "nicmanager: credentials missing",
},
{
desc: "missing login",
login: "",
username: "doe",
password: "secret",
expected: "nicmanager: credentials missing",
},
{
desc: "missing username",
login: "john",
username: "",
password: "secret",
expected: "nicmanager: credentials missing",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
config.Login = test.login
config.Username = test.username
config.Email = test.email
config.Password = test.password
config.OTPSecret = test.otpSecret
p, err := NewDNSProviderConfig(config)
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestLivePresent(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.Present(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func TestLiveCleanUp(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
time.Sleep(1 * time.Second)
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}