From b20e8f33949bc8960a47584b76432543ca37ff5a Mon Sep 17 00:00:00 2001
From: Ludovic Fernandez <ldez@users.noreply.github.com>
Date: Mon, 14 Jun 2021 02:44:13 +0200
Subject: [PATCH] Add DNS provider for Internet.bs (#1431)

---
 .golangci.toml                                |   1 +
 README.md                                     |  24 +-
 cmd/zz_gen_cmd_dnshelp.go                     |  22 ++
 docs/content/dns/zz_gen_internetbs.md         |  64 +++++
 providers/dns/dns_providers.go                |   3 +
 providers/dns/internetbs/internal/client.go   | 140 ++++++++++
 .../dns/internetbs/internal/client_test.go    | 258 ++++++++++++++++++
 .../Domain_DnsRecord_Add_FAILURE.json         |   6 +
 .../Domain_DnsRecord_Add_SUCCESS.json         |   4 +
 .../Domain_DnsRecord_List_FAILURE.json        |   6 +
 .../Domain_DnsRecord_List_SUCCESS.json        |  43 +++
 .../Domain_DnsRecord_Remove_SUCCESS.json      |   4 +
 providers/dns/internetbs/internal/types.go    |  40 +++
 providers/dns/internetbs/internetbs.go        | 135 +++++++++
 providers/dns/internetbs/internetbs.toml      |  24 ++
 providers/dns/internetbs/internetbs_test.go   | 144 ++++++++++
 16 files changed, 906 insertions(+), 12 deletions(-)
 create mode 100644 docs/content/dns/zz_gen_internetbs.md
 create mode 100644 providers/dns/internetbs/internal/client.go
 create mode 100644 providers/dns/internetbs/internal/client_test.go
 create mode 100644 providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_FAILURE.json
 create mode 100644 providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_SUCCESS.json
 create mode 100644 providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_FAILURE.json
 create mode 100644 providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_SUCCESS.json
 create mode 100644 providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Remove_SUCCESS.json
 create mode 100644 providers/dns/internetbs/internal/types.go
 create mode 100644 providers/dns/internetbs/internetbs.go
 create mode 100644 providers/dns/internetbs/internetbs.toml
 create mode 100644 providers/dns/internetbs/internetbs_test.go

diff --git a/.golangci.toml b/.golangci.toml
index b441c363..60cdd2da 100644
--- a/.golangci.toml
+++ b/.golangci.toml
@@ -19,6 +19,7 @@
 
   [linters-settings.misspell]
     locale = "US"
+    ignore-words = ["internetbs"]
 
   [linters-settings.depguard]
     list-type = "blacklist"
diff --git a/README.md b/README.md
index 9b3e00ae..c1c25eac 100644
--- a/README.md
+++ b/README.md
@@ -57,18 +57,18 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
 | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/)              | [Gandi](https://go-acme.github.io/lego/dns/gandi/)                              | [Glesys](https://go-acme.github.io/lego/dns/glesys/)                            | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/)                         |
 | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/)                      | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/)                          | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/)                     | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/)                     |
 | [Hurricane Electric DNS](https://go-acme.github.io/lego/dns/hurricane/)         | [HyperOne](https://go-acme.github.io/lego/dns/hyperone/)                        | [Infoblox](https://go-acme.github.io/lego/dns/infoblox/)                        | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/)                    |
-| [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/)            | [INWX](https://go-acme.github.io/lego/dns/inwx/)                                | [Ionos](https://go-acme.github.io/lego/dns/ionos/)                              | [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/)                         | [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/)                            | [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/)                                  | [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/)                             | [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/)                        | [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/)                              | [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/)                 | [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/)                              | [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/)                            |                                                                                 |                                                                                 |
+| [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/)            | [Internet.bs](https://go-acme.github.io/lego/dns/internetbs/)                   | [INWX](https://go-acme.github.io/lego/dns/inwx/)                                | [Ionos](https://go-acme.github.io/lego/dns/ionos/)                              |
+| [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/)                         |
+| [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/)                            |
+| [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/)                                  |
+| [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/)                             |
+| [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/)                        |
+| [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/)                              |
+| [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/)                 |
+| [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/)                              |
+| [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 -->
 
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index 39344a66..b198b926 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -58,6 +58,7 @@ func allDNSCodes() string {
 		"iij",
 		"infoblox",
 		"infomaniak",
+		"internetbs",
 		"inwx",
 		"ionos",
 		"joker",
@@ -1041,6 +1042,27 @@ func displayDNSHelp(name string) error {
 		ew.writeln()
 		ew.writeln(`More information: https://go-acme.github.io/lego/dns/infomaniak`)
 
+	case "internetbs":
+		// generated from: providers/dns/internetbs/internetbs.toml
+		ew.writeln(`Configuration for Internet.bs.`)
+		ew.writeln(`Code:	'internetbs'`)
+		ew.writeln(`Since:	'v4.5.0'`)
+		ew.writeln()
+
+		ew.writeln(`Credentials:`)
+		ew.writeln(`	- "INTERNET_BS_API_KEY":	API key`)
+		ew.writeln(`	- "INTERNET_BS_PASSWORD":	API password`)
+		ew.writeln()
+
+		ew.writeln(`Additional Configuration:`)
+		ew.writeln(`	- "INTERNET_BS_HTTP_TIMEOUT":	API request timeout`)
+		ew.writeln(`	- "INTERNET_BS_POLLING_INTERVAL":	Time between DNS propagation check`)
+		ew.writeln(`	- "INTERNET_BS_PROPAGATION_TIMEOUT":	Maximum waiting time for DNS propagation`)
+		ew.writeln(`	- "INTERNET_BS_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/internetbs`)
+
 	case "inwx":
 		// generated from: providers/dns/inwx/inwx.toml
 		ew.writeln(`Configuration for INWX.`)
diff --git a/docs/content/dns/zz_gen_internetbs.md b/docs/content/dns/zz_gen_internetbs.md
new file mode 100644
index 00000000..f187dd88
--- /dev/null
+++ b/docs/content/dns/zz_gen_internetbs.md
@@ -0,0 +1,64 @@
+---
+title: "Internet.bs"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: internetbs
+---
+
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+<!-- providers/dns/internetbs/internetbs.toml -->
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+
+Since: v4.5.0
+
+Configuration for [Internet.bs](https://internetbs.net).
+
+
+<!--more-->
+
+- Code: `internetbs`
+
+Here is an example bash command using the Internet.bs provider:
+
+```bash
+INTERNET_BS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \
+INTERNET_BS_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \
+lego --email myemail@example.com --dns internetbs --domains my.example.org run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `INTERNET_BS_API_KEY` | API key |
+| `INTERNET_BS_PASSWORD` | API password |
+
+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 |
+|--------------------------------|-------------|
+| `INTERNET_BS_HTTP_TIMEOUT` | API request timeout |
+| `INTERNET_BS_POLLING_INTERVAL` | Time between DNS propagation check |
+| `INTERNET_BS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `INTERNET_BS_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).
+
+
+
+
+## More information
+
+- [API documentation](https://internetbs.net/internet-bs-api.pdf)
+
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
+<!-- providers/dns/internetbs/internetbs.toml -->
+<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index 500ac11e..bd36f4be 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -49,6 +49,7 @@ import (
 	"github.com/go-acme/lego/v4/providers/dns/iij"
 	"github.com/go-acme/lego/v4/providers/dns/infoblox"
 	"github.com/go-acme/lego/v4/providers/dns/infomaniak"
+	"github.com/go-acme/lego/v4/providers/dns/internetbs"
 	"github.com/go-acme/lego/v4/providers/dns/inwx"
 	"github.com/go-acme/lego/v4/providers/dns/ionos"
 	"github.com/go-acme/lego/v4/providers/dns/joker"
@@ -187,6 +188,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
 		return infoblox.NewDNSProvider()
 	case "infomaniak":
 		return infomaniak.NewDNSProvider()
+	case "internetbs":
+		return internetbs.NewDNSProvider()
 	case "inwx":
 		return inwx.NewDNSProvider()
 	case "ionos":
diff --git a/providers/dns/internetbs/internal/client.go b/providers/dns/internetbs/internal/client.go
new file mode 100644
index 00000000..767de2ad
--- /dev/null
+++ b/providers/dns/internetbs/internal/client.go
@@ -0,0 +1,140 @@
+package internal
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"path"
+	"path/filepath"
+	"strings"
+	"time"
+	"unicode"
+
+	querystring "github.com/google/go-querystring/query"
+)
+
+const baseURL = "https://api.internet.bs"
+
+// status SUCCESS, PENDING, FAILURE.
+const statusSuccess = "SUCCESS"
+
+// Client is the API client.
+type Client struct {
+	HTTPClient *http.Client
+	baseURL    *url.URL
+	debug      bool
+
+	apiKey   string
+	password string
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey string, password string) *Client {
+	baseURL, _ := url.Parse(baseURL)
+
+	return &Client{
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
+		baseURL:    baseURL,
+		apiKey:     apiKey,
+		password:   password,
+	}
+}
+
+// AddRecord The command is intended to add a new DNS record to a specific zone (domain).
+func (c Client) AddRecord(query RecordQuery) error {
+	var r APIResponse
+	err := c.do("Add", query, &r)
+	if err != nil {
+		return err
+	}
+
+	if r.Status != statusSuccess {
+		return r
+	}
+
+	return nil
+}
+
+// RemoveRecord The command is intended to remove a DNS record from a specific zone.
+func (c Client) RemoveRecord(query RecordQuery) error {
+	var r APIResponse
+	err := c.do("Remove", query, &r)
+	if err != nil {
+		return err
+	}
+
+	if r.Status != statusSuccess {
+		return r
+	}
+
+	return nil
+}
+
+// ListRecords The command is intended to retrieve the list of DNS records for a specific domain.
+func (c Client) ListRecords(query ListRecordQuery) ([]Record, error) {
+	var l ListResponse
+	err := c.do("List", query, &l)
+	if err != nil {
+		return nil, err
+	}
+
+	if l.Status != statusSuccess {
+		return nil, l.APIResponse
+	}
+
+	return l.Records, nil
+}
+
+func (c Client) do(action string, params interface{}, response interface{}) error {
+	endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "Domain", "DnsRecord", action))
+	if err != nil {
+		return fmt.Errorf("create endpoint: %w", err)
+	}
+
+	values, err := querystring.Values(params)
+	if err != nil {
+		return fmt.Errorf("parse query parameters: %w", err)
+	}
+
+	values.Set("apiKey", c.apiKey)
+	values.Set("password", c.password)
+	values.Set("ResponseFormat", "JSON")
+
+	resp, err := c.HTTPClient.PostForm(endpoint.String(), values)
+	if err != nil {
+		return fmt.Errorf("post request: %w", err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		data, _ := ioutil.ReadAll(resp.Body)
+		return fmt.Errorf("status code: %d, %s", resp.StatusCode, string(data))
+	}
+
+	if c.debug {
+		return dump(endpoint, resp, response)
+	}
+
+	return json.NewDecoder(resp.Body).Decode(response)
+}
+
+func dump(endpoint *url.URL, resp *http.Response, response interface{}) error {
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+
+	fields := strings.FieldsFunc(endpoint.Path, func(r rune) bool {
+		return !unicode.IsLetter(r) && !unicode.IsNumber(r)
+	})
+
+	err = ioutil.WriteFile(filepath.Join("fixtures", strings.Join(fields, "_")+".json"), data, 0o666)
+	if err != nil {
+		return err
+	}
+
+	return json.Unmarshal(data, response)
+}
diff --git a/providers/dns/internetbs/internal/client_test.go b/providers/dns/internetbs/internal/client_test.go
new file mode 100644
index 00000000..0efc6cab
--- /dev/null
+++ b/providers/dns/internetbs/internal/client_test.go
@@ -0,0 +1,258 @@
+package internal
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"strconv"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const testBaseURL = "https://testapi.internet.bs"
+
+const (
+	testAPIKey   = "testapi"
+	testPassword = "testpass"
+)
+
+func TestClient_AddRecord(t *testing.T) {
+	client := setupTest(t, "/Domain/DnsRecord/Add", "./fixtures/Domain_DnsRecord_Add_SUCCESS.json")
+
+	query := RecordQuery{
+		FullRecordName: "www.example.com",
+		Type:           "TXT",
+		Value:          "xxx",
+		TTL:            36000,
+	}
+
+	err := client.AddRecord(query)
+	require.NoError(t, err)
+}
+
+func TestClient_AddRecord_error(t *testing.T) {
+	client := setupTest(t, "/Domain/DnsRecord/Add", "./fixtures/Domain_DnsRecord_Add_FAILURE.json")
+
+	query := RecordQuery{
+		FullRecordName: "www.example.com.",
+		Type:           "TXT",
+		Value:          "xxx",
+		TTL:            36000,
+	}
+
+	err := client.AddRecord(query)
+	require.Error(t, err)
+}
+
+func TestClient_AddRecord_integration(t *testing.T) {
+	env, ok := os.LookupEnv("INTERNET_BS_DEBUG")
+	if !ok {
+		t.Skip("skip integration test")
+	}
+
+	client := NewClient(testAPIKey, testPassword)
+	client.baseURL, _ = url.Parse(testBaseURL)
+	client.debug, _ = strconv.ParseBool(env)
+
+	query := RecordQuery{
+		FullRecordName: "www.example.com",
+		Type:           "TXT",
+		Value:          "xxx",
+		TTL:            36000,
+	}
+
+	err := client.AddRecord(query)
+	require.NoError(t, err)
+
+	query = RecordQuery{
+		FullRecordName: "www.example.com",
+		Type:           "TXT",
+		Value:          "yyy",
+		TTL:            36000,
+	}
+
+	err = client.AddRecord(query)
+	require.NoError(t, err)
+}
+
+func TestClient_RemoveRecord(t *testing.T) {
+	client := setupTest(t, "/Domain/DnsRecord/Remove", "./fixtures/Domain_DnsRecord_Remove_SUCCESS.json")
+
+	query := RecordQuery{
+		FullRecordName: "www.example.com",
+		Type:           "TXT",
+		Value:          "",
+	}
+	err := client.RemoveRecord(query)
+	require.NoError(t, err)
+}
+
+func TestClient_RemoveRecord_error(t *testing.T) {
+	client := setupTest(t, "/Domain/DnsRecord/Remove", "./fixtures/Domain_DnsRecord_Remove_FAILURE.json")
+
+	query := RecordQuery{
+		FullRecordName: "www.example.com.",
+		Type:           "TXT",
+		Value:          "",
+	}
+	err := client.RemoveRecord(query)
+	require.Error(t, err)
+}
+
+func TestClient_RemoveRecord_integration(t *testing.T) {
+	env, ok := os.LookupEnv("INTERNET_BS_DEBUG")
+	if !ok {
+		t.Skip("skip integration test")
+	}
+
+	client := NewClient(testAPIKey, testPassword)
+	client.baseURL, _ = url.Parse(testBaseURL)
+	client.debug, _ = strconv.ParseBool(env)
+
+	query := RecordQuery{
+		FullRecordName: "www.example.com",
+		Type:           "TXT",
+		Value:          "",
+	}
+
+	err := client.RemoveRecord(query)
+	require.NoError(t, err)
+}
+
+func TestClient_ListRecords(t *testing.T) {
+	client := setupTest(t, "/Domain/DnsRecord/List", "./fixtures/Domain_DnsRecord_List_SUCCESS.json")
+
+	query := ListRecordQuery{
+		Domain: "example.com",
+	}
+
+	records, err := client.ListRecords(query)
+	require.NoError(t, err)
+
+	expected := []Record{
+		{
+			Name:  "example.com",
+			Value: "ns-hongkong.internet.bs",
+			TTL:   3600,
+			Type:  "NS",
+		},
+		{
+			Name:  "example.com",
+			Value: "ns-toronto.internet.bs",
+			TTL:   3600,
+			Type:  "NS",
+		},
+		{
+			Name:  "example.com",
+			Value: "ns-london.internet.bs",
+			TTL:   3600,
+			Type:  "NS",
+		},
+		{
+			Name:  "test.example.com",
+			Value: "example1.com",
+			TTL:   3600,
+			Type:  "CNAME",
+		},
+		{
+			Name:  "www.example.com",
+			Value: "xxx",
+			TTL:   36000,
+			Type:  "TXT",
+		},
+		{
+			Name:  "www.example.com",
+			Value: "yyy",
+			TTL:   36000,
+			Type:  "TXT",
+		},
+	}
+
+	assert.Equal(t, expected, records)
+}
+
+func TestClient_ListRecords_error(t *testing.T) {
+	client := setupTest(t, "/Domain/DnsRecord/List", "./fixtures/Domain_DnsRecord_List_FAILURE.json")
+
+	query := ListRecordQuery{
+		Domain: "www.example.com",
+	}
+
+	_, err := client.ListRecords(query)
+	require.Error(t, err)
+}
+
+func TestClient_ListRecords_integration(t *testing.T) {
+	env, ok := os.LookupEnv("INTERNET_BS_DEBUG")
+	if !ok {
+		t.Skip("skip integration test")
+	}
+
+	client := NewClient(testAPIKey, testPassword)
+	client.baseURL, _ = url.Parse(testBaseURL)
+	client.debug, _ = strconv.ParseBool(env)
+
+	query := ListRecordQuery{
+		Domain: "example.com",
+	}
+
+	records, err := client.ListRecords(query)
+	require.NoError(t, err)
+
+	for _, record := range records {
+		fmt.Println(record)
+	}
+}
+
+func setupTest(t *testing.T, path, filename string) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(path, testHandler(filename))
+
+	client := NewClient(testAPIKey, testPassword)
+	client.baseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func testHandler(filename string) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
+			return
+		}
+
+		if req.FormValue("apiKey") != testAPIKey {
+			http.Error(rw, `{"transactid":"d46d812569acdb8b39c3933ec4351e79","status":"FAILURE","message":"Invalid API key and\/or Password","code":107002}`, http.StatusOK)
+			return
+		}
+
+		if req.FormValue("password") != testPassword {
+			http.Error(rw, `{"transactid":"d46d812569acdb8b39c3933ec4351e79","status":"FAILURE","message":"Invalid API key and\/or Password","code":107002}`, http.StatusOK)
+			return
+		}
+
+		file, err := os.Open(filename)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = file.Close() }()
+
+		_, err = io.Copy(rw, file)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+}
diff --git a/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_FAILURE.json b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_FAILURE.json
new file mode 100644
index 00000000..4b3f9bc1
--- /dev/null
+++ b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_FAILURE.json
@@ -0,0 +1,6 @@
+{
+  "transactid": "67e4689073df2f153e7184aeb47a98f9",
+  "status": "FAILURE",
+  "message": "Invalid value \"www.example.com.\" for parameter \"fullrecordname\"!",
+  "code": 100002
+}
diff --git a/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_SUCCESS.json b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_SUCCESS.json
new file mode 100644
index 00000000..29ec476b
--- /dev/null
+++ b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_SUCCESS.json
@@ -0,0 +1,4 @@
+{
+  "transactid": "548e3298130b492de23258634fd74481",
+  "status": "SUCCESS"
+}
diff --git a/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_FAILURE.json b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_FAILURE.json
new file mode 100644
index 00000000..fdeb5b51
--- /dev/null
+++ b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_FAILURE.json
@@ -0,0 +1,6 @@
+{
+  "transactid": "5d554e0a5d145feb316b1805aae50706",
+  "status": "FAILURE",
+  "message": "The domain www.example.com does not have a supported extension!",
+  "code": 100004
+}
diff --git a/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_SUCCESS.json b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_SUCCESS.json
new file mode 100644
index 00000000..32ef443b
--- /dev/null
+++ b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_SUCCESS.json
@@ -0,0 +1,43 @@
+{
+  "transactid": "3d161c37da7c824c8b3463b25f461df0",
+  "status": "SUCCESS",
+  "total_records": 6,
+  "records": [
+    {
+      "name": "example.com",
+      "value": "ns-hongkong.internet.bs",
+      "ttl": 3600,
+      "type": "NS"
+    },
+    {
+      "name": "example.com",
+      "value": "ns-toronto.internet.bs",
+      "ttl": 3600,
+      "type": "NS"
+    },
+    {
+      "name": "example.com",
+      "value": "ns-london.internet.bs",
+      "ttl": 3600,
+      "type": "NS"
+    },
+    {
+      "name": "test.example.com",
+      "value": "example1.com",
+      "ttl": 3600,
+      "type": "CNAME"
+    },
+    {
+      "name": "www.example.com",
+      "value": "xxx",
+      "ttl": 36000,
+      "type": "TXT"
+    },
+    {
+      "name": "www.example.com",
+      "value": "yyy",
+      "ttl": 36000,
+      "type": "TXT"
+    }
+  ]
+}
diff --git a/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Remove_SUCCESS.json b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Remove_SUCCESS.json
new file mode 100644
index 00000000..c4911eb9
--- /dev/null
+++ b/providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Remove_SUCCESS.json
@@ -0,0 +1,4 @@
+{
+  "transactid": "221a0fe572f0505194214405f395a847",
+  "status": "SUCCESS"
+}
diff --git a/providers/dns/internetbs/internal/types.go b/providers/dns/internetbs/internal/types.go
new file mode 100644
index 00000000..1b538e1b
--- /dev/null
+++ b/providers/dns/internetbs/internal/types.go
@@ -0,0 +1,40 @@
+package internal
+
+import "fmt"
+
+type APIResponse struct {
+	TransactID string `json:"transactid"`
+	Status     string `json:"status"`
+	Message    string `json:"message,omitempty"`
+	Code       int    `json:"code,omitempty"`
+}
+
+func (a APIResponse) Error() string {
+	return fmt.Sprintf("%s(%d): %s (%s)", a.Status, a.Code, a.Message, a.TransactID)
+}
+
+type ListResponse struct {
+	APIResponse
+
+	TotalRecords int      `json:"total_records,omitempty"`
+	Records      []Record `json:"records,omitempty"`
+}
+
+type Record struct {
+	Name  string `json:"name,omitempty"`
+	Value string `json:"value,omitempty"`
+	TTL   int    `json:"ttl,omitempty"`
+	Type  string `json:"type,omitempty"`
+}
+
+type RecordQuery struct {
+	FullRecordName string `url:"fullrecordname"`
+	Type           string `url:"type"`
+	Value          string `url:"value,omitempty"`
+	TTL            int    `url:"ttl,omitempty"`
+}
+
+type ListRecordQuery struct {
+	Domain     string `url:"Domain"`
+	FilterType string `url:"FilterType,omitempty"`
+}
diff --git a/providers/dns/internetbs/internetbs.go b/providers/dns/internetbs/internetbs.go
new file mode 100644
index 00000000..5d94283d
--- /dev/null
+++ b/providers/dns/internetbs/internetbs.go
@@ -0,0 +1,135 @@
+// Package internetbs implements a DNS provider for solving the DNS-01 challenge using internet.bs.
+package internetbs
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"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/internetbs/internal"
+)
+
+// Environment variables names.
+const (
+	envNamespace = "INTERNET_BS_"
+
+	EnvAPIKey   = envNamespace + "API_KEY"
+	EnvPassword = envNamespace + "PASSWORD"
+
+	EnvTTL                = envNamespace + "TTL"
+	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
+	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+	APIKey             string
+	Password           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, 3600),
+		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+		},
+	}
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+	config *Config
+	client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for internet.bs.
+// Credentials must be passed in the environment variable: INTERNET_BS_API_KEY, INTERNET_BS_PASSWORD.
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get(EnvAPIKey, EnvPassword)
+	if err != nil {
+		return nil, fmt.Errorf("internetbs: %w", err)
+	}
+
+	config := NewDefaultConfig()
+	config.APIKey = values[EnvAPIKey]
+	config.Password = values[EnvPassword]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for internet.bs.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("internetbs: the configuration of the DNS provider is nil")
+	}
+
+	if config.APIKey == "" || config.Password == "" {
+		return nil, errors.New("internetbs: missing credentials")
+	}
+
+	client := internal.NewClient(config.APIKey, config.Password)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{
+		config: config,
+		client: client,
+	}, 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 using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	fqdn, value := dns01.GetRecord(domain, keyAuth)
+
+	query := internal.RecordQuery{
+		FullRecordName: dns01.UnFqdn(fqdn),
+		Type:           "TXT",
+		Value:          value,
+		TTL:            d.config.TTL,
+	}
+
+	err := d.client.AddRecord(query)
+	if err != nil {
+		return fmt.Errorf("internetbs: %w", 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)
+
+	query := internal.RecordQuery{
+		FullRecordName: dns01.UnFqdn(fqdn),
+		Type:           "TXT",
+		Value:          value,
+		TTL:            d.config.TTL,
+	}
+
+	err := d.client.RemoveRecord(query)
+	if err != nil {
+		return fmt.Errorf("internetbs: %w", err)
+	}
+
+	return nil
+}
diff --git a/providers/dns/internetbs/internetbs.toml b/providers/dns/internetbs/internetbs.toml
new file mode 100644
index 00000000..63bb1392
--- /dev/null
+++ b/providers/dns/internetbs/internetbs.toml
@@ -0,0 +1,24 @@
+Name = "Internet.bs"
+Description = ''''''
+URL = "https://internetbs.net"
+Code = "internetbs"
+Since = "v4.5.0"
+
+Example = '''
+INTERNET_BS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \
+INTERNET_BS_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \
+lego --email myemail@example.com --dns internetbs --domains my.example.org run
+'''
+
+[Configuration]
+  [Configuration.Credentials]
+    INTERNET_BS_API_KEY = "API key"
+    INTERNET_BS_PASSWORD = "API password"
+  [Configuration.Additional]
+    INTERNET_BS_POLLING_INTERVAL = "Time between DNS propagation check"
+    INTERNET_BS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
+    INTERNET_BS_TTL = "The TTL of the TXT record used for the DNS challenge"
+    INTERNET_BS_HTTP_TIMEOUT = "API request timeout"
+
+[Links]
+  API = "https://internetbs.net/internet-bs-api.pdf"
diff --git a/providers/dns/internetbs/internetbs_test.go b/providers/dns/internetbs/internetbs_test.go
new file mode 100644
index 00000000..e46c8c23
--- /dev/null
+++ b/providers/dns/internetbs/internetbs_test.go
@@ -0,0 +1,144 @@
+package internetbs
+
+import (
+	"testing"
+	"time"
+
+	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIKey, EnvPassword).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "success",
+			envVars: map[string]string{
+				EnvAPIKey:   "user",
+				EnvPassword: "secret",
+			},
+		},
+		{
+			desc: "missing API key",
+			envVars: map[string]string{
+				EnvPassword: "secret",
+			},
+			expected: "internetbs: some credentials information are missing: INTERNET_BS_API_KEY",
+		},
+		{
+			desc: "missing password",
+			envVars: map[string]string{
+				EnvAPIKey: "user",
+			},
+			expected: "internetbs: some credentials information are missing: INTERNET_BS_PASSWORD",
+		},
+		{
+			desc:     "missing credentials",
+			envVars:  map[string]string{},
+			expected: "internetbs: some credentials information are missing: INTERNET_BS_API_KEY,INTERNET_BS_PASSWORD",
+		},
+	}
+
+	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 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 TestNewDNSProviderConfig(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		apiKey   string
+		password string
+		expected string
+	}{
+		{
+			desc:     "success",
+			apiKey:   "user",
+			password: "secret",
+		},
+		{
+			desc:     "missing API key",
+			expected: "internetbs: missing credentials",
+			password: "secret",
+		},
+		{
+			desc:     "missing password",
+			expected: "internetbs: missing credentials",
+			apiKey:   "user",
+		},
+		{
+			desc:     "missing credentials",
+			expected: "internetbs: missing credentials",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.APIKey = test.apiKey
+			config.Password = test.password
+
+			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 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)
+}