Add DNS provider for HyperOne (#1233)

This commit is contained in:
Jakub Surdej 2020-08-24 23:50:52 +02:00 committed by GitHub
parent d14bef50f3
commit 7557dbc98c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1440 additions and 10 deletions

View file

@ -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/) |
| [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/) |
| [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/) |
| [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/) |
| [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/) | [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/) | [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/) | [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/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) |
| [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | |
| [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/) |
| [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/) |
| [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/) | [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/) | [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/) | [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/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) |
| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) |
<!-- END DNS PROVIDERS LIST -->

View file

@ -53,6 +53,7 @@ func allDNSCodes() string {
"hetzner",
"hostingde",
"httpreq",
"hyperone",
"iij",
"inwx",
"joker",
@ -928,6 +929,24 @@ func displayDNSHelp(name string) error {
ew.writeln()
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":
// generated from: providers/dns/iij/iij.toml
ew.writeln(`Configuration for Internet Initiative Japan.`)

View 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. -->

View file

@ -44,6 +44,7 @@ import (
"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/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/inwx"
"github.com/go-acme/lego/v3/providers/dns/joker"
@ -166,6 +167,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return hostingde.NewDNSProvider()
case "httpreq":
return httpreq.NewDNSProvider()
case "hyperone":
return hyperone.NewDNSProvider()
case "iij":
return iij.NewDNSProvider()
case "inwx":

View 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
}

View 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"

View 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)
}

View 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)
}

View 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
}
}

View file

@ -0,0 +1,5 @@
{
"id": "123321qwerqwewqerq",
"content": "value",
"enabled": true
}

View file

@ -0,0 +1,6 @@
{
"id": "1234567890qwertyuiop",
"name": "test.example.com.",
"type": "TXT",
"ttl": 3600
}

View file

@ -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"
}

View file

@ -0,0 +1,7 @@
[
{
"id": "135128352183572dd",
"content": "pns.hyperone.com. hostmaster.hyperone.com. 1 15 180 1209600 1800",
"enabled": true
}
]

View 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
}
]

View file

@ -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"
}

View 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": ""
}
]

View 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"`
}

View 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
}

View 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)
}

View 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
}

View 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)
}