forked from TrueCloudLab/lego
Add DNS provider for HyperOne (#1233)
This commit is contained in:
parent
d14bef50f3
commit
7557dbc98c
21 changed files with 1440 additions and 10 deletions
20
README.md
20
README.md
|
@ -55,15 +55,15 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
|
||||||
| [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | [FastDNS (Deprecated)](https://go-acme.github.io/lego/dns/fastdns/) |
|
| [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | [FastDNS (Deprecated)](https://go-acme.github.io/lego/dns/fastdns/) |
|
||||||
| [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/) |
|
| [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/) |
|
| [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/) |
|
||||||
| [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) |
|
| [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Joker](https://go-acme.github.io/lego/dns/joker/) |
|
||||||
| [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) |
|
| [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) |
|
||||||
| [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/) |
|
| [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/) |
|
||||||
| [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/) |
|
| [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/) |
|
||||||
| [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/) |
|
| [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [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/) |
|
||||||
| [OVH](https://go-acme.github.io/lego/dns/ovh/) | [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/) |
|
| [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [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/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) |
|
| [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/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) |
|
||||||
| [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) |
|
| [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/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) |
|
||||||
| [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/) | |
|
| [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/) |
|
||||||
|
|
||||||
<!-- END DNS PROVIDERS LIST -->
|
<!-- END DNS PROVIDERS LIST -->
|
||||||
|
|
|
@ -53,6 +53,7 @@ func allDNSCodes() string {
|
||||||
"hetzner",
|
"hetzner",
|
||||||
"hostingde",
|
"hostingde",
|
||||||
"httpreq",
|
"httpreq",
|
||||||
|
"hyperone",
|
||||||
"iij",
|
"iij",
|
||||||
"inwx",
|
"inwx",
|
||||||
"joker",
|
"joker",
|
||||||
|
@ -928,6 +929,24 @@ func displayDNSHelp(name string) error {
|
||||||
ew.writeln()
|
ew.writeln()
|
||||||
ew.writeln(`More information: https://go-acme.github.io/lego/dns/httpreq`)
|
ew.writeln(`More information: https://go-acme.github.io/lego/dns/httpreq`)
|
||||||
|
|
||||||
|
case "hyperone":
|
||||||
|
// generated from: providers/dns/hyperone/hyperone.toml
|
||||||
|
ew.writeln(`Configuration for HyperOne.`)
|
||||||
|
ew.writeln(`Code: 'hyperone'`)
|
||||||
|
ew.writeln(`Since: ''`)
|
||||||
|
ew.writeln()
|
||||||
|
|
||||||
|
ew.writeln(`Additional Configuration:`)
|
||||||
|
ew.writeln(` - "HYPERONE_API_URL": Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2)`)
|
||||||
|
ew.writeln(` - "HYPERONE_LOCATION_ID": Specifies location (region) to be used in API calls. (default pl-waw-1)`)
|
||||||
|
ew.writeln(` - "HYPERONE_PASSPORT_LOCATION": Allows to pass custom passport file location (default ~/.h1/passport.json)`)
|
||||||
|
ew.writeln(` - "HYPERONE_POLLING_INTERVAL": Time between DNS propagation check`)
|
||||||
|
ew.writeln(` - "HYPERONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
|
||||||
|
ew.writeln(` - "HYPERONE_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/hyperone`)
|
||||||
|
|
||||||
case "iij":
|
case "iij":
|
||||||
// generated from: providers/dns/iij/iij.toml
|
// generated from: providers/dns/iij/iij.toml
|
||||||
ew.writeln(`Configuration for Internet Initiative Japan.`)
|
ew.writeln(`Configuration for Internet Initiative Japan.`)
|
||||||
|
|
78
docs/content/dns/zz_gen_hyperone.md
Normal file
78
docs/content/dns/zz_gen_hyperone.md
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
title: "HyperOne"
|
||||||
|
date: 2019-03-03T16:39:46+01:00
|
||||||
|
draft: false
|
||||||
|
slug: hyperone
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
|
||||||
|
<!-- providers/dns/hyperone/hyperone.toml -->
|
||||||
|
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
|
||||||
|
|
||||||
|
Since:
|
||||||
|
|
||||||
|
Configuration for [HyperOne](https://www.hyperone.com).
|
||||||
|
|
||||||
|
|
||||||
|
<!--more-->
|
||||||
|
|
||||||
|
- Code: `hyperone`
|
||||||
|
|
||||||
|
Here is an example bash command using the HyperOne provider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lego --dns hyperone --domains my.domain.com --email my@email.com run
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Additional Configuration
|
||||||
|
|
||||||
|
| Environment Variable Name | Description |
|
||||||
|
|--------------------------------|-------------|
|
||||||
|
| `HYPERONE_API_URL` | Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2) |
|
||||||
|
| `HYPERONE_LOCATION_ID` | Specifies location (region) to be used in API calls. (default pl-waw-1) |
|
||||||
|
| `HYPERONE_PASSPORT_LOCATION` | Allows to pass custom passport file location (default ~/.h1/passport.json) |
|
||||||
|
| `HYPERONE_POLLING_INTERVAL` | Time between DNS propagation check |
|
||||||
|
| `HYPERONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
|
||||||
|
| `HYPERONE_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
|
||||||
|
|
||||||
|
Default configuration does not require any additional environment variables,
|
||||||
|
just a passport file in `~/.h1/passport.json` location.
|
||||||
|
|
||||||
|
### Generating passport file using H1 CLI
|
||||||
|
|
||||||
|
To use this application you have to generate passport file for `sa`:
|
||||||
|
|
||||||
|
```
|
||||||
|
h1 sa credential generate --name my-passport --sa <sa ID> --passport-output-file ~/.h1/passport.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required permissions
|
||||||
|
|
||||||
|
Depending of environment variables usage, the application requires different permissions:
|
||||||
|
- `dns/zone/list` if `HYPERONE_ZONE_URI` is not specified
|
||||||
|
- `dns/zone.recordset/list`
|
||||||
|
- `dns/zone.recordset/create`
|
||||||
|
- `dns/zone.recordset/delete`
|
||||||
|
- `dns/zone.record/create`
|
||||||
|
- `dns/zone.record/list`
|
||||||
|
- `dns/zone.record/delete`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## More information
|
||||||
|
|
||||||
|
- [API documentation](https://api.hyperone.com/v2/docs)
|
||||||
|
|
||||||
|
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
|
||||||
|
<!-- providers/dns/hyperone/hyperone.toml -->
|
||||||
|
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
|
|
@ -44,6 +44,7 @@ import (
|
||||||
"github.com/go-acme/lego/v3/providers/dns/hetzner"
|
"github.com/go-acme/lego/v3/providers/dns/hetzner"
|
||||||
"github.com/go-acme/lego/v3/providers/dns/hostingde"
|
"github.com/go-acme/lego/v3/providers/dns/hostingde"
|
||||||
"github.com/go-acme/lego/v3/providers/dns/httpreq"
|
"github.com/go-acme/lego/v3/providers/dns/httpreq"
|
||||||
|
"github.com/go-acme/lego/v3/providers/dns/hyperone"
|
||||||
"github.com/go-acme/lego/v3/providers/dns/iij"
|
"github.com/go-acme/lego/v3/providers/dns/iij"
|
||||||
"github.com/go-acme/lego/v3/providers/dns/inwx"
|
"github.com/go-acme/lego/v3/providers/dns/inwx"
|
||||||
"github.com/go-acme/lego/v3/providers/dns/joker"
|
"github.com/go-acme/lego/v3/providers/dns/joker"
|
||||||
|
@ -166,6 +167,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
|
||||||
return hostingde.NewDNSProvider()
|
return hostingde.NewDNSProvider()
|
||||||
case "httpreq":
|
case "httpreq":
|
||||||
return httpreq.NewDNSProvider()
|
return httpreq.NewDNSProvider()
|
||||||
|
case "hyperone":
|
||||||
|
return hyperone.NewDNSProvider()
|
||||||
case "iij":
|
case "iij":
|
||||||
return iij.NewDNSProvider()
|
return iij.NewDNSProvider()
|
||||||
case "inwx":
|
case "inwx":
|
||||||
|
|
203
providers/dns/hyperone/hyperone.go
Normal file
203
providers/dns/hyperone/hyperone.go
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
// Package hyperone implements a DNS provider for solving the DNS-01 challenge using HyperOne.
|
||||||
|
package hyperone
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-acme/lego/v3/challenge/dns01"
|
||||||
|
"github.com/go-acme/lego/v3/platform/config/env"
|
||||||
|
"github.com/go-acme/lego/v3/providers/dns/hyperone/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Environment variables names.
|
||||||
|
const (
|
||||||
|
envNamespace = "HYPERONE_"
|
||||||
|
|
||||||
|
EnvPassportLocation = envNamespace + "PASSPORT_LOCATION"
|
||||||
|
EnvAPIUrl = envNamespace + "API_URL"
|
||||||
|
EnvLocationID = envNamespace + "LOCATION_ID"
|
||||||
|
|
||||||
|
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 {
|
||||||
|
APIEndpoint string
|
||||||
|
LocationID string
|
||||||
|
PassportLocation string
|
||||||
|
|
||||||
|
TTL int
|
||||||
|
PropagationTimeout time.Duration
|
||||||
|
PollingInterval time.Duration
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDefaultConfig returns a default configuration for the DNSProvider.
|
||||||
|
func NewDefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
|
||||||
|
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 {
|
||||||
|
client *internal.Client
|
||||||
|
config *Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDNSProvider returns a DNSProvider instance configured for HyperOne.
|
||||||
|
func NewDNSProvider() (*DNSProvider, error) {
|
||||||
|
config := NewDefaultConfig()
|
||||||
|
|
||||||
|
config.PassportLocation = env.GetOrFile(EnvPassportLocation)
|
||||||
|
config.LocationID = env.GetOrFile(EnvLocationID)
|
||||||
|
config.APIEndpoint = env.GetOrFile(EnvAPIUrl)
|
||||||
|
|
||||||
|
return NewDNSProviderConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDNSProviderConfig return a DNSProvider instance configured for HyperOne.
|
||||||
|
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
|
||||||
|
if config.PassportLocation == "" {
|
||||||
|
var err error
|
||||||
|
config.PassportLocation, err = GetDefaultPassportLocation()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hyperone: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passport, err := internal.LoadPassportFile(config.PassportLocation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hyperone: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := internal.NewClient(config.APIEndpoint, config.LocationID, passport)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hyperone: failed to create client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
zone, err := d.getHostedZone(fqdn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", fqdn, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordset, err := d.client.FindRecordset(zone.ID, "TXT", fqdn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", fqdn, zone.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recordset == nil {
|
||||||
|
_, err = d.client.CreateRecordset(zone.ID, "TXT", fqdn, value, d.config.TTL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: failed to create recordset: fqdn=%s, zone ID=%s, value=%s: %w", fqdn, zone.ID, value, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.client.CreateRecord(zone.ID, recordset.ID, value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: failed to create record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", fqdn, zone.ID, recordset.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanUp removes the TXT record matching the specified parameters and recordset if no other records are remaining.
|
||||||
|
// There is a small possibility that race will cause to delete recordset with records for other DNS Challenges.
|
||||||
|
func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
|
||||||
|
fqdn, value := dns01.GetRecord(domain, keyAuth)
|
||||||
|
|
||||||
|
zone, err := d.getHostedZone(fqdn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", fqdn, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordset, err := d.client.FindRecordset(zone.ID, "TXT", fqdn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", fqdn, zone.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recordset == nil {
|
||||||
|
return fmt.Errorf("hyperone: recordset to remove not found: fqdn=%s", fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := d.client.GetRecords(zone.ID, recordset.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(records) == 1 {
|
||||||
|
if records[0].Content != value {
|
||||||
|
return fmt.Errorf("hyperone: record with content %s not found: fqdn=%s", value, fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = d.client.DeleteRecordset(zone.ID, recordset.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: failed to delete record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", fqdn, zone.ID, recordset.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if record.Content == value {
|
||||||
|
err = d.client.DeleteRecord(zone.ID, recordset.ID, record.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s, recordset ID=%s, record ID=%s: %w", fqdn, zone.ID, recordset.ID, record.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("hyperone: fqdn=%s, failed to find record with given value", fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHostedZone gets the hosted zone.
|
||||||
|
func (d *DNSProvider) getHostedZone(fqdn string) (*internal.Zone, error) {
|
||||||
|
authZone, err := dns01.FindZoneByFqdn(fqdn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.client.FindZone(authZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDefaultPassportLocation() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(homeDir, ".h1", "passport.json"), nil
|
||||||
|
}
|
46
providers/dns/hyperone/hyperone.toml
Normal file
46
providers/dns/hyperone/hyperone.toml
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
Name = "HyperOne"
|
||||||
|
Description = ''''''
|
||||||
|
URL = "https://www.hyperone.com"
|
||||||
|
Code = "hyperone"
|
||||||
|
|
||||||
|
Example = '''
|
||||||
|
lego --dns hyperone --domains my.domain.com --email my@email.com run
|
||||||
|
'''
|
||||||
|
|
||||||
|
Additional = '''
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Default configuration does not require any additional environment variables,
|
||||||
|
just a passport file in `~/.h1/passport.json` location.
|
||||||
|
|
||||||
|
### Generating passport file using H1 CLI
|
||||||
|
|
||||||
|
To use this application you have to generate passport file for `sa`:
|
||||||
|
|
||||||
|
```
|
||||||
|
h1 sa credential generate --name my-passport --sa <sa ID> --passport-output-file ~/.h1/passport.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required permissions
|
||||||
|
|
||||||
|
Depending of environment variables usage, the application requires different permissions:
|
||||||
|
- `dns/zone/list` if `HYPERONE_ZONE_URI` is not specified
|
||||||
|
- `dns/zone.recordset/list`
|
||||||
|
- `dns/zone.recordset/create`
|
||||||
|
- `dns/zone.recordset/delete`
|
||||||
|
- `dns/zone.record/create`
|
||||||
|
- `dns/zone.record/list`
|
||||||
|
- `dns/zone.record/delete`
|
||||||
|
'''
|
||||||
|
|
||||||
|
[Configuration]
|
||||||
|
[Configuration.Additional]
|
||||||
|
HYPERONE_PASSPORT_LOCATION = "Allows to pass custom passport file location (default ~/.h1/passport.json)"
|
||||||
|
HYPERONE_API_URL = "Allows to pass custom API Endpoint to be used in the challenge (default https://api.hyperone.com/v2)"
|
||||||
|
HYPERONE_LOCATION_ID = "Specifies location (region) to be used in API calls. (default pl-waw-1)"
|
||||||
|
HYPERONE_TTL = "The TTL of the TXT record used for the DNS challenge"
|
||||||
|
HYPERONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
|
||||||
|
HYPERONE_POLLING_INTERVAL = "Time between DNS propagation check"
|
||||||
|
|
||||||
|
[Links]
|
||||||
|
API = "https://api.hyperone.com/v2/docs"
|
145
providers/dns/hyperone/hyperone_test.go
Normal file
145
providers/dns/hyperone/hyperone_test.go
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
package hyperone
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-acme/lego/v3/platform/tester"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const envDomain = envNamespace + "DOMAIN"
|
||||||
|
|
||||||
|
var envTest = tester.NewEnvTest(EnvPassportLocation, EnvAPIUrl, EnvLocationID).
|
||||||
|
WithDomain(envDomain)
|
||||||
|
|
||||||
|
func TestNewDNSProvider(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
desc string
|
||||||
|
envVars map[string]string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "success",
|
||||||
|
envVars: map[string]string{
|
||||||
|
EnvPassportLocation: "./internal/fixtures/validPassport.json",
|
||||||
|
EnvAPIUrl: "",
|
||||||
|
EnvLocationID: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "invalid passport",
|
||||||
|
envVars: map[string]string{
|
||||||
|
EnvPassportLocation: "./internal/fixtures/invalidPassport.json",
|
||||||
|
EnvAPIUrl: "",
|
||||||
|
EnvLocationID: "",
|
||||||
|
},
|
||||||
|
expected: "hyperone: passport file validation failed: private key is missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "non existing passport",
|
||||||
|
envVars: map[string]string{
|
||||||
|
EnvPassportLocation: "./internal/fixtures/non-existing.json",
|
||||||
|
EnvAPIUrl: "",
|
||||||
|
EnvLocationID: "",
|
||||||
|
},
|
||||||
|
expected: "hyperone: failed to open passport file: open ./internal/fixtures/non-existing.json:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 len(test.expected) == 0 {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, p)
|
||||||
|
require.NotNil(t, p.config)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), test.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDNSProviderConfig(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
desc string
|
||||||
|
passportLocation string
|
||||||
|
apiEndpoint string
|
||||||
|
locationID string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "success",
|
||||||
|
passportLocation: "./internal/fixtures/validPassport.json",
|
||||||
|
apiEndpoint: "",
|
||||||
|
locationID: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "invalid passport",
|
||||||
|
passportLocation: "./internal/fixtures/invalidPassport.json",
|
||||||
|
apiEndpoint: "",
|
||||||
|
locationID: "",
|
||||||
|
expected: "hyperone: passport file validation failed: private key is missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "non existing passport",
|
||||||
|
passportLocation: "./internal/fixtures/non-existing.json",
|
||||||
|
apiEndpoint: "",
|
||||||
|
locationID: "",
|
||||||
|
expected: "hyperone: failed to open passport file: open ./internal/fixtures/non-existing.json:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range testCases {
|
||||||
|
t.Run(test.desc, func(t *testing.T) {
|
||||||
|
config := NewDefaultConfig()
|
||||||
|
config.PassportLocation = test.passportLocation
|
||||||
|
config.APIEndpoint = test.apiEndpoint
|
||||||
|
config.LocationID = test.locationID
|
||||||
|
|
||||||
|
p, err := NewDNSProviderConfig(config)
|
||||||
|
|
||||||
|
if len(test.expected) == 0 {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, p)
|
||||||
|
require.NotNil(t, p.config)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), 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)
|
||||||
|
|
||||||
|
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
325
providers/dns/hyperone/internal/client.go
Normal file
325
providers/dns/hyperone/internal/client.go
Normal file
|
@ -0,0 +1,325 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultBaseURL = "https://api.hyperone.com/v2"
|
||||||
|
|
||||||
|
const defaultLocationID = "pl-waw-1"
|
||||||
|
|
||||||
|
type signer interface {
|
||||||
|
GetJWT() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client the HyperOne client.
|
||||||
|
type Client struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
|
||||||
|
apiEndpoint string
|
||||||
|
locationID string
|
||||||
|
projectID string
|
||||||
|
|
||||||
|
passport *Passport
|
||||||
|
signer signer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient Creates a new HyperOne client.
|
||||||
|
func NewClient(apiEndpoint, locationID string, passport *Passport) (*Client, error) {
|
||||||
|
if passport == nil {
|
||||||
|
return nil, errors.New("the passport is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID, err := passport.ExtractProjectID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := defaultBaseURL
|
||||||
|
if apiEndpoint != "" {
|
||||||
|
baseURL = apiEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenSigner := &TokenSigner{
|
||||||
|
PrivateKey: passport.PrivateKey,
|
||||||
|
KeyID: passport.CertificateID,
|
||||||
|
Audience: baseURL,
|
||||||
|
Issuer: passport.Issuer,
|
||||||
|
Subject: passport.SubjectID,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
apiEndpoint: baseURL,
|
||||||
|
locationID: locationID,
|
||||||
|
passport: passport,
|
||||||
|
projectID: projectID,
|
||||||
|
signer: tokenSigner,
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.locationID == "" {
|
||||||
|
client.locationID = defaultLocationID
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindRecordset looks for recordset with given recordType and name and returns it.
|
||||||
|
// In case if recordset is not found returns nil.
|
||||||
|
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_list
|
||||||
|
func (c *Client) FindRecordset(zoneID, recordType, name string) (*Recordset, error) {
|
||||||
|
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
|
||||||
|
resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset")
|
||||||
|
|
||||||
|
req, err := c.createRequest(http.MethodGet, resourceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordSets []Recordset
|
||||||
|
|
||||||
|
err = c.do(req, &recordSets)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get recordsets from server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range recordSets {
|
||||||
|
if v.RecordType == recordType && v.Name == name {
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// when recordset is not present returns nil, but error is not thrown
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRecordset creates recordset and record with given value within one request.
|
||||||
|
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_create
|
||||||
|
func (c *Client) CreateRecordset(zoneID, recordType, name, recordValue string, ttl int) (*Recordset, error) {
|
||||||
|
recordsetInput := Recordset{
|
||||||
|
RecordType: recordType,
|
||||||
|
Name: name,
|
||||||
|
TTL: ttl,
|
||||||
|
Record: &Record{Content: recordValue},
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(recordsetInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal recordset: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
|
||||||
|
resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset")
|
||||||
|
|
||||||
|
req, err := c.createRequest(http.MethodPost, resourceURL, bytes.NewBuffer(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordsetResponse Recordset
|
||||||
|
|
||||||
|
err = c.do(req, &recordsetResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create recordset: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &recordsetResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRecordset deletes a recordset.
|
||||||
|
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_delete
|
||||||
|
func (c *Client) DeleteRecordset(zoneID string, recordsetID string) error {
|
||||||
|
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}
|
||||||
|
resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID)
|
||||||
|
|
||||||
|
req, err := c.createRequest(http.MethodDelete, resourceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.do(req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecords gets all records within specified recordset.
|
||||||
|
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_list
|
||||||
|
func (c *Client) GetRecords(zoneID string, recordsetID string) ([]Record, error) {
|
||||||
|
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
|
||||||
|
resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record")
|
||||||
|
|
||||||
|
req, err := c.createRequest(http.MethodGet, resourceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []Record
|
||||||
|
|
||||||
|
err = c.do(req, &records)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get records from server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRecord creates a record.
|
||||||
|
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_create
|
||||||
|
func (c *Client) CreateRecord(zoneID, recordsetID, recordContent string) (*Record, error) {
|
||||||
|
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
|
||||||
|
resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record")
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(Record{Content: recordContent})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := c.createRequest(http.MethodPost, resourceURL, bytes.NewBuffer(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordResponse Record
|
||||||
|
|
||||||
|
err = c.do(req, &recordResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &recordResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRecord deletes a record.
|
||||||
|
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_delete
|
||||||
|
func (c *Client) DeleteRecord(zoneID, recordsetID, recordID string) error {
|
||||||
|
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record/{recordId}
|
||||||
|
resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record", recordID)
|
||||||
|
|
||||||
|
req, err := c.createRequest(http.MethodDelete, resourceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.do(req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindZone looks for DNS Zone and returns nil if it does not exist.
|
||||||
|
func (c *Client) FindZone(name string) (*Zone, error) {
|
||||||
|
zones, err := c.GetZones()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, zone := range zones {
|
||||||
|
if zone.DNSName == name {
|
||||||
|
return &zone, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to find zone for %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetZones gets all user's zones.
|
||||||
|
// https://api.hyperone.com/v2/docs#operation/dns_project_zone_list
|
||||||
|
func (c *Client) GetZones() ([]Zone, error) {
|
||||||
|
// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone
|
||||||
|
resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone")
|
||||||
|
|
||||||
|
req, err := c.createRequest(http.MethodGet, resourceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var zones []Zone
|
||||||
|
|
||||||
|
err = c.do(req, &zones)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch available zones: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zones, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) createRequest(method, uri string, body io.Reader) (*http.Request, error) {
|
||||||
|
baseURL, err := url.Parse(c.apiEndpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := baseURL.Parse(path.Join(baseURL.Path, uri))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, endpoint.String(), body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := c.signer.GetJWT()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sign the request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(req *http.Request, v interface{}) error {
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
err = checkResponse(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(raw, v); err != nil {
|
||||||
|
return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkResponse(resp *http.Response) error {
|
||||||
|
if resp.StatusCode/100 == 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg string
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
msg = "forbidden: check if service account you are trying to use has permissions required for managing DNS"
|
||||||
|
} else {
|
||||||
|
msg = fmt.Sprintf("%d: unknown error", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add response body to error message if not empty
|
||||||
|
responseBody, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
if len(responseBody) > 0 {
|
||||||
|
msg = fmt.Sprintf("%s: %s", msg, string(responseBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
219
providers/dns/hyperone/internal/client_test.go
Normal file
219
providers/dns/hyperone/internal/client_test.go
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type signerMock struct{}
|
||||||
|
|
||||||
|
func (s signerMock) GetJWT() (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_FindRecordset(t *testing.T) {
|
||||||
|
client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/zone321/recordset", respFromFile("recordset.json"))
|
||||||
|
|
||||||
|
recordset, err := client.FindRecordset("zone321", "SOA", "example.com.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := &Recordset{
|
||||||
|
ID: "123456789abcd",
|
||||||
|
Name: "example.com.",
|
||||||
|
RecordType: "SOA",
|
||||||
|
TTL: 1800,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expected, recordset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_CreateRecordset(t *testing.T) {
|
||||||
|
expectedReqBody := Recordset{
|
||||||
|
RecordType: "TXT",
|
||||||
|
Name: "test.example.com.",
|
||||||
|
TTL: 3600,
|
||||||
|
Record: &Record{Content: "value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/zone123/recordset",
|
||||||
|
hasReqBody(expectedReqBody), respFromFile("createRecordset.json"))
|
||||||
|
|
||||||
|
rs, err := client.CreateRecordset("zone123", "TXT", "test.example.com.", "value", 3600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := &Recordset{RecordType: "TXT", Name: "test.example.com.", TTL: 3600, ID: "1234567890qwertyuiop"}
|
||||||
|
assert.Equal(t, expected, rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_DeleteRecordset(t *testing.T) {
|
||||||
|
client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/zone321/recordset/rs322")
|
||||||
|
|
||||||
|
err := client.DeleteRecordset("zone321", "rs322")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_GetRecords(t *testing.T) {
|
||||||
|
client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/321/recordset/322/record", respFromFile("record.json"))
|
||||||
|
|
||||||
|
records, err := client.GetRecords("321", "322")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := []Record{
|
||||||
|
{
|
||||||
|
ID: "135128352183572dd",
|
||||||
|
Content: "pns.hyperone.com. hostmaster.hyperone.com. 1 15 180 1209600 1800",
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expected, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_CreateRecord(t *testing.T) {
|
||||||
|
expectedReqBody := Record{
|
||||||
|
Content: "value",
|
||||||
|
}
|
||||||
|
|
||||||
|
client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/z123/recordset/rs325/record",
|
||||||
|
hasReqBody(expectedReqBody), respFromFile("createRecord.json"))
|
||||||
|
|
||||||
|
rs, err := client.CreateRecord("z123", "rs325", "value")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := &Record{ID: "123321qwerqwewqerq", Content: "value", Enabled: true}
|
||||||
|
assert.Equal(t, expected, rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_DeleteRecord(t *testing.T) {
|
||||||
|
client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/321/recordset/322/record/323")
|
||||||
|
|
||||||
|
err := client.DeleteRecord("321", "322", "323")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_FindZone(t *testing.T) {
|
||||||
|
client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json"))
|
||||||
|
|
||||||
|
zone, err := client.FindZone("example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := &Zone{
|
||||||
|
ID: "zoneB",
|
||||||
|
Name: "example.com",
|
||||||
|
DNSName: "example.com",
|
||||||
|
FQDN: "example.com.",
|
||||||
|
URI: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expected, zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_GetZones(t *testing.T) {
|
||||||
|
client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json"))
|
||||||
|
|
||||||
|
zones, err := client.GetZones()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := []Zone{
|
||||||
|
{
|
||||||
|
ID: "zoneA",
|
||||||
|
Name: "example.org",
|
||||||
|
DNSName: "example.org",
|
||||||
|
FQDN: "example.org.",
|
||||||
|
URI: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "zoneB",
|
||||||
|
Name: "example.com",
|
||||||
|
DNSName: "example.com",
|
||||||
|
FQDN: "example.com.",
|
||||||
|
URI: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expected, zones)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTest(t *testing.T, method, path string, handlers ...assertHandler) *Client {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
|
||||||
|
mux.Handle(path, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != method {
|
||||||
|
http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(handlers) != 0 {
|
||||||
|
for _, handler := range handlers {
|
||||||
|
code, err := handler(rw, req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, err.Error(), code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
passport := &Passport{
|
||||||
|
SubjectID: "/iam/project/proj123/sa/xxxxxxx",
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewClient(server.URL, "loc123", passport)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client.signer = signerMock{}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
type assertHandler func(http.ResponseWriter, *http.Request) (int, error)
|
||||||
|
|
||||||
|
func hasReqBody(v interface{}) assertHandler {
|
||||||
|
return func(rw http.ResponseWriter, req *http.Request) (int, error) {
|
||||||
|
reqBody, err := ioutil.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadRequest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
marshal, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(marshal, reqBody) {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("invalid request body, got: %s, expect: %s", string(reqBody), string(marshal))
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respFromFile(fixtureName string) assertHandler {
|
||||||
|
return func(rw http.ResponseWriter, req *http.Request) (int, error) {
|
||||||
|
file, err := os.Open(filepath.Join(".", "fixtures", fixtureName))
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(rw, file)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"id": "123321qwerqwewqerq",
|
||||||
|
"content": "value",
|
||||||
|
"enabled": true
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"id": "1234567890qwertyuiop",
|
||||||
|
"name": "test.example.com.",
|
||||||
|
"type": "TXT",
|
||||||
|
"ttl": 3600
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"subject_id": "/iam/project/projectId/sa/serviceAccountId",
|
||||||
|
"certificate_id": "certificateID",
|
||||||
|
"issuer": "https://api.hyperone.com/v2/iam/project/projectId/sa/serviceAccountId"
|
||||||
|
}
|
7
providers/dns/hyperone/internal/fixtures/record.json
Normal file
7
providers/dns/hyperone/internal/fixtures/record.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "135128352183572dd",
|
||||||
|
"content": "pns.hyperone.com. hostmaster.hyperone.com. 1 15 180 1209600 1800",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
20
providers/dns/hyperone/internal/fixtures/recordset.json
Normal file
20
providers/dns/hyperone/internal/fixtures/recordset.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "123456789abcd",
|
||||||
|
"name": "example.com.",
|
||||||
|
"type": "SOA",
|
||||||
|
"ttl": 1800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "123456789abcde",
|
||||||
|
"name": "example.com.",
|
||||||
|
"type": "NS",
|
||||||
|
"ttl": 3600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "123456789abcdf",
|
||||||
|
"name": "example.com.",
|
||||||
|
"type": "CNAME",
|
||||||
|
"ttl": 3600
|
||||||
|
}
|
||||||
|
]
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"subject_id": "/iam/project/projectId/sa/serviceAccountId",
|
||||||
|
"certificate_id": "certificateID",
|
||||||
|
"issuer": "https://api.hyperone.com/v2/iam/project/projectId/sa/serviceAccountId",
|
||||||
|
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nlrMAsSjjkKiRxGdgR8p5kZJj0AFgdWYa3OT2snIXnN5+/p7j13PSkseUcrAFyokc\nV9pgeDfitAhb9lpdjxjjuxRcuQjBfmNVLPF9MFyNOvhrprGNukUh/12oSKO9dFEt\ns39F/2h6Ld5IQrGt3gZaBB1aGO+tw3ill1VBy2zGPIDeuSz6DS3GG/oQ2gLSSMP4\nOVfQ32Oajo496iHRkdIh/7Hho7BNzMYr1GxrYTcE9/Znr6xgeSdNT37CCeCH8cmP\naEAUgSMTeIMVSpILwkKeNvBURic1EWaqXRgPRIWK0vNyOCs/+jNoFISnV4pu1ROF\n92vayHDNSVw9wHcdSQ75XSE4Msawqv5U1iI7e2lD64uo1qhmJdrPcXDJQCiDbh+F\nhQhF+wAoLRvMNwwhg+LttL8vXqMDQl3olsWSvWPs6b/MZpB0qwd1bklzA6P+PeAU\nsfOvTqi9edIOfKqvXqTXEhBP8qC7ZtOKLGnryZb7W04SSVrNtuJUFRcLiqu+w/F/\nMSxGSGalYpzIZ1B5HLQqISgWMXdbt39uMeeooeZjkuI3VIllFjtybecjPR9ZYQPt\nFFEP1XqNXjLFmGh84TXtvGLWretWM1OZmN8UKKUeATqrr7zuh5AYGAIbXd8BvweL\nPigl9ei0hTculPqohvkoc5x1srPBvzHrirGlxOYjW3fc4kDgZpy+6ik5k5g7JWQD\nlbXCRz3HGazgUPeiwUr06a52vhgT7QuNIUZqdHb4IfCYs2pQTLHzQjAqvVk1mm2D\nkh4myIcTtf69BFcu/Wuptm3NaKd1nwk1squR6psvcTXOWII81pstnxNYkrokx4r2\n7YVllNruOD+cMDNZbIG2CwT6V9ukIS8tl9EJp8eyb0a1uAEc22BNOjYHPF50beWF\nukf3uc0SA+G3zhmXCM5sMf5OxVjKr5jgcir7kySY5KbmG71omYhczgr4H0qgxYo9\nZyj2wMKrTHLfFOpd4OOEun9Gi3srqlKZep7Hj7gNyUwZu1qiBvElmBVmp0HJxT0N\nmktuaVbaFgBsTS0/us1EqWvCA4REh1Ut/NoA9oG3JFt0lGDstTw1j+orDmIHOmSu\n7FKYzr0uCz14AkLMSOixdPD1F0YyED1NMVnRVXw77HiAFGmb0CDi2KEg70pEKpn3\nksa8oe0MQi6oEwlMsAxVTXOB1wblTBuSBeaECzTzWE+/DHF+QQfQi8kAjjSdmmMJ\nyN+shdBWHYRGYnxRkTatONhcDBIY7sZV7wolYHz/rf7dpYUZf37vdQnYV8FpO1um\nYa0GslyRJ5GqMBfDS1cQKne+FvVHxEE2YqEGBcOYhx/JI2soE8aA8W4XffN+DoEy\nZkinJ/+BOwJ/zUI9GZtwB4JXqbNEE+j7r7/fJO9KxfPp4MPK4YWu0H0EUWONpVwe\nTWtbRhQUCOe4PVSC/Vv1pstvMD/D+E/0L4GQNHxr+xyFxuvILty5lvFTxoAVYpqD\nu8gNhk3NWefTrlSkhY4N+tPP6o7E4t3y40nOA/d9qaqiid+lYcIDB0cJTpZvgeeQ\nijohxY3PHruU4vVZa37ITQnco9az6lsy18vbU0bOyK2fEZ2R9XVO8fH11jiV8oGH\n-----END RSA PRIVATE KEY-----\n",
|
||||||
|
"public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK\n5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa\nvkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0\nFK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC\nVTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M\nr3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s\nYwIDAQAB\n-----END PUBLIC KEY-----\n"
|
||||||
|
}
|
16
providers/dns/hyperone/internal/fixtures/zones.json
Normal file
16
providers/dns/hyperone/internal/fixtures/zones.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "zoneA",
|
||||||
|
"name": "example.org",
|
||||||
|
"dnsName": "example.org",
|
||||||
|
"fqdn": "example.org.",
|
||||||
|
"uri": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zoneB",
|
||||||
|
"name": "example.com",
|
||||||
|
"dnsName": "example.com",
|
||||||
|
"fqdn": "example.com.",
|
||||||
|
"uri": ""
|
||||||
|
}
|
||||||
|
]
|
23
providers/dns/hyperone/internal/models.go
Normal file
23
providers/dns/hyperone/internal/models.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
type Recordset struct {
|
||||||
|
RecordType string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TTL int `json:"ttl,omitempty"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Record *Record `json:"record,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Zone struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DNSName string `json:"dnsName"`
|
||||||
|
FQDN string `json:"fqdn"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
}
|
70
providers/dns/hyperone/internal/passport.go
Normal file
70
providers/dns/hyperone/internal/passport.go
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Passport struct {
|
||||||
|
SubjectID string `json:"subject_id"`
|
||||||
|
CertificateID string `json:"certificate_id"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadPassportFile(location string) (*Passport, error) {
|
||||||
|
file, err := os.Open(location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open passport file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
var passport Passport
|
||||||
|
err = json.NewDecoder(file).Decode(&passport)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse passport file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = passport.validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("passport file validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &passport, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (passport *Passport) validate() error {
|
||||||
|
if passport.Issuer == "" {
|
||||||
|
return errors.New("issuer is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if passport.CertificateID == "" {
|
||||||
|
return errors.New("certificate ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if passport.PrivateKey == "" {
|
||||||
|
return errors.New("private key is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if passport.SubjectID == "" {
|
||||||
|
return errors.New("subject is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (passport *Passport) ExtractProjectID() (string, error) {
|
||||||
|
re := regexp.MustCompile("iam/project/([a-zA-Z0-9]+)")
|
||||||
|
|
||||||
|
parts := re.FindStringSubmatch(passport.SubjectID)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("failed to extract project ID from subject ID: %s", passport.SubjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[1], nil
|
||||||
|
}
|
83
providers/dns/hyperone/internal/passport_test.go
Normal file
83
providers/dns/hyperone/internal/passport_test.go
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadPassportFile(t *testing.T) {
|
||||||
|
passport, err := LoadPassportFile("fixtures/validPassport.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := &Passport{
|
||||||
|
SubjectID: "/iam/project/projectId/sa/serviceAccountId",
|
||||||
|
CertificateID: "certificateID",
|
||||||
|
Issuer: "https://api.hyperone.com/v2/iam/project/projectId/sa/serviceAccountId",
|
||||||
|
PrivateKey: `-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
lrMAsSjjkKiRxGdgR8p5kZJj0AFgdWYa3OT2snIXnN5+/p7j13PSkseUcrAFyokc
|
||||||
|
V9pgeDfitAhb9lpdjxjjuxRcuQjBfmNVLPF9MFyNOvhrprGNukUh/12oSKO9dFEt
|
||||||
|
s39F/2h6Ld5IQrGt3gZaBB1aGO+tw3ill1VBy2zGPIDeuSz6DS3GG/oQ2gLSSMP4
|
||||||
|
OVfQ32Oajo496iHRkdIh/7Hho7BNzMYr1GxrYTcE9/Znr6xgeSdNT37CCeCH8cmP
|
||||||
|
aEAUgSMTeIMVSpILwkKeNvBURic1EWaqXRgPRIWK0vNyOCs/+jNoFISnV4pu1ROF
|
||||||
|
92vayHDNSVw9wHcdSQ75XSE4Msawqv5U1iI7e2lD64uo1qhmJdrPcXDJQCiDbh+F
|
||||||
|
hQhF+wAoLRvMNwwhg+LttL8vXqMDQl3olsWSvWPs6b/MZpB0qwd1bklzA6P+PeAU
|
||||||
|
sfOvTqi9edIOfKqvXqTXEhBP8qC7ZtOKLGnryZb7W04SSVrNtuJUFRcLiqu+w/F/
|
||||||
|
MSxGSGalYpzIZ1B5HLQqISgWMXdbt39uMeeooeZjkuI3VIllFjtybecjPR9ZYQPt
|
||||||
|
FFEP1XqNXjLFmGh84TXtvGLWretWM1OZmN8UKKUeATqrr7zuh5AYGAIbXd8BvweL
|
||||||
|
Pigl9ei0hTculPqohvkoc5x1srPBvzHrirGlxOYjW3fc4kDgZpy+6ik5k5g7JWQD
|
||||||
|
lbXCRz3HGazgUPeiwUr06a52vhgT7QuNIUZqdHb4IfCYs2pQTLHzQjAqvVk1mm2D
|
||||||
|
kh4myIcTtf69BFcu/Wuptm3NaKd1nwk1squR6psvcTXOWII81pstnxNYkrokx4r2
|
||||||
|
7YVllNruOD+cMDNZbIG2CwT6V9ukIS8tl9EJp8eyb0a1uAEc22BNOjYHPF50beWF
|
||||||
|
ukf3uc0SA+G3zhmXCM5sMf5OxVjKr5jgcir7kySY5KbmG71omYhczgr4H0qgxYo9
|
||||||
|
Zyj2wMKrTHLfFOpd4OOEun9Gi3srqlKZep7Hj7gNyUwZu1qiBvElmBVmp0HJxT0N
|
||||||
|
mktuaVbaFgBsTS0/us1EqWvCA4REh1Ut/NoA9oG3JFt0lGDstTw1j+orDmIHOmSu
|
||||||
|
7FKYzr0uCz14AkLMSOixdPD1F0YyED1NMVnRVXw77HiAFGmb0CDi2KEg70pEKpn3
|
||||||
|
ksa8oe0MQi6oEwlMsAxVTXOB1wblTBuSBeaECzTzWE+/DHF+QQfQi8kAjjSdmmMJ
|
||||||
|
yN+shdBWHYRGYnxRkTatONhcDBIY7sZV7wolYHz/rf7dpYUZf37vdQnYV8FpO1um
|
||||||
|
Ya0GslyRJ5GqMBfDS1cQKne+FvVHxEE2YqEGBcOYhx/JI2soE8aA8W4XffN+DoEy
|
||||||
|
ZkinJ/+BOwJ/zUI9GZtwB4JXqbNEE+j7r7/fJO9KxfPp4MPK4YWu0H0EUWONpVwe
|
||||||
|
TWtbRhQUCOe4PVSC/Vv1pstvMD/D+E/0L4GQNHxr+xyFxuvILty5lvFTxoAVYpqD
|
||||||
|
u8gNhk3NWefTrlSkhY4N+tPP6o7E4t3y40nOA/d9qaqiid+lYcIDB0cJTpZvgeeQ
|
||||||
|
ijohxY3PHruU4vVZa37ITQnco9az6lsy18vbU0bOyK2fEZ2R9XVO8fH11jiV8oGH
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
`,
|
||||||
|
PublicKey: `-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK
|
||||||
|
5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa
|
||||||
|
vkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0
|
||||||
|
FK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC
|
||||||
|
VTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M
|
||||||
|
r3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s
|
||||||
|
YwIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expected, passport)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPassportFile_invalid(t *testing.T) {
|
||||||
|
passport, err := LoadPassportFile("fixtures/invalidPassport.json")
|
||||||
|
require.EqualError(t, err, "passport file validation failed: private key is missing")
|
||||||
|
|
||||||
|
assert.Nil(t, passport)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractProjectID(t *testing.T) {
|
||||||
|
passport := Passport{SubjectID: "/iam/project/ddd/sa/5ef759c0ab0acab07xxxxxxx"}
|
||||||
|
extractedID, err := passport.ExtractProjectID()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "ddd", extractedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractProjectID_invalid(t *testing.T) {
|
||||||
|
passport := Passport{SubjectID: "ddddddd"}
|
||||||
|
|
||||||
|
extractedID, err := passport.ExtractProjectID()
|
||||||
|
require.EqualError(t, err, "failed to extract project ID from subject ID: ddddddd")
|
||||||
|
|
||||||
|
assert.Empty(t, extractedID)
|
||||||
|
}
|
85
providers/dns/hyperone/internal/token.go
Normal file
85
providers/dns/hyperone/internal/token.go
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/square/go-jose.v2"
|
||||||
|
"gopkg.in/square/go-jose.v2/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenSigner struct {
|
||||||
|
PrivateKey string
|
||||||
|
KeyID string
|
||||||
|
Audience string
|
||||||
|
Issuer string
|
||||||
|
Subject string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (input *TokenSigner) GetJWT() (string, error) {
|
||||||
|
signer, err := getRSASigner(input.PrivateKey, input.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
issuedAt := time.Now()
|
||||||
|
expiresAt := issuedAt.Add(5 * time.Minute)
|
||||||
|
|
||||||
|
payload := Payload{IssuedAt: issuedAt.Unix(), Expiry: expiresAt.Unix(), Audience: input.Audience, Issuer: input.Issuer, Subject: input.Subject}
|
||||||
|
token, err := payload.buildToken(&signer)
|
||||||
|
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRSASigner(privateKey, keyID string) (jose.Signer, error) {
|
||||||
|
parsedKey, err := parseRSAKey(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key := jose.SigningKey{Algorithm: jose.RS256, Key: parsedKey}
|
||||||
|
|
||||||
|
signerOpts := jose.SignerOptions{}
|
||||||
|
signerOpts.WithType("JWT")
|
||||||
|
signerOpts.WithHeader("kid", keyID)
|
||||||
|
|
||||||
|
rsaSigner, err := jose.NewSigner(key, &signerOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create JWS RSA256 signer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rsaSigner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Payload struct {
|
||||||
|
IssuedAt int64 `json:"iat"`
|
||||||
|
Expiry int64 `json:"exp"`
|
||||||
|
Audience string `json:"aud"`
|
||||||
|
Issuer string `json:"iss"`
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *Payload) buildToken(signer *jose.Signer) (string, error) {
|
||||||
|
builder := jwt.Signed(*signer).Claims(payload)
|
||||||
|
|
||||||
|
token, err := builder.CompactSerialize()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to build JWT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRSAKey(pemString string) (*rsa.PrivateKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(pemString))
|
||||||
|
|
||||||
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
65
providers/dns/hyperone/internal/token_test.go
Normal file
65
providers/dns/hyperone/internal/token_test.go
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const privateKey = `-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIICWgIBAAKBgGFfgMY+DuO8l0RYrMLhcl6U/NigNIiOVhoo/xnYyoQALpWxBaBR
|
||||||
|
+iVJiBUYunQjKA33yAiY0AasCfSn1JB6asayQvGGn73xztLjkeCVLT+9e4nJ0A/o
|
||||||
|
dK8SOKBg9FFe70KJrWjJd626el0aVDJjtCE+QxJExA0UZbQp+XIyveQXAgMBAAEC
|
||||||
|
gYBHcL1XNWLRPaWx9GlUVfoGYMMd4HSKl/ueF+QKP59dt5B2LTnWhS7FOqzH5auu
|
||||||
|
17hkfx3ZCNzfeEuZn6T6F4bMtsQ6A5iT/DeRlG8tOPiCVZ/L0j6IFM78iIUT8XyA
|
||||||
|
miwnSy1xGSBA67yUmsLxFg2DtGCjamAkY0C5pccadaB7oQJBAKsIPpMXMni+Oo1I
|
||||||
|
kVxRyoIZgDxsMJiihG2YLVqo8rPtdErl+Lyg3ziVyg9KR6lFMaTBkYBTLoCPof3E
|
||||||
|
AB/jyucCQQCRv1cVnYNx+bfnXsBlcsCFDV2HkEuLTpxj7hauD4P3GcyLidSsUkn1
|
||||||
|
PiPunZqKpsQaIoxc/BzTOCcP19ifgqdRAkBJ8Cp9FE4xfKt7YJ/WtVVCoRubA3qO
|
||||||
|
wdNWPa99vgQOXN0lc/3wLevSXo8XxRjtyIgJndT1EQDNe0qglhcnsiaJAkBziAcR
|
||||||
|
/VAq0tZys2szf6kYTyXqxfj8Lo5NsHeN9oKXJ346xkEtb/VsT5vQFGJishsU1HoL
|
||||||
|
Y1W+IO7l4iW3G6xhAkACNwtqxSRRbVsNCUMENpKmYhsyN8QXJ8V+o2A9s+pl21Kz
|
||||||
|
HIIm179mUYCgO6iAHmkqxlFHFwprUBKdPrmP8qF9
|
||||||
|
-----END RSA PRIVATE KEY-----`
|
||||||
|
|
||||||
|
type Header struct {
|
||||||
|
Algorithm string `json:"alg"`
|
||||||
|
Type string `json:"typ"`
|
||||||
|
KeyID string `json:"kid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayload_buildToken(t *testing.T) {
|
||||||
|
signer, err := getRSASigner(privateKey, "sampleKeyId")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
payload := Payload{IssuedAt: 1234, Expiry: 4321, Audience: "api.url", Issuer: "issuer", Subject: "subject"}
|
||||||
|
|
||||||
|
token, err := payload.buildToken(&signer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
segments := strings.Split(token, ".")
|
||||||
|
require.Len(t, segments, 3)
|
||||||
|
|
||||||
|
headerString, err := base64.RawStdEncoding.DecodeString(segments[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var headerStruct Header
|
||||||
|
err = json.Unmarshal(headerString, &headerStruct)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
payloadString, err := base64.RawStdEncoding.DecodeString(segments[1])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payloadStruct Payload
|
||||||
|
err = json.Unmarshal(payloadString, &payloadStruct)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedHeader := Header{Algorithm: "RS256", Type: "JWT", KeyID: "sampleKeyId"}
|
||||||
|
|
||||||
|
assert.Equal(t, expectedHeader, headerStruct)
|
||||||
|
assert.Equal(t, payload, payloadStruct)
|
||||||
|
}
|
Loading…
Reference in a new issue