diff --git a/README.md b/README.md index c166b46..0bca837 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A [Rancher](http://rancher.com/rancher/) service that obtains free SSL/TLS certi * `DNSimple` * `Dyn` * `Gandi` + * `Linode` * `NS1` * `Ovh` * `Vultr` @@ -110,6 +111,8 @@ Then make sure that HTTP requests to `domain.com/.well-known/acme-challenge` are `make build && make image` +This project uses [trash](https://github.com/rancher/trash) to manage dependencies. + ### Contributions PR's welcome! diff --git a/context.go b/context.go index 7b26531..8b1076a 100644 --- a/context.go +++ b/context.go @@ -126,6 +126,7 @@ func (c *Context) InitContext() { OvhConsumerKey: getEnvOption("OVH_CONSUMER_KEY", false), GandiApiKey: getEnvOption("GANDI_API_KEY", false), NS1ApiKey: getEnvOption("NS1_API_KEY", false), + LinodeApiKey: getEnvOption("LINODE_API_KEY", false), } c.Acme, err = letsencrypt.NewClient(emailParam, keyType, apiVersion, dnsResolvers, providerOpts) diff --git a/letsencrypt/providers.go b/letsencrypt/providers.go index bd1bc89..2881772 100644 --- a/letsencrypt/providers.go +++ b/letsencrypt/providers.go @@ -12,6 +12,7 @@ import ( "github.com/xenolf/lego/providers/dns/dnsimple" "github.com/xenolf/lego/providers/dns/dyn" "github.com/xenolf/lego/providers/dns/gandi" + "github.com/xenolf/lego/providers/dns/linode" "github.com/xenolf/lego/providers/dns/ns1" "github.com/xenolf/lego/providers/dns/ovh" "github.com/xenolf/lego/providers/dns/route53" @@ -58,6 +59,9 @@ type ProviderOpts struct { // Gandi credentials GandiApiKey string + // Linode credentials + LinodeApiKey string + // NS1 credentials NS1ApiKey string @@ -80,6 +84,7 @@ const ( DNSIMPLE = Provider("DNSimple") DYN = Provider("Dyn") GANDI = Provider("Gandi") + LINODE = Provider("Linode") NS1 = Provider("NS1") OVH = Provider("Ovh") ROUTE53 = Provider("Route53") @@ -100,6 +105,7 @@ var providerFactory = map[Provider]ProviderFactory{ DNSIMPLE: ProviderFactory{makeDNSimpleProvider, lego.DNS01}, DYN: ProviderFactory{makeDynProvider, lego.DNS01}, GANDI: ProviderFactory{makeGandiProvider, lego.DNS01}, + LINODE: ProviderFactory{makeLinodeProvider, lego.DNS01}, NS1: ProviderFactory{makeNS1Provider, lego.DNS01}, OVH: ProviderFactory{makeOvhProvider, lego.DNS01}, ROUTE53: ProviderFactory{makeRoute53Provider, lego.DNS01}, @@ -275,6 +281,19 @@ func makeGandiProvider(opts ProviderOpts) (lego.ChallengeProvider, error) { return provider, nil } +// returns a preconfigured Linode lego.ChallengeProvider +func makeLinodeProvider(opts ProviderOpts) (lego.ChallengeProvider, error) { + if len(opts.LinodeApiKey) == 0 { + return nil, fmt.Errorf("Linode API key is not set") + } + + provider, err := linode.NewDNSProviderCredentials(opts.LinodeApiKey) + if err != nil { + return nil, err + } + return provider, nil +} + // returns a preconfigured HTTP lego.ChallengeProvider func makeHTTPProvider(opts ProviderOpts) (lego.ChallengeProvider, error) { provider := lego.NewHTTPProviderServer("", "") diff --git a/vendor.conf b/vendor.conf index 73b1160..40ca4f5 100644 --- a/vendor.conf +++ b/vendor.conf @@ -18,6 +18,7 @@ github.com/ovh/go-ovh d220717 github.com/pkg/errors c605e28 github.com/rancher/go-rancher/v2 939fd85 github.com/Sirupsen/logrus v0.11.5 +github.com/timewasted/linode/dns 37e8452 github.com/xenolf/lego aaa8e70 golang.org/x/crypto ab89591 golang.org/x/net 84f0e6f diff --git a/vendor/github.com/timewasted/linode/dns/dns.go b/vendor/github.com/timewasted/linode/dns/dns.go new file mode 100644 index 0000000..a849158 --- /dev/null +++ b/vendor/github.com/timewasted/linode/dns/dns.go @@ -0,0 +1,69 @@ +package dns + +import ( + "strconv" + + "github.com/timewasted/linode" +) + +type ( + // DNS represents the interface to the DNS portion of Linode's API. + DNS struct { + linode *linode.Linode + } +) + +// New returns a pointer to a new DNS object. +func New(apiKey string) *DNS { + return &DNS{ + linode: linode.New(apiKey), + } +} + +// FromLinode returns a pointer to a new DNS object, using the provided Linode +// instance as backing. +func FromLinode(l *linode.Linode) *DNS { + return &DNS{ + linode: l, + } +} + +// ToLinode returns a pointer to the internal Linode object. +func (d *DNS) ToLinode() *linode.Linode { + return d.linode +} + +// GetDomains executes the "domain.list" API call. When domainID is nil, this +// will return a list of domains. Otherwise, it will return only the domain +// specified by domainID. +func (d *DNS) GetDomains(domainId interface{}) ([]*Domain, error) { + params := linode.Parameters{} + if domainId != nil { + id, ok := domainId.(int) + if ok { + params.Set("DomainID", strconv.Itoa(id)) + } + } + + var list []*Domain + _, err := d.linode.Request("domain.list", params, &list) + if err != nil { + return nil, err + } + return list, nil +} + +// GetDomainResources executes the "domain.resource.list" API call. This will +// return a list of domain resources associated with the specified domainID. +func (d *DNS) GetDomainResources(domainID int) ([]*Resource, error) { + params := linode.Parameters{ + "DomainID": strconv.Itoa(domainID), + } + + var list []*Resource + _, err := d.linode.Request("domain.resource.list", params, &list) + if err != nil { + return nil, err + } + return list, nil +} diff --git a/vendor/github.com/timewasted/linode/dns/domain.go b/vendor/github.com/timewasted/linode/dns/domain.go new file mode 100644 index 0000000..237af9f --- /dev/null +++ b/vendor/github.com/timewasted/linode/dns/domain.go @@ -0,0 +1,63 @@ +package dns + +import ( + "errors" + "strconv" + "strings" + + "github.com/timewasted/linode" +) + +type ( + // Domain represents a domain. + Domain struct { + AXFR_IPs string `json:"AXFR_IPS"` + Description string `json:"DESCRIPTION"` + Domain string `json:"DOMAIN"` + DomainID int `json:"DOMAINID"` + Expire_Sec int `json:"EXPIRE_SEC"` + LPM_DisplayGroup string `json:"LPM_DISPLAYGROUP"` + Master_IPs string `json:"MASTER_IPS"` + Refresh_Sec int `json:"REFRESH_SEC"` + Retry_Sec int `json:"RETRY_SEC"` + SOA_Email string `json:"SOA_EMAIL"` + Status int `json:"STATUS"` + TTL_Sec int `json:"TTL_SEC"` + Type string `json:"TYPE"` + } + // DomainResponse represents the response to a create, update, or + // delete domain API call. + DomainResponse struct { + DomainID int `json:"DomainID"` + } +) + +// DeleteDomain executes the "domain.delete" API call. This will delete the +// domain specified by domainID. +func (d *DNS) DeleteDomain(domainID int) (*DomainResponse, error) { + params := linode.Parameters{ + "DomainID": strconv.Itoa(domainID), + } + var response *DomainResponse + _, err := d.linode.Request("domain.delete", params, &response) + if err != nil { + return nil, err + } + return response, nil +} + +// GetDomain returns the specified domain. This search is not case-sensitive. +func (d *DNS) GetDomain(domain string) (*Domain, error) { + list, err := d.GetDomains(nil) + if err != nil { + return nil, err + } + + for _, d := range list { + if strings.EqualFold(d.Domain, domain) { + return d, nil + } + } + + return nil, linode.NewError(errors.New("dns: requested domain not found")) +} diff --git a/vendor/github.com/timewasted/linode/dns/resource.go b/vendor/github.com/timewasted/linode/dns/resource.go new file mode 100644 index 0000000..9ceb4ca --- /dev/null +++ b/vendor/github.com/timewasted/linode/dns/resource.go @@ -0,0 +1,228 @@ +package dns + +import ( + "errors" + "strconv" + "strings" + + "github.com/timewasted/linode" +) + +type ( + // Resource represents a domain resource. + Resource struct { + DomainID int `json:"DOMAINID"` + Name string `json:"NAME"` + Port interface{} `json:"PORT"` + Priority interface{} `json:"PRIORITY"` + Protocol string `json:"PROTOCOL"` + ResourceID int `json:"RESOURCEID"` + Target string `json:"TARGET"` + TTL_Sec int `json:"TTL_SEC"` + Type string `json:"TYPE"` + Weight interface{} `json:"WEIGHT"` + } + // ResourceResponse represents the response to a create, update, or + // delete resource API call. + ResourceResponse struct { + ResourceID int `json:"ResourceID"` + } +) + +// CreateDomainResourceA executes the "domain.resource.create" API call. This +// will create a new "A" resource using the specified parameters. +func (d *DNS) CreateDomainResourceA(domainID int, name, target string, ttlSeconds int) (*ResourceResponse, error) { + return d.createDomainResource(linode.Parameters{ + "Type": "A", + "DomainID": strconv.Itoa(domainID), + "Name": name, + "Target": target, + "TTL_Sec": strconv.Itoa(ttlSeconds), + }) +} + +// CreateDomainResourceAAAA executes the "domain.resource.create" API call. +// This will create a new "AAAA" resource using the specified parameters. +func (d *DNS) CreateDomainResourceAAAA(domainID int, name, target string, ttlSeconds int) (*ResourceResponse, error) { + return d.createDomainResource(linode.Parameters{ + "Type": "AAAA", + "DomainID": strconv.Itoa(domainID), + "Name": name, + "Target": target, + "TTL_Sec": strconv.Itoa(ttlSeconds), + }) +} + +// CreateDomainResourceCNAME executes the "domain.resource.create" API call. +// This will create a new "CNAME" resource using the specified parameters. +func (d *DNS) CreateDomainResourceCNAME(domainID int, name, target string, ttlSeconds int) (*ResourceResponse, error) { + return d.createDomainResource(linode.Parameters{ + "Type": "CNAME", + "DomainID": strconv.Itoa(domainID), + "Name": name, + "Target": target, + "TTL_Sec": strconv.Itoa(ttlSeconds), + }) +} + +// CreateDomainResourceMX executes the "domain.resource.create" API call. This +// will create a new "MX" resource using the specified parameters. +func (d *DNS) CreateDomainResourceMX(domainID int, name, target string, priority, ttlSeconds int) (*ResourceResponse, error) { + return d.createDomainResource(linode.Parameters{ + "Type": "MX", + "DomainID": strconv.Itoa(domainID), + "Name": name, + "Target": target, + "Priority": strconv.Itoa(priority), + "TTL_Sec": strconv.Itoa(ttlSeconds), + }) +} + +// CreateDomainResourceNS executes the "domain.resource.create" API call. This +// will create a new "NS" resource using the specified parameters. +func (d *DNS) CreateDomainResourceNS(domainID int, name, target string, ttlSeconds int) (*ResourceResponse, error) { + return d.createDomainResource(linode.Parameters{ + "Type": "NS", + "DomainID": strconv.Itoa(domainID), + "Name": name, + "Target": target, + "TTL_Sec": strconv.Itoa(ttlSeconds), + }) +} + +// CreateDomainResourceSRV executes the "domain.resource.create" API call. This +// will create a new "SRV" resource using the specified parameters. +func (d *DNS) CreateDomainResourceSRV(domainID int, name, target, protocol string, priority, ttlSeconds int) (*ResourceResponse, error) { + // FIXME: This probably also needs weight and port. Weight has a valid + // range of 0-255, while port is 0-65535. + return d.createDomainResource(linode.Parameters{ + "Type": "SRV", + "DomainID": strconv.Itoa(domainID), + "Name": name, + "Target": target, + "Protocol": protocol, + "Priority": strconv.Itoa(priority), + "TTL_Sec": strconv.Itoa(ttlSeconds), + }) +} + +// CreateDomainResourceTXT executes the "domain.resource.create" API call. This +// will create a new "TXT" resource using the specified parameters. +func (d *DNS) CreateDomainResourceTXT(domainID int, name, target string, ttlSeconds int) (*ResourceResponse, error) { + return d.createDomainResource(linode.Parameters{ + "Type": "TXT", + "DomainID": strconv.Itoa(domainID), + "Name": name, + "Target": target, + "TTL_Sec": strconv.Itoa(ttlSeconds), + }) +} + +// CreateDomainResource executes the "domain.resource.create" API call. This +// will create a new resource using the values specified in the resource. +func (d *DNS) CreateDomainResource(r *Resource) (*ResourceResponse, error) { + // Ensure that the resource has a name. + if len(r.Name) == 0 { + return nil, linode.NewError(errors.New("dns: creating a resource requires Name be specified")) + } + + // Initialize parameters that are shared across resource types. + params := linode.Parameters{ + "DomainID": strconv.Itoa(r.DomainID), + "Name": r.Name, + "TTL_Sec": strconv.Itoa(r.TTL_Sec), + } + + // Ensure that the resource has a valid, supported type. + r.Type = strings.ToUpper(r.Type) + switch r.Type { + case "A": + case "AAAA": + case "CNAME": + case "MX": + case "NS": + case "TXT": + // No further processing required for these types. + break + case "SRV": + // Ensure that SRV has a protocol. + if len(r.Protocol) == 0 { + return nil, linode.NewError(errors.New("dns: creating a SRV resource requires Priority be specified")) + } + params.Set("Protocol", r.Protocol) + break + default: + // Unsupported type. + return nil, linode.NewError(errors.New("dns: can not create resource of unsupported type: " + r.Type)) + } + params.Set("Type", r.Type) + + // Ensure that the resource has a valid target. + if len(r.Target) == 0 { + return nil, linode.NewError(errors.New("dns: creating a resource requires Target to be specified")) + } + params.Set("Target", r.Target) + + if r.Name == "MX" || r.Name == "SRV" { + // If priority is defined, ensure that it's valid. + if r.Priority != nil { + priority, ok := r.Priority.(int) + if !ok { + return nil, linode.NewError(errors.New("dns: priority must be specified as an int")) + } + if priority < 0 || priority > 255 { + return nil, linode.NewError(errors.New("dns: priority must be within the range of 0-255")) + } + r.Priority = priority + params.Set("Priority", strconv.Itoa(priority)) + } + } + + // Create the resource. + return d.createDomainResource(params) +} + +// createDomainResource executes the "domain.resource.create" API call. This +// will create a resource using the specified parameters. +func (d *DNS) createDomainResource(params linode.Parameters) (*ResourceResponse, error) { + var response *ResourceResponse + _, err := d.linode.Request("domain.resource.create", params, &response) + if err != nil { + return nil, err + } + return response, nil +} + +// DeleteDomainResource executes the "domain.resource.delete" API call. This +// will delete the resource specified by resourceID under the domain specified +// by domainID. +func (d *DNS) DeleteDomainResource(domainID, resourceID int) (*ResourceResponse, error) { + params := linode.Parameters{ + "DomainID": strconv.Itoa(domainID), + "ResourceID": strconv.Itoa(resourceID), + } + var response *ResourceResponse + _, err := d.linode.Request("domain.resource.delete", params, &response) + if err != nil { + return nil, err + } + return response, nil +} + +// GetResourceByType returns a list of domain resources that match the specified +// type. This search is not case-sensitive. +func (d *DNS) GetResourcesByType(domainID int, res_type string) ([]*Resource, error) { + resources, err := d.GetDomainResources(domainID) + if err != nil { + return nil, err + } + + list := []*Resource{} + for _, r := range resources { + if strings.EqualFold(r.Type, res_type) { + list = append(list, r) + } + } + + return list, nil +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/linode/linode.go b/vendor/github.com/xenolf/lego/providers/dns/linode/linode.go new file mode 100644 index 0000000..a91d2b4 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/linode/linode.go @@ -0,0 +1,131 @@ +// Package linode implements a DNS provider for solving the DNS-01 challenge +// using Linode DNS. +package linode + +import ( + "errors" + "os" + "strings" + "time" + + "github.com/timewasted/linode/dns" + "github.com/xenolf/lego/acme" +) + +const ( + dnsMinTTLSecs = 300 + dnsUpdateFreqMins = 15 + dnsUpdateFudgeSecs = 120 +) + +type hostedZoneInfo struct { + domainId int + resourceName string +} + +// DNSProvider implements the acme.ChallengeProvider interface. +type DNSProvider struct { + linode *dns.DNS +} + +// NewDNSProvider returns a DNSProvider instance configured for Linode. +// Credentials must be passed in the environment variable: LINODE_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + apiKey := os.Getenv("LINODE_API_KEY") + return NewDNSProviderCredentials(apiKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Linode. +func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { + if len(apiKey) == 0 { + return nil, errors.New("Linode credentials missing") + } + + return &DNSProvider{ + linode: dns.New(apiKey), + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS +// propagation. Adjusting here to cope with spikes in propagation times. +func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { + // Since Linode only updates their zone files every X minutes, we need + // to figure out how many minutes we have to wait until we hit the next + // interval of X. We then wait another couple of minutes, just to be + // safe. Hopefully at some point during all of this, the record will + // have propagated throughout Linode's network. + minsRemaining := dnsUpdateFreqMins - (time.Now().Minute() % dnsUpdateFreqMins) + + timeout = (time.Duration(minsRemaining) * time.Minute) + + (dnsMinTTLSecs * time.Second) + + (dnsUpdateFudgeSecs * time.Second) + interval = 15 * time.Second + return +} + +// Present creates a TXT record using the specified parameters. +func (p *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + zone, err := p.getHostedZoneInfo(fqdn) + if err != nil { + return err + } + + if _, err = p.linode.CreateDomainResourceTXT(zone.domainId, acme.UnFqdn(fqdn), value, 60); err != nil { + return err + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + zone, err := p.getHostedZoneInfo(fqdn) + if err != nil { + return err + } + + // Get all TXT records for the specified domain. + resources, err := p.linode.GetResourcesByType(zone.domainId, "TXT") + if err != nil { + return err + } + + // Remove the specified resource, if it exists. + for _, resource := range resources { + if resource.Name == zone.resourceName && resource.Target == value { + resp, err := p.linode.DeleteDomainResource(resource.DomainID, resource.ResourceID) + if err != nil { + return err + } + if resp.ResourceID != resource.ResourceID { + return errors.New("Error deleting resource: resource IDs do not match!") + } + break + } + } + + return nil +} + +func (p *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) { + // Lookup the zone that handles the specified FQDN. + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return nil, err + } + resourceName := strings.TrimSuffix(fqdn, "."+authZone) + + // Query the authority zone. + domain, err := p.linode.GetDomain(acme.UnFqdn(authZone)) + if err != nil { + return nil, err + } + + return &hostedZoneInfo{ + domainId: domain.DomainID, + resourceName: resourceName, + }, nil +}