Repository: go-acme/lego Branch: master Commit: 830768fe2764 Files: 2074 Total size: 5.0 MB Directory structure: gitextract_89wpnizv/ ├── .dockerignore ├── .gitattributes ├── .gitcookies.enc ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ ├── feature_request.yml │ │ └── new_dns_provider.yml │ ├── PULL_REQUEST_TEMPLATE/ │ │ └── mnp.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── documentation.yml │ ├── go-cross.yml │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── acme/ │ ├── api/ │ │ ├── account.go │ │ ├── account_test.go │ │ ├── api.go │ │ ├── authorization.go │ │ ├── certificate.go │ │ ├── certificate_test.go │ │ ├── challenge.go │ │ ├── identifier.go │ │ ├── identifier_test.go │ │ ├── internal/ │ │ │ ├── nonces/ │ │ │ │ ├── nonce_manager.go │ │ │ │ └── nonce_manager_test.go │ │ │ ├── secure/ │ │ │ │ ├── jws.go │ │ │ │ └── jws_test.go │ │ │ └── sender/ │ │ │ ├── sender.go │ │ │ ├── sender_test.go │ │ │ └── useragent.go │ │ ├── order.go │ │ ├── order_test.go │ │ ├── renewal.go │ │ ├── service.go │ │ └── service_test.go │ ├── commons.go │ └── errors.go ├── buildx.Dockerfile ├── certcrypto/ │ ├── crypto.go │ └── crypto_test.go ├── certificate/ │ ├── authorization.go │ ├── certificates.go │ ├── certificates_test.go │ ├── errors.go │ ├── errors_test.go │ ├── renewal.go │ └── renewal_test.go ├── challenge/ │ ├── challenges.go │ ├── dns01/ │ │ ├── cname.go │ │ ├── cname_test.go │ │ ├── dns_challenge.go │ │ ├── dns_challenge_manual.go │ │ ├── dns_challenge_test.go │ │ ├── domain.go │ │ ├── domain_test.go │ │ ├── fixtures/ │ │ │ └── resolv.conf.1 │ │ ├── fqdn.go │ │ ├── fqdn_test.go │ │ ├── mock_test.go │ │ ├── nameserver.go │ │ ├── nameserver_test.go │ │ ├── nameserver_unix.go │ │ ├── nameserver_windows.go │ │ ├── precheck.go │ │ └── precheck_test.go │ ├── http01/ │ │ ├── domain_matcher.go │ │ ├── domain_matcher_test.go │ │ ├── http_challenge.go │ │ ├── http_challenge_server.go │ │ └── http_challenge_test.go │ ├── provider.go │ ├── resolver/ │ │ ├── errors.go │ │ ├── errors_test.go │ │ ├── prober.go │ │ ├── prober_mock_test.go │ │ ├── prober_test.go │ │ ├── solver_manager.go │ │ └── solver_manager_test.go │ └── tlsalpn01/ │ ├── tls_alpn_challenge.go │ ├── tls_alpn_challenge_server.go │ └── tls_alpn_challenge_test.go ├── cmd/ │ ├── account.go │ ├── accounts_storage.go │ ├── certs_storage.go │ ├── certs_storage_test.go │ ├── cmd.go │ ├── cmd_before.go │ ├── cmd_dnshelp.go │ ├── cmd_list.go │ ├── cmd_renew.go │ ├── cmd_renew_test.go │ ├── cmd_revoke.go │ ├── cmd_run.go │ ├── flags.go │ ├── hook.go │ ├── hook_test.go │ ├── lego/ │ │ ├── main.go │ │ └── zz_gen_version.go │ ├── setup.go │ ├── setup_challenges.go │ ├── testdata/ │ │ ├── sleeping_beauty.sh │ │ └── sleepy.sh │ └── zz_gen_cmd_dnshelp.go ├── docs/ │ ├── .gitignore │ ├── Makefile │ ├── archetypes/ │ │ └── default.md │ ├── content/ │ │ ├── _index.md │ │ ├── dns/ │ │ │ ├── _index.md │ │ │ ├── zz_gen_acme-dns.md │ │ │ ├── zz_gen_active24.md │ │ │ ├── zz_gen_alidns.md │ │ │ ├── zz_gen_aliesa.md │ │ │ ├── zz_gen_allinkl.md │ │ │ ├── zz_gen_alwaysdata.md │ │ │ ├── zz_gen_anexia.md │ │ │ ├── zz_gen_artfiles.md │ │ │ ├── zz_gen_arvancloud.md │ │ │ ├── zz_gen_auroradns.md │ │ │ ├── zz_gen_autodns.md │ │ │ ├── zz_gen_axelname.md │ │ │ ├── zz_gen_azion.md │ │ │ ├── zz_gen_azure.md │ │ │ ├── zz_gen_azuredns.md │ │ │ ├── zz_gen_baiducloud.md │ │ │ ├── zz_gen_beget.md │ │ │ ├── zz_gen_binarylane.md │ │ │ ├── zz_gen_bindman.md │ │ │ ├── zz_gen_bluecat.md │ │ │ ├── zz_gen_bluecatv2.md │ │ │ ├── zz_gen_bookmyname.md │ │ │ ├── zz_gen_brandit.md │ │ │ ├── zz_gen_bunny.md │ │ │ ├── zz_gen_checkdomain.md │ │ │ ├── zz_gen_civo.md │ │ │ ├── zz_gen_clouddns.md │ │ │ ├── zz_gen_cloudflare.md │ │ │ ├── zz_gen_cloudns.md │ │ │ ├── zz_gen_cloudru.md │ │ │ ├── zz_gen_cloudxns.md │ │ │ ├── zz_gen_com35.md │ │ │ ├── zz_gen_conoha.md │ │ │ ├── zz_gen_conohav3.md │ │ │ ├── zz_gen_constellix.md │ │ │ ├── zz_gen_corenetworks.md │ │ │ ├── zz_gen_cpanel.md │ │ │ ├── zz_gen_czechia.md │ │ │ ├── zz_gen_ddnss.md │ │ │ ├── zz_gen_derak.md │ │ │ ├── zz_gen_desec.md │ │ │ ├── zz_gen_designate.md │ │ │ ├── zz_gen_digitalocean.md │ │ │ ├── zz_gen_directadmin.md │ │ │ ├── zz_gen_dnsexit.md │ │ │ ├── zz_gen_dnshomede.md │ │ │ ├── zz_gen_dnsimple.md │ │ │ ├── zz_gen_dnsmadeeasy.md │ │ │ ├── zz_gen_dnspod.md │ │ │ ├── zz_gen_dode.md │ │ │ ├── zz_gen_domeneshop.md │ │ │ ├── zz_gen_dreamhost.md │ │ │ ├── zz_gen_duckdns.md │ │ │ ├── zz_gen_dyn.md │ │ │ ├── zz_gen_dyndnsfree.md │ │ │ ├── zz_gen_dynu.md │ │ │ ├── zz_gen_easydns.md │ │ │ ├── zz_gen_edgecenter.md │ │ │ ├── zz_gen_edgedns.md │ │ │ ├── zz_gen_edgeone.md │ │ │ ├── zz_gen_efficientip.md │ │ │ ├── zz_gen_epik.md │ │ │ ├── zz_gen_eurodns.md │ │ │ ├── zz_gen_excedo.md │ │ │ ├── zz_gen_exec.md │ │ │ ├── zz_gen_exoscale.md │ │ │ ├── zz_gen_f5xc.md │ │ │ ├── zz_gen_freemyip.md │ │ │ ├── zz_gen_gandi.md │ │ │ ├── zz_gen_gandiv5.md │ │ │ ├── zz_gen_gcloud.md │ │ │ ├── zz_gen_gcore.md │ │ │ ├── zz_gen_gigahostno.md │ │ │ ├── zz_gen_glesys.md │ │ │ ├── zz_gen_godaddy.md │ │ │ ├── zz_gen_googledomains.md │ │ │ ├── zz_gen_gravity.md │ │ │ ├── zz_gen_hetzner.md │ │ │ ├── zz_gen_hostingde.md │ │ │ ├── zz_gen_hostinger.md │ │ │ ├── zz_gen_hostingnl.md │ │ │ ├── zz_gen_hosttech.md │ │ │ ├── zz_gen_httpnet.md │ │ │ ├── zz_gen_httpreq.md │ │ │ ├── zz_gen_huaweicloud.md │ │ │ ├── zz_gen_hurricane.md │ │ │ ├── zz_gen_hyperone.md │ │ │ ├── zz_gen_ibmcloud.md │ │ │ ├── zz_gen_iij.md │ │ │ ├── zz_gen_iijdpf.md │ │ │ ├── zz_gen_infoblox.md │ │ │ ├── zz_gen_infomaniak.md │ │ │ ├── zz_gen_internetbs.md │ │ │ ├── zz_gen_inwx.md │ │ │ ├── zz_gen_ionos.md │ │ │ ├── zz_gen_ionoscloud.md │ │ │ ├── zz_gen_ipv64.md │ │ │ ├── zz_gen_ispconfig.md │ │ │ ├── zz_gen_ispconfigddns.md │ │ │ ├── zz_gen_iwantmyname.md │ │ │ ├── zz_gen_jdcloud.md │ │ │ ├── zz_gen_joker.md │ │ │ ├── zz_gen_keyhelp.md │ │ │ ├── zz_gen_leaseweb.md │ │ │ ├── zz_gen_liara.md │ │ │ ├── zz_gen_lightsail.md │ │ │ ├── zz_gen_limacity.md │ │ │ ├── zz_gen_linode.md │ │ │ ├── zz_gen_liquidweb.md │ │ │ ├── zz_gen_loopia.md │ │ │ ├── zz_gen_luadns.md │ │ │ ├── zz_gen_mailinabox.md │ │ │ ├── zz_gen_manageengine.md │ │ │ ├── zz_gen_manual.md │ │ │ ├── zz_gen_metaname.md │ │ │ ├── zz_gen_metaregistrar.md │ │ │ ├── zz_gen_mijnhost.md │ │ │ ├── zz_gen_mittwald.md │ │ │ ├── zz_gen_myaddr.md │ │ │ ├── zz_gen_mydnsjp.md │ │ │ ├── zz_gen_mythicbeasts.md │ │ │ ├── zz_gen_namecheap.md │ │ │ ├── zz_gen_namedotcom.md │ │ │ ├── zz_gen_namesilo.md │ │ │ ├── zz_gen_namesurfer.md │ │ │ ├── zz_gen_nearlyfreespeech.md │ │ │ ├── zz_gen_neodigit.md │ │ │ ├── zz_gen_netcup.md │ │ │ ├── zz_gen_netlify.md │ │ │ ├── zz_gen_nicmanager.md │ │ │ ├── zz_gen_nicru.md │ │ │ ├── zz_gen_nifcloud.md │ │ │ ├── zz_gen_njalla.md │ │ │ ├── zz_gen_nodion.md │ │ │ ├── zz_gen_ns1.md │ │ │ ├── zz_gen_octenium.md │ │ │ ├── zz_gen_oraclecloud.md │ │ │ ├── zz_gen_otc.md │ │ │ ├── zz_gen_ovh.md │ │ │ ├── zz_gen_pdns.md │ │ │ ├── zz_gen_plesk.md │ │ │ ├── zz_gen_porkbun.md │ │ │ ├── zz_gen_rackspace.md │ │ │ ├── zz_gen_rainyun.md │ │ │ ├── zz_gen_rcodezero.md │ │ │ ├── zz_gen_regfish.md │ │ │ ├── zz_gen_regru.md │ │ │ ├── zz_gen_rfc2136.md │ │ │ ├── zz_gen_rimuhosting.md │ │ │ ├── zz_gen_route53.md │ │ │ ├── zz_gen_safedns.md │ │ │ ├── zz_gen_sakuracloud.md │ │ │ ├── zz_gen_scaleway.md │ │ │ ├── zz_gen_selectel.md │ │ │ ├── zz_gen_selectelv2.md │ │ │ ├── zz_gen_selfhostde.md │ │ │ ├── zz_gen_servercow.md │ │ │ ├── zz_gen_shellrent.md │ │ │ ├── zz_gen_simply.md │ │ │ ├── zz_gen_sonic.md │ │ │ ├── zz_gen_spaceship.md │ │ │ ├── zz_gen_stackpath.md │ │ │ ├── zz_gen_syse.md │ │ │ ├── zz_gen_technitium.md │ │ │ ├── zz_gen_tencentcloud.md │ │ │ ├── zz_gen_timewebcloud.md │ │ │ ├── zz_gen_todaynic.md │ │ │ ├── zz_gen_transip.md │ │ │ ├── zz_gen_ultradns.md │ │ │ ├── zz_gen_uniteddomains.md │ │ │ ├── zz_gen_variomedia.md │ │ │ ├── zz_gen_vegadns.md │ │ │ ├── zz_gen_vercel.md │ │ │ ├── zz_gen_versio.md │ │ │ ├── zz_gen_vinyldns.md │ │ │ ├── zz_gen_virtualname.md │ │ │ ├── zz_gen_vkcloud.md │ │ │ ├── zz_gen_volcengine.md │ │ │ ├── zz_gen_vscale.md │ │ │ ├── zz_gen_vultr.md │ │ │ ├── zz_gen_webnames.md │ │ │ ├── zz_gen_webnamesca.md │ │ │ ├── zz_gen_websupport.md │ │ │ ├── zz_gen_wedos.md │ │ │ ├── zz_gen_westcn.md │ │ │ ├── zz_gen_yandex.md │ │ │ ├── zz_gen_yandex360.md │ │ │ ├── zz_gen_yandexcloud.md │ │ │ ├── zz_gen_zoneedit.md │ │ │ ├── zz_gen_zoneee.md │ │ │ └── zz_gen_zonomi.md │ │ ├── installation/ │ │ │ └── _index.md │ │ └── usage/ │ │ ├── _index.md │ │ ├── cli/ │ │ │ ├── General-Instructions.md │ │ │ ├── Obtain-a-Certificate.md │ │ │ ├── Options.md │ │ │ ├── Renew-a-Certificate.md │ │ │ ├── _index.md │ │ │ └── examples.md │ │ └── library/ │ │ ├── Writing-a-Challenge-Solver.md │ │ └── _index.md │ ├── data/ │ │ └── zz_cli_help.toml │ ├── go.mod │ ├── go.sum │ ├── hugo.toml │ ├── layouts/ │ │ ├── partials/ │ │ │ └── logo.html │ │ └── shortcodes/ │ │ ├── clihelp.html │ │ └── tableofdnsproviders.html │ └── static/ │ ├── .nojekyll │ └── css/ │ └── theme-custom.css ├── e2e/ │ ├── challenges_test.go │ ├── dnschallenge/ │ │ └── dns_challenges_test.go │ ├── fixtures/ │ │ ├── certs/ │ │ │ ├── README.md │ │ │ ├── localhost/ │ │ │ │ ├── README.md │ │ │ │ ├── cert.pem │ │ │ │ └── key.pem │ │ │ ├── pebble.minica.key.pem │ │ │ └── pebble.minica.pem │ │ ├── pebble-config-dns.json │ │ ├── pebble-config.json │ │ └── update-dns.sh │ ├── loader/ │ │ └── loader.go │ └── readme.md ├── go.mod ├── go.sum ├── internal/ │ ├── clihelp/ │ │ └── generator.go │ ├── dns/ │ │ ├── descriptors/ │ │ │ └── descriptors.go │ │ ├── docs/ │ │ │ ├── generator.go │ │ │ └── templates/ │ │ │ ├── dns.go.tmpl │ │ │ ├── dns.md.tmpl │ │ │ └── readme.md.tmpl │ │ └── providers/ │ │ ├── dns_providers.go.tmpl │ │ └── generator.go │ └── releaser/ │ ├── generator.go │ ├── releaser.go │ └── templates/ │ ├── dns.go.tmpl │ ├── sender.go.tmpl │ └── version.go.tmpl ├── lego/ │ ├── client.go │ ├── client_config.go │ └── client_test.go ├── log/ │ └── logger.go ├── platform/ │ ├── config/ │ │ └── env/ │ │ ├── env.go │ │ └── env_test.go │ ├── tester/ │ │ ├── api.go │ │ ├── dnsmock/ │ │ │ ├── dnsmock.go │ │ │ ├── dnsmock_test.go │ │ │ ├── handlers.go │ │ │ └── handlers_test.go │ │ ├── env.go │ │ ├── env_test.go │ │ └── servermock/ │ │ ├── builder.go │ │ ├── handler_dump.go │ │ ├── handler_file.go │ │ ├── handler_json.go │ │ ├── handler_noop.go │ │ ├── handler_raw.go │ │ ├── link_form.go │ │ ├── link_headers.go │ │ ├── link_query.go │ │ ├── link_request_body.go │ │ └── link_request_body_json.go │ └── wait/ │ ├── wait.go │ └── wait_test.go ├── providers/ │ ├── dns/ │ │ ├── acmedns/ │ │ │ ├── acmedns.go │ │ │ ├── acmedns.toml │ │ │ ├── acmedns_test.go │ │ │ ├── internal/ │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ ├── fetch-request.json │ │ │ │ │ ├── fetch.json │ │ │ │ │ └── fetch_all.json │ │ │ │ ├── http_storage.go │ │ │ │ ├── http_storage_test.go │ │ │ │ └── readme.md │ │ │ └── mock_test.go │ │ ├── active24/ │ │ │ ├── active24.go │ │ │ ├── active24.toml │ │ │ └── active24_test.go │ │ ├── alidns/ │ │ │ ├── alidns.go │ │ │ ├── alidns.toml │ │ │ └── alidns_test.go │ │ ├── aliesa/ │ │ │ ├── aliesa.go │ │ │ ├── aliesa.toml │ │ │ └── aliesa_test.go │ │ ├── allinkl/ │ │ │ ├── allinkl.go │ │ │ ├── allinkl.toml │ │ │ ├── allinkl_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_dns_settings-request.xml │ │ │ │ ├── add_dns_settings.json │ │ │ │ ├── add_dns_settings.xml │ │ │ │ ├── auth-request.xml │ │ │ │ ├── auth.xml │ │ │ │ ├── auth_fault.xml │ │ │ │ ├── delete_dns_settings-request.xml │ │ │ │ ├── delete_dns_settings.json │ │ │ │ ├── delete_dns_settings.xml │ │ │ │ ├── flood_protection.xml │ │ │ │ ├── get_dns_settings-request.xml │ │ │ │ ├── get_dns_settings-zone_not_found.xml │ │ │ │ ├── get_dns_settings-zone_syntax_incorrect.xml │ │ │ │ ├── get_dns_settings.json │ │ │ │ └── get_dns_settings.xml │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ ├── types.go │ │ │ ├── types_api.go │ │ │ └── types_auth.go │ │ ├── alwaysdata/ │ │ │ ├── alwaysdata.go │ │ │ ├── alwaysdata.toml │ │ │ ├── alwaysdata_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── domains.json │ │ │ │ ├── record_add-request.json │ │ │ │ └── records.json │ │ │ └── types.go │ │ ├── anexia/ │ │ │ ├── anexia.go │ │ │ ├── anexia.toml │ │ │ ├── anexia_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_record-request.json │ │ │ │ ├── create_record.json │ │ │ │ ├── create_record_incomplete.json │ │ │ │ ├── error.json │ │ │ │ └── get_zone.json │ │ │ └── types.go │ │ ├── artfiles/ │ │ │ ├── artfiles.go │ │ │ ├── artfiles.toml │ │ │ ├── artfiles_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── domains.txt │ │ │ │ ├── get_dns.json │ │ │ │ ├── set_dns.json │ │ │ │ ├── txt_record-multiple.txt │ │ │ │ └── txt_record.txt │ │ │ ├── types.go │ │ │ └── types_test.go │ │ ├── arvancloud/ │ │ │ ├── arvancloud.go │ │ │ ├── arvancloud.toml │ │ │ ├── arvancloud_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_record-request.json │ │ │ │ ├── create_txt_record.json │ │ │ │ └── get_txt_record.json │ │ │ └── types.go │ │ ├── auroradns/ │ │ │ ├── auroradns.go │ │ │ ├── auroradns.toml │ │ │ └── auroradns_test.go │ │ ├── autodns/ │ │ │ ├── autodns.go │ │ │ ├── autodns.toml │ │ │ ├── autodns_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_record-request.json │ │ │ │ ├── add_record.json │ │ │ │ ├── error.json │ │ │ │ ├── remove_record-request.json │ │ │ │ └── remove_record.json │ │ │ └── types.go │ │ ├── axelname/ │ │ │ ├── axelname.go │ │ │ ├── axelname.toml │ │ │ ├── axelname_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── dns_add.json │ │ │ │ ├── dns_add_error.json │ │ │ │ ├── dns_delete.json │ │ │ │ ├── dns_delete_error.json │ │ │ │ ├── dns_list.json │ │ │ │ └── dns_list_error.json │ │ │ └── types.go │ │ ├── azion/ │ │ │ ├── azion.go │ │ │ ├── azion.toml │ │ │ ├── azion_test.go │ │ │ └── fixtures/ │ │ │ ├── zones.json │ │ │ └── zones_empty.json │ │ ├── azure/ │ │ │ ├── azure.go │ │ │ ├── azure.toml │ │ │ ├── azure_test.go │ │ │ ├── private.go │ │ │ └── public.go │ │ ├── azuredns/ │ │ │ ├── azuredns.go │ │ │ ├── azuredns.toml │ │ │ ├── azuredns_test.go │ │ │ ├── credentials.go │ │ │ ├── oidc.go │ │ │ ├── private.go │ │ │ ├── public.go │ │ │ ├── servicediscovery.go │ │ │ └── servicediscovery_test.go │ │ ├── baiducloud/ │ │ │ ├── baiducloud.go │ │ │ ├── baiducloud.toml │ │ │ └── baiducloud_test.go │ │ ├── beget/ │ │ │ ├── beget.go │ │ │ ├── beget.toml │ │ │ ├── beget_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── answer_error.json │ │ │ │ ├── changeRecords-doc.json │ │ │ │ ├── error.json │ │ │ │ ├── getData-doc.json │ │ │ │ ├── getData-real.json │ │ │ │ ├── getData.json │ │ │ │ └── getData_empty.json │ │ │ └── types.go │ │ ├── binarylane/ │ │ │ ├── binarylane.go │ │ │ ├── binarylane.toml │ │ │ ├── binarylane_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_record-request.json │ │ │ │ ├── create_record.json │ │ │ │ └── error.json │ │ │ └── types.go │ │ ├── bindman/ │ │ │ ├── bindman.go │ │ │ ├── bindman.toml │ │ │ ├── bindman_test.go │ │ │ └── fixtures/ │ │ │ ├── add_record-request.json │ │ │ └── error.json │ │ ├── bluecat/ │ │ │ ├── bluecat.go │ │ │ ├── bluecat.toml │ │ │ ├── bluecat_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ └── types.go │ │ ├── bluecatv2/ │ │ │ ├── bluecatv2.go │ │ │ ├── bluecatv2.toml │ │ │ ├── bluecatv2_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── deleteResourceRecord.json │ │ │ │ ├── error.json │ │ │ │ ├── getZoneDeployments.json │ │ │ │ ├── postSession-request.json │ │ │ │ ├── postSession.json │ │ │ │ ├── postZoneDeployment-request.json │ │ │ │ ├── postZoneDeployment.json │ │ │ │ ├── postZoneResourceRecord-request.json │ │ │ │ ├── postZoneResourceRecord.json │ │ │ │ └── zones.json │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ ├── predicates.go │ │ │ ├── predicates_test.go │ │ │ └── types.go │ │ ├── bookmyname/ │ │ │ ├── bookmyname.go │ │ │ ├── bookmyname.toml │ │ │ ├── bookmyname_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_success.txt │ │ │ │ ├── error.txt │ │ │ │ └── remove_success.txt │ │ │ └── types.go │ │ ├── brandit/ │ │ │ ├── brandit.go │ │ │ ├── brandit.toml │ │ │ ├── brandit_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add-record.json │ │ │ │ ├── delete-record.json │ │ │ │ ├── error.json │ │ │ │ ├── list-records.json │ │ │ │ └── status-domain.json │ │ │ └── types.go │ │ ├── bunny/ │ │ │ ├── bunny.go │ │ │ ├── bunny.toml │ │ │ └── bunny_test.go │ │ ├── checkdomain/ │ │ │ ├── checkdomain.go │ │ │ ├── checkdomain.toml │ │ │ ├── checkdomain_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_record-request.json │ │ │ │ └── delete_txt_record-request.json │ │ │ └── types.go │ │ ├── civo/ │ │ │ ├── civo.go │ │ │ ├── civo.toml │ │ │ ├── civo_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_dns_record-request.json │ │ │ │ ├── create_dns_record.json │ │ │ │ ├── delete_dns_record.json │ │ │ │ ├── error.json │ │ │ │ ├── list_dns_records.json │ │ │ │ └── list_domain_names.json │ │ │ └── types.go │ │ ├── clouddns/ │ │ │ ├── clouddns.go │ │ │ ├── clouddns.toml │ │ │ ├── clouddns_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── domain-request.json │ │ │ │ ├── domain_search-request.json │ │ │ │ ├── domain_search.json │ │ │ │ ├── login-request.json │ │ │ │ ├── login.json │ │ │ │ ├── publish-request.json │ │ │ │ └── record_txt-request.json │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ └── types.go │ │ ├── cloudflare/ │ │ │ ├── cloudflare.go │ │ │ ├── cloudflare.toml │ │ │ ├── cloudflare_test.go │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── create_record-request.json │ │ │ │ │ ├── create_record.json │ │ │ │ │ ├── delete_record.json │ │ │ │ │ ├── error.json │ │ │ │ │ └── zones.json │ │ │ │ ├── options.go │ │ │ │ └── types.go │ │ │ └── wrapper.go │ │ ├── cloudns/ │ │ │ ├── cloudns.go │ │ │ ├── cloudns.toml │ │ │ ├── cloudns_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ └── types.go │ │ ├── cloudru/ │ │ │ ├── cloudru.go │ │ │ ├── cloudru.toml │ │ │ ├── cloudru_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── auth-error.json │ │ │ │ ├── auth.json │ │ │ │ ├── record.json │ │ │ │ ├── records.json │ │ │ │ └── zones.json │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ └── types.go │ │ ├── cloudxns/ │ │ │ ├── cloudxns.go │ │ │ └── cloudxns.toml │ │ ├── com35/ │ │ │ ├── com35.go │ │ │ ├── com35.toml │ │ │ └── com35_test.go │ │ ├── conoha/ │ │ │ ├── conoha.go │ │ │ ├── conoha.toml │ │ │ ├── conoha_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── domains-records_GET.json │ │ │ │ ├── domains-records_POST.json │ │ │ │ ├── domains_GET.json │ │ │ │ ├── empty.json │ │ │ │ └── tokens_POST.json │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ └── types.go │ │ ├── conohav3/ │ │ │ ├── conohav3.go │ │ │ ├── conohav3.toml │ │ │ ├── conohav3_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── domains-records_GET.json │ │ │ │ ├── domains-records_POST.json │ │ │ │ ├── domains_GET.json │ │ │ │ └── empty.json │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ └── types.go │ │ ├── constellix/ │ │ │ ├── constellix.go │ │ │ ├── constellix.toml │ │ │ ├── constellix_test.go │ │ │ └── internal/ │ │ │ ├── auth.go │ │ │ ├── auth_test.go │ │ │ ├── client.go │ │ │ ├── domains.go │ │ │ ├── domains_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── domains-GetAll.json │ │ │ │ ├── domains-Search.json │ │ │ │ ├── records-Create.json │ │ │ │ ├── records-Get.json │ │ │ │ ├── records-GetAll.json │ │ │ │ └── records-Search.json │ │ │ ├── txtrecords.go │ │ │ ├── txtrecords_test.go │ │ │ └── types.go │ │ ├── corenetworks/ │ │ │ ├── corenetworks.go │ │ │ ├── corenetworks.toml │ │ │ ├── corenetworks_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── GetZoneDetails.json │ │ │ │ ├── ListRecords.json │ │ │ │ ├── ListZone.json │ │ │ │ └── auth.json │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ └── types.go │ │ ├── cpanel/ │ │ │ ├── cpanel.go │ │ │ ├── cpanel.toml │ │ │ ├── cpanel_test.go │ │ │ └── internal/ │ │ │ ├── cpanel/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── update-zone.json │ │ │ │ │ ├── update-zone_error.json │ │ │ │ │ ├── zone-info.json │ │ │ │ │ └── zone-info_error.json │ │ │ │ └── types.go │ │ │ ├── shared/ │ │ │ │ └── types.go │ │ │ └── whm/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── update-zone.json │ │ │ │ ├── update-zone_error.json │ │ │ │ ├── zone-info.json │ │ │ │ └── zone-info_error.json │ │ │ └── types.go │ │ ├── czechia/ │ │ │ ├── czechia.go │ │ │ ├── czechia.toml │ │ │ ├── czechia_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_txt_record-request.json │ │ │ │ └── delete_txt_record-request.json │ │ │ └── types.go │ │ ├── ddnss/ │ │ │ ├── ddnss.go │ │ │ ├── ddnss.toml │ │ │ ├── ddnss_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── error.html │ │ │ │ └── success.html │ │ │ └── types.go │ │ ├── derak/ │ │ │ ├── derak.go │ │ │ ├── derak.toml │ │ │ ├── derak_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── error.json │ │ │ │ ├── record-DELETE.json │ │ │ │ ├── record-GET.json │ │ │ │ ├── record-PATCH.json │ │ │ │ ├── record-PUT.json │ │ │ │ ├── records-GET.json │ │ │ │ └── service-cdn-zones.json │ │ │ ├── readme.md │ │ │ └── types.go │ │ ├── desec/ │ │ │ ├── desec.go │ │ │ ├── desec.toml │ │ │ └── desec_test.go │ │ ├── designate/ │ │ │ ├── designate.go │ │ │ ├── designate.toml │ │ │ └── designate_test.go │ │ ├── digitalocean/ │ │ │ ├── digitalocean.go │ │ │ ├── digitalocean.toml │ │ │ ├── digitalocean_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ └── domains-records_POST.json │ │ │ └── types.go │ │ ├── directadmin/ │ │ │ ├── directadmin.go │ │ │ ├── directadmin.toml │ │ │ ├── directadmin_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ └── types.go │ │ ├── dns_providers_test.go │ │ ├── dnsexit/ │ │ │ ├── dnsexit.go │ │ │ ├── dnsexit.toml │ │ │ ├── dnsexit_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_record-request.json │ │ │ │ ├── delete_record-request.json │ │ │ │ ├── error.json │ │ │ │ └── success.json │ │ │ └── types.go │ │ ├── dnshomede/ │ │ │ ├── dnshomede.go │ │ │ ├── dnshomede.toml │ │ │ ├── dnshomede_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ └── readme.md │ │ ├── dnsimple/ │ │ │ ├── dnsimple.go │ │ │ ├── dnsimple.toml │ │ │ └── dnsimple_test.go │ │ ├── dnsmadeeasy/ │ │ │ ├── dnsmadeeasy.go │ │ │ ├── dnsmadeeasy.toml │ │ │ ├── dnsmadeeasy_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_record-request.json │ │ │ │ └── get_records.json │ │ │ └── types.go │ │ ├── dnspod/ │ │ │ ├── dnspod.go │ │ │ ├── dnspod.toml │ │ │ └── dnspod_test.go │ │ ├── dode/ │ │ │ ├── dode.go │ │ │ ├── dode.toml │ │ │ ├── dode_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ └── success.json │ │ │ └── types.go │ │ ├── domeneshop/ │ │ │ ├── domeneshop.go │ │ │ ├── domeneshop.toml │ │ │ ├── domeneshop_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_record-request.json │ │ │ │ ├── create_record.json │ │ │ │ ├── delete_record.json │ │ │ │ ├── getDnsRecords.json │ │ │ │ └── getDomains.json │ │ │ └── types.go │ │ ├── dreamhost/ │ │ │ ├── dreamhost.go │ │ │ ├── dreamhost.toml │ │ │ ├── dreamhost_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ └── types.go │ │ ├── duckdns/ │ │ │ ├── duckdns.go │ │ │ ├── duckdns.toml │ │ │ ├── duckdns_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ └── client_test.go │ │ ├── dyn/ │ │ │ ├── dyn.go │ │ │ ├── dyn.toml │ │ │ ├── dyn_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create-txt-record.json │ │ │ │ ├── login.json │ │ │ │ └── publish.json │ │ │ ├── session.go │ │ │ ├── session_test.go │ │ │ └── types.go │ │ ├── dyndnsfree/ │ │ │ ├── dyndnsfree.go │ │ │ ├── dyndnsfree.toml │ │ │ ├── dyndnsfree_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ └── client_test.go │ │ ├── dynu/ │ │ │ ├── dynu.go │ │ │ ├── dynu.toml │ │ │ ├── dynu_test.go │ │ │ └── internal/ │ │ │ ├── auth.go │ │ │ ├── auth_test.go │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_new_record-request.json │ │ │ │ ├── add_new_record.json │ │ │ │ ├── add_new_record_invalid.json │ │ │ │ ├── delete_record.json │ │ │ │ ├── delete_record_invalid.json │ │ │ │ ├── get_records.json │ │ │ │ ├── get_records_empty.json │ │ │ │ ├── get_records_invalid.json │ │ │ │ ├── get_root_domain.json │ │ │ │ └── get_root_domain_invalid.json │ │ │ └── types.go │ │ ├── easydns/ │ │ │ ├── easydns.go │ │ │ ├── easydns.toml │ │ │ ├── easydns_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add-record.json │ │ │ │ ├── error.json │ │ │ │ ├── error1.json │ │ │ │ └── list-zone.json │ │ │ ├── readme.md │ │ │ └── types.go │ │ ├── edgecenter/ │ │ │ ├── edgecenter.go │ │ │ ├── edgecenter.toml │ │ │ └── edgecenter_test.go │ │ ├── edgedns/ │ │ │ ├── edgedns.go │ │ │ ├── edgedns.toml │ │ │ ├── edgedns_integration_test.go │ │ │ └── edgedns_test.go │ │ ├── edgeone/ │ │ │ ├── edgeone.go │ │ │ ├── edgeone.toml │ │ │ ├── edgeone_test.go │ │ │ └── wrapper.go │ │ ├── efficientip/ │ │ │ ├── efficientip.go │ │ │ ├── efficientip.toml │ │ │ ├── efficientip_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── dns_rr_add.json │ │ │ │ ├── dns_rr_delete-error.json │ │ │ │ ├── dns_rr_delete.json │ │ │ │ ├── dns_rr_info.json │ │ │ │ └── dns_rr_list.json │ │ │ └── types.go │ │ ├── epik/ │ │ │ ├── epik.go │ │ │ ├── epik.toml │ │ │ ├── epik_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── createHostRecord.json │ │ │ │ ├── error.json │ │ │ │ ├── getDnsRecord.json │ │ │ │ └── removeHostRecord.json │ │ │ └── types.go │ │ ├── eurodns/ │ │ │ ├── eurodns.go │ │ │ ├── eurodns.toml │ │ │ ├── eurodns_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── error.json │ │ │ │ ├── zone_add.json │ │ │ │ ├── zone_add_empty_forwards.json │ │ │ │ ├── zone_add_validate_ko.json │ │ │ │ ├── zone_add_validate_ok.json │ │ │ │ ├── zone_get.json │ │ │ │ └── zone_remove.json │ │ │ └── types.go │ │ ├── excedo/ │ │ │ ├── excedo.go │ │ │ ├── excedo.toml │ │ │ ├── excedo_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── addrecord.json │ │ │ │ ├── deleterecord.json │ │ │ │ ├── error.json │ │ │ │ ├── getrecords.json │ │ │ │ └── login.json │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ └── types.go │ │ ├── exec/ │ │ │ ├── exec.go │ │ │ ├── exec.toml │ │ │ ├── exec_test.go │ │ │ └── log_mock_test.go │ │ ├── exoscale/ │ │ │ ├── exoscale.go │ │ │ ├── exoscale.toml │ │ │ └── exoscale_test.go │ │ ├── f5xc/ │ │ │ ├── f5xc.go │ │ │ ├── f5xc.toml │ │ │ ├── f5xc_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create.json │ │ │ │ ├── delete.json │ │ │ │ ├── error_404.json │ │ │ │ ├── error_503.json │ │ │ │ ├── get.json │ │ │ │ └── replace.json │ │ │ └── types.go │ │ ├── freemyip/ │ │ │ ├── freemyip.go │ │ │ ├── freemyip.toml │ │ │ └── freemyip_test.go │ │ ├── gandi/ │ │ │ ├── gandi.go │ │ │ ├── gandi.toml │ │ │ ├── gandi_mock_test.go │ │ │ ├── gandi_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_txt_record-request.xml │ │ │ │ ├── clone_zone-request.xml │ │ │ │ ├── clone_zone.xml │ │ │ │ ├── delete_zone-request.xml │ │ │ │ ├── delete_zone.xml │ │ │ │ ├── empty.xml │ │ │ │ ├── get_zone_id-request.xml │ │ │ │ ├── get_zone_id.xml │ │ │ │ ├── new_zone_version-request.xml │ │ │ │ ├── new_zone_version.xml │ │ │ │ ├── set_zone-request.xml │ │ │ │ ├── set_zone.xml │ │ │ │ ├── set_zone_version-request.xml │ │ │ │ └── set_zone_version.xml │ │ │ └── types.go │ │ ├── gandiv5/ │ │ │ ├── gandiv5.go │ │ │ ├── gandiv5.toml │ │ │ ├── gandiv5_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_txt_record_get.json │ │ │ │ └── api_response.json │ │ │ └── types.go │ │ ├── gcloud/ │ │ │ ├── fixtures/ │ │ │ │ └── gce_account_service_file.json │ │ │ ├── gcloud.toml │ │ │ ├── googlecloud.go │ │ │ └── googlecloud_test.go │ │ ├── gcore/ │ │ │ ├── gcore.go │ │ │ ├── gcore.toml │ │ │ └── gcore_test.go │ │ ├── gigahostno/ │ │ │ ├── gigahostno.go │ │ │ ├── gigahostno.toml │ │ │ ├── gigahostno_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── authenticate-request.json │ │ │ │ ├── authenticate.json │ │ │ │ ├── create_record-request.json │ │ │ │ ├── create_record.json │ │ │ │ ├── delete_record.json │ │ │ │ ├── error.json │ │ │ │ ├── zone_records.json │ │ │ │ └── zones.json │ │ │ ├── identity.go │ │ │ ├── identity_test.go │ │ │ └── types.go │ │ ├── glesys/ │ │ │ ├── glesys.go │ │ │ ├── glesys.toml │ │ │ ├── glesys_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add-record.json │ │ │ │ └── delete-record.json │ │ │ └── types.go │ │ ├── godaddy/ │ │ │ ├── godaddy.go │ │ │ ├── godaddy.toml │ │ │ ├── godaddy_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── error-extended.json │ │ │ │ ├── errors.json │ │ │ │ ├── getrecords.json │ │ │ │ └── update_records-request.json │ │ │ └── types.go │ │ ├── googledomains/ │ │ │ ├── googledomains.go │ │ │ └── googledomains.toml │ │ ├── gravity/ │ │ │ ├── gravity.go │ │ │ ├── gravity.toml │ │ │ ├── gravity_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_record-request.json │ │ │ │ ├── error.json │ │ │ │ ├── login-request.json │ │ │ │ ├── login.json │ │ │ │ ├── me.json │ │ │ │ ├── me_unauthenticated.json │ │ │ │ ├── zones.json │ │ │ │ └── zones_empty.json │ │ │ └── types.go │ │ ├── hetzner/ │ │ │ ├── hetzner.go │ │ │ ├── hetzner.toml │ │ │ ├── hetzner_test.go │ │ │ └── internal/ │ │ │ ├── hetznerv1/ │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_rrset_records-request.json │ │ │ │ │ ├── add_rrset_records.json │ │ │ │ │ ├── get_action_error.json │ │ │ │ │ ├── get_action_running.json │ │ │ │ │ ├── get_action_success.json │ │ │ │ │ ├── remove_rrset_records-request.json │ │ │ │ │ └── remove_rrset_records.json │ │ │ │ ├── hetznerv1.go │ │ │ │ ├── hetznerv1_test.go │ │ │ │ └── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_rrset_records-request.json │ │ │ │ │ ├── add_rrset_records.json │ │ │ │ │ ├── error-deprecated_api_endpoint.json │ │ │ │ │ ├── error-invalid_input.json │ │ │ │ │ ├── error-resource_limit_exceeded.json │ │ │ │ │ ├── get_action.json │ │ │ │ │ ├── remove_rrset_records-request.json │ │ │ │ │ └── remove_rrset_records.json │ │ │ │ └── types.go │ │ │ └── legacy/ │ │ │ ├── hetzner.go │ │ │ ├── hetzner_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_txt_record-request.json │ │ │ │ ├── create_txt_record.json │ │ │ │ ├── get_txt_record.json │ │ │ │ └── get_zone_id.json │ │ │ └── types.go │ │ ├── hostingde/ │ │ │ ├── hostingde.go │ │ │ ├── hostingde.toml │ │ │ └── hostingde_test.go │ │ ├── hostinger/ │ │ │ ├── hostinger.go │ │ │ ├── hostinger.toml │ │ │ ├── hostinger_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── delete_dns_records.json │ │ │ │ ├── error_401.json │ │ │ │ ├── error_422.json │ │ │ │ ├── get_dns_records.json │ │ │ │ ├── get_dns_records_acme.json │ │ │ │ ├── get_dns_records_empty.json │ │ │ │ ├── update_dns_records-request.json │ │ │ │ ├── update_dns_records.json │ │ │ │ └── update_dns_records_base-request.json │ │ │ └── types.go │ │ ├── hostingnl/ │ │ │ ├── hostingnl.go │ │ │ ├── hostingnl.toml │ │ │ ├── hostingnl_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── add_record-request.json │ │ │ │ ├── add_record.json │ │ │ │ ├── delete_record-request.json │ │ │ │ ├── delete_record.json │ │ │ │ ├── error.json │ │ │ │ └── error_other.json │ │ │ └── types.go │ │ ├── hosttech/ │ │ │ ├── hosttech.go │ │ │ ├── hosttech.toml │ │ │ ├── hosttech_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── error-details.json │ │ │ │ ├── error.json │ │ │ │ ├── record.json │ │ │ │ ├── records.json │ │ │ │ ├── zone.json │ │ │ │ └── zones.json │ │ │ └── types.go │ │ ├── httpnet/ │ │ │ ├── httpnet.go │ │ │ ├── httpnet.toml │ │ │ └── httpnet_test.go │ │ ├── httpreq/ │ │ │ ├── httpreq.go │ │ │ ├── httpreq.toml │ │ │ └── httpreq_test.go │ │ ├── huaweicloud/ │ │ │ ├── huaweicloud.go │ │ │ ├── huaweicloud.toml │ │ │ ├── huaweicloud_test.go │ │ │ └── internal/ │ │ │ └── client.go │ │ ├── hurricane/ │ │ │ ├── hurricane.go │ │ │ ├── hurricane.toml │ │ │ ├── hurricane_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ └── client_test.go │ │ ├── hyperone/ │ │ │ ├── hyperone.go │ │ │ ├── hyperone.toml │ │ │ ├── hyperone_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── createRecord.json │ │ │ │ ├── createRecordset.json │ │ │ │ ├── invalidPassport.json │ │ │ │ ├── record.json │ │ │ │ ├── recordset.json │ │ │ │ ├── validPassport.json │ │ │ │ └── zones.json │ │ │ ├── passport.go │ │ │ ├── passport_test.go │ │ │ ├── token.go │ │ │ ├── token_test.go │ │ │ └── types.go │ │ ├── ibmcloud/ │ │ │ ├── ibmcloud.go │ │ │ ├── ibmcloud.toml │ │ │ ├── ibmcloud_test.go │ │ │ └── internal/ │ │ │ └── wrapper.go │ │ ├── iij/ │ │ │ ├── iij.go │ │ │ ├── iij.toml │ │ │ └── iij_test.go │ │ ├── iijdpf/ │ │ │ ├── iijdpf.go │ │ │ ├── iijdpf.toml │ │ │ ├── iijdpf_test.go │ │ │ └── wrapper.go │ │ ├── infoblox/ │ │ │ ├── infoblox.go │ │ │ ├── infoblox.toml │ │ │ └── infoblox_test.go │ │ ├── infomaniak/ │ │ │ ├── infomaniak.go │ │ │ ├── infomaniak.toml │ │ │ ├── infomaniak_test.go │ │ │ └── internal/ │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── create_dns_record-request.json │ │ │ │ └── get_domain_name.json │ │ │ └── types.go │ │ ├── internal/ │ │ │ ├── active24/ │ │ │ │ ├── internal/ │ │ │ │ │ ├── client.go │ │ │ │ │ ├── client_test.go │ │ │ │ │ ├── fixtures/ │ │ │ │ │ │ ├── error_403.json │ │ │ │ │ │ ├── error_422.json │ │ │ │ │ │ ├── error_v1.json │ │ │ │ │ │ ├── records.json │ │ │ │ │ │ └── services.json │ │ │ │ │ └── types.go │ │ │ │ ├── provider.go │ │ │ │ └── provider_test.go │ │ │ ├── clientdebug/ │ │ │ │ ├── .gitattributes │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ └── testdata/ │ │ │ │ ├── env_vars.txt │ │ │ │ ├── headers.txt │ │ │ │ └── values.txt │ │ │ ├── errutils/ │ │ │ │ └── client.go │ │ │ ├── gcore/ │ │ │ │ ├── internal/ │ │ │ │ │ ├── client.go │ │ │ │ │ ├── client_test.go │ │ │ │ │ └── types.go │ │ │ │ ├── provider.go │ │ │ │ └── provider_test.go │ │ │ ├── hostingde/ │ │ │ │ ├── internal/ │ │ │ │ │ ├── client.go │ │ │ │ │ ├── client_test.go │ │ │ │ │ ├── fixtures/ │ │ │ │ │ │ ├── zoneConfigsFind-request.json │ │ │ │ │ │ ├── zoneConfigsFind.json │ │ │ │ │ │ ├── zoneConfigsFind_error.json │ │ │ │ │ │ ├── zoneUpdate-request.json │ │ │ │ │ │ ├── zoneUpdate.json │ │ │ │ │ │ └── zoneUpdate_error.json │ │ │ │ │ └── types.go │ │ │ │ ├── provider.go │ │ │ │ └── provider_test.go │ │ │ ├── ionos/ │ │ │ │ ├── internal/ │ │ │ │ │ ├── client.go │ │ │ │ │ ├── client_test.go │ │ │ │ │ ├── fixtures/ │ │ │ │ │ │ ├── get_records.json │ │ │ │ │ │ ├── get_records_error.json │ │ │ │ │ │ ├── list_zones.json │ │ │ │ │ │ ├── list_zones_error.json │ │ │ │ │ │ ├── remove_record_error.json │ │ │ │ │ │ └── replace_records_error.json │ │ │ │ │ └── types.go │ │ │ │ ├── provider.go │ │ │ │ └── provider_test.go │ │ │ ├── ptr/ │ │ │ │ └── types.go │ │ │ ├── rimuhosting/ │ │ │ │ ├── internal/ │ │ │ │ │ ├── client.go │ │ │ │ │ ├── client_test.go │ │ │ │ │ ├── fixtures/ │ │ │ │ │ │ ├── add_record.xml │ │ │ │ │ │ ├── add_record_error.xml │ │ │ │ │ │ ├── add_record_same_domain.xml │ │ │ │ │ │ ├── delete_record.xml │ │ │ │ │ │ ├── delete_record_error.xml │ │ │ │ │ │ ├── delete_record_nothing.xml │ │ │ │ │ │ ├── find_records.xml │ │ │ │ │ │ ├── find_records_empty.xml │ │ │ │ │ │ └── find_records_pattern.xml │ │ │ │ │ └── types.go │ │ │ │ ├── provider.go │ │ │ │ └── provider_test.go │ │ │ ├── selectel/ │ │ │ │ ├── internal/ │ │ │ │ │ ├── client.go │ │ │ │ │ ├── client_test.go │ │ │ │ │ ├── fixtures/ │ │ │ │ │ │ ├── add_record-request.json │ │ │ │ │ │ ├── add_record.json │ │ │ │ │ │ ├── domains.json │ │ │ │ │ │ ├── error.json │ │ │ │ │ │ └── list_records.json │ │ │ │ │ └── types.go │ │ │ │ ├── provider.go │ │ │ │ └── provider_test.go │ │ │ ├── tecnocratica/ │ │ │ │ ├── internal/ │ │ │ │ │ ├── client.go │ │ │ │ │ ├── client_test.go │ │ │ │ │ ├── fixtures/ │ │ │ │ │ │ ├── create_record-request.json │ │ │ │ │ │ ├── create_record.json │ │ │ │ │ │ ├── get_records.json │ │ │ │ │ │ └── get_zones.json │ │ │ │ │ └── types.go │ │ │ │ ├── provider.go │ │ │ │ └── provider_test.go │ │ │ ├── useragent/ │ │ │ │ └── useragent.go │ │ │ └── westcn/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── adddnsrecord.json │ │ │ │ │ ├── deldnsrecord.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── provider.go │ │ │ └── provider_test.go │ │ ├── internetbs/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── Domain_DnsRecord_Add_FAILURE.json │ │ │ │ │ ├── Domain_DnsRecord_Add_SUCCESS.json │ │ │ │ │ ├── Domain_DnsRecord_List_FAILURE.json │ │ │ │ │ ├── Domain_DnsRecord_List_SUCCESS.json │ │ │ │ │ ├── Domain_DnsRecord_Remove_SUCCESS.json │ │ │ │ │ └── auth_error.json │ │ │ │ └── types.go │ │ │ ├── internetbs.go │ │ │ ├── internetbs.toml │ │ │ └── internetbs_test.go │ │ ├── inwx/ │ │ │ ├── inwx.go │ │ │ ├── inwx.toml │ │ │ └── inwx_test.go │ │ ├── ionos/ │ │ │ ├── ionos.go │ │ │ ├── ionos.toml │ │ │ └── ionos_test.go │ │ ├── ionoscloud/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── create_record-request.json │ │ │ │ │ ├── create_record.json │ │ │ │ │ ├── error.json │ │ │ │ │ └── zones.json │ │ │ │ └── types.go │ │ │ ├── ionoscloud.go │ │ │ ├── ionoscloud.toml │ │ │ └── ionoscloud_test.go │ │ ├── ipv64/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_record-error.json │ │ │ │ │ ├── add_record.json │ │ │ │ │ ├── del_record-error.json │ │ │ │ │ ├── del_record.json │ │ │ │ │ ├── error.json │ │ │ │ │ └── get_domains.json │ │ │ │ └── types.go │ │ │ ├── ipv64.go │ │ │ ├── ipv64.toml │ │ │ └── ipv64_test.go │ │ ├── ispconfig/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── client_get_id-request.json │ │ │ │ │ ├── client_get_id.json │ │ │ │ │ ├── dns_txt_add-request.json │ │ │ │ │ ├── dns_txt_add.json │ │ │ │ │ ├── dns_txt_delete-request.json │ │ │ │ │ ├── dns_txt_delete.json │ │ │ │ │ ├── dns_txt_get-request.json │ │ │ │ │ ├── dns_txt_get.json │ │ │ │ │ ├── dns_zone_get-request.json │ │ │ │ │ ├── dns_zone_get.json │ │ │ │ │ ├── dns_zone_get_id-request.json │ │ │ │ │ ├── dns_zone_get_id.json │ │ │ │ │ ├── error.json │ │ │ │ │ ├── login-request.json │ │ │ │ │ └── login.json │ │ │ │ ├── readme.md │ │ │ │ └── types.go │ │ │ ├── ispconfig.go │ │ │ ├── ispconfig.toml │ │ │ └── ispconfig_test.go │ │ ├── ispconfigddns/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ └── types.go │ │ │ ├── ispconfigddns.go │ │ │ ├── ispconfigddns.toml │ │ │ └── ispconfigddns_test.go │ │ ├── iwantmyname/ │ │ │ ├── iwantmyname.go │ │ │ └── iwantmyname.toml │ │ ├── jdcloud/ │ │ │ ├── fixtures/ │ │ │ │ ├── create_record-request.json │ │ │ │ ├── create_record.json │ │ │ │ ├── delete_record.json │ │ │ │ ├── describe_domains_page1.json │ │ │ │ └── describe_domains_page2.json │ │ │ ├── jdcloud.go │ │ │ ├── jdcloud.toml │ │ │ └── jdcloud_test.go │ │ ├── joker/ │ │ │ ├── internal/ │ │ │ │ ├── dmapi/ │ │ │ │ │ ├── client.go │ │ │ │ │ ├── client_test.go │ │ │ │ │ ├── identity.go │ │ │ │ │ └── identity_test.go │ │ │ │ └── svc/ │ │ │ │ ├── client.go │ │ │ │ └── client_test.go │ │ │ ├── joker.go │ │ │ ├── joker.toml │ │ │ ├── joker_test.go │ │ │ ├── provider_dmapi.go │ │ │ ├── provider_dmapi_test.go │ │ │ ├── provider_svc.go │ │ │ └── provider_svc_test.go │ │ ├── keyhelp/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ ├── get_domain_records.json │ │ │ │ │ ├── get_domain_records2.json │ │ │ │ │ ├── get_domains.json │ │ │ │ │ ├── update_domain_records-request.json │ │ │ │ │ ├── update_domain_records-request2.json │ │ │ │ │ └── update_domain_records.json │ │ │ │ └── types.go │ │ │ ├── keyhelp.go │ │ │ ├── keyhelp.toml │ │ │ └── keyhelp_test.go │ │ ├── leaseweb/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── createResourceRecordSet-request.json │ │ │ │ │ ├── createResourceRecordSet.json │ │ │ │ │ ├── error_400.json │ │ │ │ │ ├── error_401.json │ │ │ │ │ ├── error_404.json │ │ │ │ │ ├── getResourceRecordSet.json │ │ │ │ │ ├── getResourceRecordSet2.json │ │ │ │ │ ├── updateResourceRecordSet-request.json │ │ │ │ │ ├── updateResourceRecordSet-request2.json │ │ │ │ │ └── updateResourceRecordSet.json │ │ │ │ └── types.go │ │ │ ├── leaseweb.go │ │ │ ├── leaseweb.toml │ │ │ └── leaseweb_test.go │ │ ├── liara/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── RecordResponse.json │ │ │ │ │ ├── RecordsResponse.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── liara.go │ │ │ ├── liara.toml │ │ │ └── liara_test.go │ │ ├── lightsail/ │ │ │ ├── lightsail.go │ │ │ ├── lightsail.toml │ │ │ ├── lightsail_integration_test.go │ │ │ └── lightsail_test.go │ │ ├── limacity/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ ├── get-domains.json │ │ │ │ │ ├── get-records.json │ │ │ │ │ └── ok.json │ │ │ │ └── types.go │ │ │ ├── limacity.go │ │ │ ├── limacity.toml │ │ │ └── limacity_test.go │ │ ├── linode/ │ │ │ ├── linode.go │ │ │ ├── linode.toml │ │ │ └── linode_test.go │ │ ├── liquidweb/ │ │ │ ├── liquidweb.go │ │ │ ├── liquidweb.toml │ │ │ ├── liquidweb_test.go │ │ │ └── servermock_test.go │ │ ├── loopia/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── mock_test.go │ │ │ │ └── types.go │ │ │ ├── loopia.go │ │ │ ├── loopia.toml │ │ │ ├── loopia_mock_test.go │ │ │ └── loopia_test.go │ │ ├── luadns/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── create_record.json │ │ │ │ │ ├── delete_record.json │ │ │ │ │ └── list_zones.json │ │ │ │ └── types.go │ │ │ ├── luadns.go │ │ │ ├── luadns.toml │ │ │ └── luadns_test.go │ │ ├── mailinabox/ │ │ │ ├── mailinabox.go │ │ │ ├── mailinabox.toml │ │ │ └── mailinabox_test.go │ │ ├── manageengine/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ ├── error_bad_request.json │ │ │ │ │ ├── zone_domains_all.json │ │ │ │ │ ├── zone_record_create.json │ │ │ │ │ ├── zone_record_delete.json │ │ │ │ │ ├── zone_record_update.json │ │ │ │ │ └── zone_records_all.json │ │ │ │ ├── identity.go │ │ │ │ └── types.go │ │ │ ├── manageengine.go │ │ │ ├── manageengine.toml │ │ │ └── manageengine_test.go │ │ ├── manual/ │ │ │ ├── manual.go │ │ │ ├── manual.toml │ │ │ └── manual_test.go │ │ ├── metaname/ │ │ │ ├── metaname.go │ │ │ ├── metaname.toml │ │ │ └── metaname_test.go │ │ ├── metaregistrar/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error-response.json │ │ │ │ │ ├── error.json │ │ │ │ │ └── update-dns-zone.json │ │ │ │ └── types.go │ │ │ ├── metaregistrar.go │ │ │ ├── metaregistrar.toml │ │ │ └── metaregistrar_test.go │ │ ├── mijnhost/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ ├── get-dns-records.json │ │ │ │ │ ├── list-domains.json │ │ │ │ │ └── update-dns-records.json │ │ │ │ └── types.go │ │ │ ├── mijnhost.go │ │ │ ├── mijnhost.toml │ │ │ └── mijnhost_test.go │ │ ├── mittwald/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── dns-create-dns-zone.json │ │ │ │ │ ├── dns-get-dns-zone.json │ │ │ │ │ ├── dns-list-dns-zones.json │ │ │ │ │ ├── domain-list-domains.json │ │ │ │ │ ├── error-client.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── mittwald.go │ │ │ ├── mittwald.toml │ │ │ └── mittwald_test.go │ │ ├── myaddr/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ └── error.txt │ │ │ │ └── types.go │ │ │ ├── myaddr.go │ │ │ ├── myaddr.toml │ │ │ └── myaddr_test.go │ │ ├── mydnsjp/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ └── client_test.go │ │ │ ├── mydnsjp.go │ │ │ ├── mydnsjp.toml │ │ │ └── mydnsjp_test.go │ │ ├── mythicbeasts/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── delete-zoneszonerecords.json │ │ │ │ │ ├── post-zoneszonerecords.json │ │ │ │ │ └── token.json │ │ │ │ ├── identity.go │ │ │ │ ├── identity_test.go │ │ │ │ └── types.go │ │ │ ├── mythicbeasts.go │ │ │ ├── mythicbeasts.toml │ │ │ └── mythicbeasts_test.go │ │ ├── namecheap/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── getHosts.xml │ │ │ │ │ ├── getHosts_errorBadAPIKey1.xml │ │ │ │ │ ├── getHosts_success1.xml │ │ │ │ │ ├── getHosts_success2.xml │ │ │ │ │ ├── setHosts.xml │ │ │ │ │ ├── setHosts_errorBadAPIKey1.xml │ │ │ │ │ ├── setHosts_success1.xml │ │ │ │ │ └── setHosts_success2.xml │ │ │ │ ├── ip.go │ │ │ │ └── types.go │ │ │ ├── namecheap.go │ │ │ ├── namecheap.toml │ │ │ ├── namecheap_test.go │ │ │ ├── transport.go │ │ │ └── transport_test.go │ │ ├── namedotcom/ │ │ │ ├── namedotcom.go │ │ │ ├── namedotcom.toml │ │ │ └── namedotcom_test.go │ │ ├── namesilo/ │ │ │ ├── namesilo.go │ │ │ ├── namesilo.toml │ │ │ └── namesilo_test.go │ │ ├── namesurfer/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── addDNSRecord-request.json │ │ │ │ │ ├── addDNSRecord.json │ │ │ │ │ ├── error.json │ │ │ │ │ ├── listZones-request.json │ │ │ │ │ ├── listZones.json │ │ │ │ │ ├── searchDNSHosts-request.json │ │ │ │ │ ├── searchDNSHosts.json │ │ │ │ │ ├── updateDNSHost-request.json │ │ │ │ │ └── updateDNSHost.json │ │ │ │ └── types.go │ │ │ ├── namesurfer.go │ │ │ ├── namesurfer.toml │ │ │ └── namesurfer_test.go │ │ ├── nearlyfreespeech/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── nearlyfreespeech.go │ │ │ ├── nearlyfreespeech.toml │ │ │ └── nearlyfreespeech_test.go │ │ ├── neodigit/ │ │ │ ├── neodigit.go │ │ │ ├── neodigit.toml │ │ │ └── neodigit_test.go │ │ ├── netcup/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_live_test.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── get_dns_records-request.json │ │ │ │ │ ├── get_dns_records.json │ │ │ │ │ ├── get_dns_records_error.json │ │ │ │ │ ├── get_dns_records_error_unmarshal.json │ │ │ │ │ ├── login-request.json │ │ │ │ │ ├── login.json │ │ │ │ │ ├── login_error.json │ │ │ │ │ ├── login_error_unmarshal.json │ │ │ │ │ ├── logout-request.json │ │ │ │ │ ├── logout.json │ │ │ │ │ └── logout_error.json │ │ │ │ ├── session.go │ │ │ │ ├── session_test.go │ │ │ │ └── types.go │ │ │ ├── netcup.go │ │ │ ├── netcup.toml │ │ │ └── netcup_test.go │ │ ├── netlify/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── create_record.json │ │ │ │ │ └── get_records.json │ │ │ │ └── types.go │ │ │ ├── netlify.go │ │ │ ├── netlify.toml │ │ │ └── netlify_test.go │ │ ├── nicmanager/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ └── zone.json │ │ │ │ └── types.go │ │ │ ├── nicmanager.go │ │ │ ├── nicmanager.toml │ │ │ └── nicmanager_test.go │ │ ├── nicru/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── commit_POST.xml │ │ │ │ │ ├── errors.xml │ │ │ │ │ ├── record_DELETE.xml │ │ │ │ │ ├── records_GET.xml │ │ │ │ │ ├── records_PUT.xml │ │ │ │ │ ├── services_GET.xml │ │ │ │ │ ├── zones_GET.xml │ │ │ │ │ └── zones_all_GET.xml │ │ │ │ ├── identity.go │ │ │ │ └── types.go │ │ │ ├── nicru.go │ │ │ ├── nicru.toml │ │ │ └── nicru_test.go │ │ ├── nifcloud/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ └── types.go │ │ │ ├── nifcloud.go │ │ │ ├── nifcloud.toml │ │ │ └── nifcloud_test.go │ │ ├── njalla/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_record-request.json │ │ │ │ │ ├── add_record.json │ │ │ │ │ ├── auth_error.json │ │ │ │ │ ├── list_records-request.json │ │ │ │ │ ├── list_records.json │ │ │ │ │ ├── remove_record-request.json │ │ │ │ │ ├── remove_record_error_missing_domain.json │ │ │ │ │ └── remove_record_error_missing_id.json │ │ │ │ └── types.go │ │ │ ├── njalla.go │ │ │ ├── njalla.toml │ │ │ └── njalla_test.go │ │ ├── nodion/ │ │ │ ├── nodion.go │ │ │ ├── nodion.toml │ │ │ └── nodion_test.go │ │ ├── ns1/ │ │ │ ├── ns1.go │ │ │ ├── ns1.toml │ │ │ └── ns1_test.go │ │ ├── octenium/ │ │ │ ├── fixtures/ │ │ │ │ ├── add_dns_record.json │ │ │ │ ├── delete_dns_record.json │ │ │ │ ├── list_dns_records.json │ │ │ │ └── list_domains.json │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_dns_record.json │ │ │ │ │ ├── delete_dns_record.json │ │ │ │ │ ├── error.json │ │ │ │ │ ├── list_dns_records.json │ │ │ │ │ └── list_domains.json │ │ │ │ └── types.go │ │ │ ├── octenium.go │ │ │ ├── octenium.toml │ │ │ └── octenium_test.go │ │ ├── oraclecloud/ │ │ │ ├── configurationprovider.go │ │ │ ├── fixtures/ │ │ │ │ ├── cert.pem │ │ │ │ └── key.pem │ │ │ ├── oraclecloud.go │ │ │ ├── oraclecloud.toml │ │ │ └── oraclecloud_test.go │ │ ├── otc/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── zones-recordsets_DELETE.json │ │ │ │ │ ├── zones-recordsets_GET.json │ │ │ │ │ ├── zones-recordsets_GET_empty.json │ │ │ │ │ ├── zones-recordsets_POST-request.json │ │ │ │ │ ├── zones-recordsets_POST.json │ │ │ │ │ ├── zones_GET.json │ │ │ │ │ └── zones_GET_empty.json │ │ │ │ ├── identity.go │ │ │ │ ├── identity_test.go │ │ │ │ ├── mock.go │ │ │ │ └── types.go │ │ │ ├── otc.go │ │ │ ├── otc.toml │ │ │ └── otc_test.go │ │ ├── ovh/ │ │ │ ├── ovh.go │ │ │ ├── ovh.toml │ │ │ └── ovh_test.go │ │ ├── pdns/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ ├── versions.json │ │ │ │ │ ├── zone-request.json │ │ │ │ │ └── zone.json │ │ │ │ └── types.go │ │ │ ├── pdns.go │ │ │ ├── pdns.toml │ │ │ └── pdns_test.go │ │ ├── plesk/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add-record-error.xml │ │ │ │ │ ├── add-record.xml │ │ │ │ │ ├── delete-record-error.xml │ │ │ │ │ ├── delete-record.xml │ │ │ │ │ ├── get-site-error.xml │ │ │ │ │ ├── get-site.xml │ │ │ │ │ └── global-error.xml │ │ │ │ └── types.go │ │ │ ├── plesk.go │ │ │ ├── plesk.toml │ │ │ └── plesk_test.go │ │ ├── porkbun/ │ │ │ ├── porkbun.go │ │ │ ├── porkbun.toml │ │ │ └── porkbun_test.go │ │ ├── rackspace/ │ │ │ ├── fixtures/ │ │ │ │ ├── delete.json │ │ │ │ ├── identity.json │ │ │ │ ├── record.json │ │ │ │ ├── record_details.json │ │ │ │ └── zone_details.json │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add-records.json │ │ │ │ │ ├── delete-records_error.json │ │ │ │ │ ├── list-domains-by-name.json │ │ │ │ │ ├── search-records.json │ │ │ │ │ └── tokens.json │ │ │ │ ├── identity.go │ │ │ │ ├── identity_test.go │ │ │ │ └── types.go │ │ │ ├── rackspace.go │ │ │ ├── rackspace.toml │ │ │ └── rackspace_test.go │ │ ├── rainyun/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── domains.json │ │ │ │ │ ├── error.json │ │ │ │ │ └── records.json │ │ │ │ └── types.go │ │ │ ├── rainyun.go │ │ │ ├── rainyun.toml │ │ │ └── rainyun_test.go │ │ ├── rcodezero/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ └── rrsets-response.json │ │ │ │ └── types.go │ │ │ ├── rcodezero.go │ │ │ ├── rcodezero.toml │ │ │ └── rcodezero_test.go │ │ ├── regfish/ │ │ │ ├── regfish.go │ │ │ ├── regfish.toml │ │ │ └── regfish_test.go │ │ ├── regru/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_txt_record.json │ │ │ │ │ ├── add_txt_record_error_auth.json │ │ │ │ │ ├── add_txt_record_error_domain.json │ │ │ │ │ ├── remove_record.json │ │ │ │ │ ├── remove_record_error_auth.json │ │ │ │ │ └── remove_record_error_domain.json │ │ │ │ ├── readme.md │ │ │ │ └── types.go │ │ │ ├── regru.go │ │ │ ├── regru.toml │ │ │ └── regru_test.go │ │ ├── rfc2136/ │ │ │ ├── internal/ │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── invalid_field.conf │ │ │ │ │ ├── invalid_key.conf │ │ │ │ │ ├── mising_algo.conf │ │ │ │ │ ├── missing_secret.conf │ │ │ │ │ ├── sample.conf │ │ │ │ │ ├── text_after.conf │ │ │ │ │ └── text_before.conf │ │ │ │ ├── readme.md │ │ │ │ ├── tsigkey.go │ │ │ │ └── tsigkey_test.go │ │ │ ├── rfc2136.go │ │ │ ├── rfc2136.toml │ │ │ └── rfc2136_test.go │ │ ├── rimuhosting/ │ │ │ ├── rimuhosting.go │ │ │ ├── rimuhosting.toml │ │ │ └── rimuhosting_test.go │ │ ├── route53/ │ │ │ ├── fixtures/ │ │ │ │ ├── changeResourceRecordSetsResponse.xml │ │ │ │ ├── getChangeResponse.xml │ │ │ │ └── listHostedZonesByNameResponse.xml │ │ │ ├── route53.go │ │ │ ├── route53.toml │ │ │ ├── route53_integration_test.go │ │ │ └── route53_test.go │ │ ├── safedns/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_record-request.json │ │ │ │ │ ├── add_record.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── safedns.go │ │ │ ├── safedns.toml │ │ │ └── safedns_test.go │ │ ├── sakuracloud/ │ │ │ ├── sakuracloud.go │ │ │ ├── sakuracloud.toml │ │ │ ├── sakuracloud_test.go │ │ │ ├── wrapper.go │ │ │ └── wrapper_test.go │ │ ├── scaleway/ │ │ │ ├── scaleway.go │ │ │ ├── scaleway.toml │ │ │ └── scaleway_test.go │ │ ├── selectel/ │ │ │ ├── selectel.go │ │ │ ├── selectel.toml │ │ │ └── selectel_test.go │ │ ├── selectelv2/ │ │ │ ├── selectelv2.go │ │ │ ├── selectelv2.toml │ │ │ └── selectelv2_test.go │ │ ├── selfhostde/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ └── readme.md │ │ │ ├── mapping.go │ │ │ ├── mapping_test.go │ │ │ ├── selfhostde.go │ │ │ ├── selfhostde.toml │ │ │ └── selfhostde_test.go │ │ ├── servercow/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ └── records-01.json │ │ │ │ ├── types.go │ │ │ │ └── types_test.go │ │ │ ├── servercow.go │ │ │ ├── servercow.toml │ │ │ └── servercow_test.go │ │ ├── shellrent/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── dns_record-remove.json │ │ │ │ │ ├── dns_record-store.json │ │ │ │ │ ├── domain-details.json │ │ │ │ │ ├── error.json │ │ │ │ │ ├── purchase-details.json │ │ │ │ │ └── purchase.json │ │ │ │ └── types.go │ │ │ ├── shellrent.go │ │ │ ├── shellrent.toml │ │ │ └── shellrent_test.go │ │ ├── simply/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_record.json │ │ │ │ │ ├── bad_auth_error.json │ │ │ │ │ ├── bad_zone_error.json │ │ │ │ │ ├── get_records.json │ │ │ │ │ ├── invalid_record_id_error.json │ │ │ │ │ └── success.json │ │ │ │ └── types.go │ │ │ ├── simply.go │ │ │ ├── simply.toml │ │ │ └── simply_test.go │ │ ├── sonic/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ └── types.go │ │ │ ├── sonic.go │ │ │ ├── sonic.toml │ │ │ └── sonic_test.go │ │ ├── spaceship/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ └── get-records.json │ │ │ │ └── types.go │ │ │ ├── spaceship.go │ │ │ ├── spaceship.toml │ │ │ └── spaceship_test.go │ │ ├── stackpath/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── get_zone_records.json │ │ │ │ │ └── get_zones.json │ │ │ │ ├── identity.go │ │ │ │ └── types.go │ │ │ ├── stackpath.go │ │ │ ├── stackpath.toml │ │ │ └── stackpath_test.go │ │ ├── syse/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── create_record-request.json │ │ │ │ │ └── create_record.json │ │ │ │ └── types.go │ │ │ ├── syse.go │ │ │ ├── syse.toml │ │ │ └── syse_test.go │ │ ├── technitium/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add-record.json │ │ │ │ │ ├── delete-record.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── technitium.go │ │ │ ├── technitium.toml │ │ │ └── technitium_test.go │ │ ├── tencentcloud/ │ │ │ ├── tencentcloud.go │ │ │ ├── tencentcloud.toml │ │ │ ├── tencentcloud_test.go │ │ │ └── wrapper.go │ │ ├── timewebcloud/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── createDomainDNSRecord.json │ │ │ │ │ ├── error_bad_request.json │ │ │ │ │ └── error_unauthorized.json │ │ │ │ ├── readme.md │ │ │ │ └── types.go │ │ │ ├── timewebcloud.go │ │ │ ├── timewebcloud.toml │ │ │ └── timewebcloud_test.go │ │ ├── todaynic/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_record.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── todaynic.go │ │ │ ├── todaynic.toml │ │ │ └── todaynic_test.go │ │ ├── transip/ │ │ │ ├── fixtures/ │ │ │ │ └── private.key │ │ │ ├── transip.go │ │ │ ├── transip.toml │ │ │ └── transip_test.go │ │ ├── ultradns/ │ │ │ ├── ultradns.go │ │ │ ├── ultradns.toml │ │ │ └── ultradns_test.go │ │ ├── uniteddomains/ │ │ │ ├── uniteddomains.go │ │ │ ├── uniteddomains.toml │ │ │ └── uniteddomains_test.go │ │ ├── variomedia/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── DELETE_dns-records_done.json │ │ │ │ │ ├── DELETE_dns-records_pending.json │ │ │ │ │ ├── GET_dns-records.json │ │ │ │ │ ├── GET_queue-jobs.json │ │ │ │ │ ├── POST_dns-records.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── variomedia.go │ │ │ ├── variomedia.toml │ │ │ └── variomedia_test.go │ │ ├── vegadns/ │ │ │ ├── fixtures/ │ │ │ │ ├── create_record.json │ │ │ │ ├── record_delete.json │ │ │ │ ├── records.json │ │ │ │ └── token.json │ │ │ ├── vegadns.go │ │ │ ├── vegadns.toml │ │ │ └── vegadns_test.go │ │ ├── vercel/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ └── types.go │ │ │ ├── vercel.go │ │ │ ├── vercel.toml │ │ │ └── vercel_test.go │ │ ├── versio/ │ │ │ ├── fixtures/ │ │ │ │ ├── error_failToCreateTXT.json │ │ │ │ ├── error_failToFindZone.json │ │ │ │ └── token.json │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── README.md │ │ │ │ │ ├── get-domain-error.json │ │ │ │ │ ├── get-domain.json │ │ │ │ │ ├── update-domain-error.json │ │ │ │ │ ├── update-domain-request.json │ │ │ │ │ └── update-domain.json │ │ │ │ └── types.go │ │ │ ├── versio.go │ │ │ ├── versio.toml │ │ │ └── versio_test.go │ │ ├── vinyldns/ │ │ │ ├── fixtures/ │ │ │ │ ├── recordSetChange-create.json │ │ │ │ ├── recordSetChange-delete.json │ │ │ │ ├── recordSetDelete.json │ │ │ │ ├── recordSetUpdate-create.json │ │ │ │ ├── recordSetsListAll-empty.json │ │ │ │ ├── recordSetsListAll.json │ │ │ │ └── zoneByName.json │ │ │ ├── vinyldns.go │ │ │ ├── vinyldns.toml │ │ │ ├── vinyldns_test.go │ │ │ └── wrapper.go │ │ ├── virtualname/ │ │ │ ├── virtualname.go │ │ │ ├── virtualname.toml │ │ │ └── virtualname_test.go │ │ ├── vkcloud/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ └── types.go │ │ │ ├── vkcloud.go │ │ │ ├── vkcloud.toml │ │ │ └── vkcloud_test.go │ │ ├── volcengine/ │ │ │ ├── volcengine.go │ │ │ ├── volcengine.toml │ │ │ └── volcengine_test.go │ │ ├── vscale/ │ │ │ ├── vscale.go │ │ │ ├── vscale.toml │ │ │ └── vscale_test.go │ │ ├── vultr/ │ │ │ ├── vultr.go │ │ │ ├── vultr.toml │ │ │ └── vultr_test.go │ │ ├── webnames/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.json │ │ │ │ │ └── ok.json │ │ │ │ └── types.go │ │ │ ├── webnames.go │ │ │ ├── webnames.toml │ │ │ └── webnames_test.go │ │ ├── webnamesca/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_txt_record.json │ │ │ │ │ ├── delete_txt_record.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── webnamesca.go │ │ │ ├── webnamesca.toml │ │ │ └── webnamesca_test.go │ │ ├── websupport/ │ │ │ ├── websupport.go │ │ │ ├── websupport.toml │ │ │ └── websupport_test.go │ │ ├── wedos/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── dns-domain-commit.json │ │ │ │ │ ├── dns-row-add.json │ │ │ │ │ ├── dns-row-delete.json │ │ │ │ │ ├── dns-row-update.json │ │ │ │ │ └── dns-rows-list.json │ │ │ │ ├── token.go │ │ │ │ └── types.go │ │ │ ├── wedos.go │ │ │ ├── wedos.toml │ │ │ └── wedos_test.go │ │ ├── westcn/ │ │ │ ├── westcn.go │ │ │ ├── westcn.toml │ │ │ └── westcn_test.go │ │ ├── yandex/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add_record.json │ │ │ │ │ ├── add_record_error.json │ │ │ │ │ ├── get_records.json │ │ │ │ │ ├── get_records_error.json │ │ │ │ │ ├── remove_record.json │ │ │ │ │ └── remove_record_error.json │ │ │ │ └── types.go │ │ │ ├── yandex.go │ │ │ ├── yandex.toml │ │ │ └── yandex_test.go │ │ ├── yandex360/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── add-record.json │ │ │ │ │ ├── delete-record.json │ │ │ │ │ └── error.json │ │ │ │ └── types.go │ │ │ ├── yandex360.go │ │ │ ├── yandex360.toml │ │ │ └── yandex360_test.go │ │ ├── yandexcloud/ │ │ │ ├── yandexcloud.go │ │ │ ├── yandexcloud.toml │ │ │ └── yandexcloud_test.go │ │ ├── zoneedit/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── error.xml │ │ │ │ │ └── success.xml │ │ │ │ └── types.go │ │ │ ├── zoneedit.go │ │ │ ├── zoneedit.toml │ │ │ └── zoneedit_test.go │ │ ├── zoneee/ │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── create-txt-record.json │ │ │ │ │ └── get-txt-records.json │ │ │ │ └── types.go │ │ │ ├── zoneee.go │ │ │ ├── zoneee.toml │ │ │ └── zoneee_test.go │ │ ├── zonomi/ │ │ │ ├── zonomi.go │ │ │ ├── zonomi.toml │ │ │ └── zonomi_test.go │ │ └── zz_gen_dns_providers.go │ └── http/ │ ├── memcached/ │ │ ├── README.md │ │ ├── memcached.go │ │ └── memcached_test.go │ ├── s3/ │ │ ├── s3.go │ │ ├── s3.toml │ │ └── s3_test.go │ └── webroot/ │ ├── webroot.go │ └── webroot_test.go └── registration/ ├── registar.go ├── registar_test.go ├── user.go └── user_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ lego.exe .lego .gitcookies .idea .vscode/ dist/ builds/ docs/ ================================================ FILE: .gitattributes ================================================ **/zz_gen_*.* linguist-generated docs/data/zz_cli_help.toml linguist-generated ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐞 Bug Report description: Create a report to help us improve. labels: [bug] body: - type: checkboxes id: terms attributes: label: Welcome options: - label: Yes, I'm using a binary release within the two latest releases. required: true - label: Yes, I've searched for similar issues on GitHub and didn't find any. required: true - label: Yes, I've included all information below (version, config, etc). required: true - type: textarea id: expected attributes: label: What did you expect to see? placeholder: Description. validations: required: true - type: textarea id: current attributes: label: What did you see instead? placeholder: Description. validations: required: true - type: dropdown id: type attributes: label: How do you use lego? options: - I don't know - Library - Binary - Docker image - Through Traefik - Through Caddy - Through Terraform ACME provider - Through Bitnami - Through 1Panel - Through Zoraxy - Through Certimate - go install - Other validations: required: true - type: textarea id: steps attributes: label: Reproduction steps description: "How do you trigger this bug? Please walk us through it step by step." placeholder: | 1. ... 2. ... 3. ... ... validations: required: true - type: textarea id: version attributes: label: Effective version of lego description: |- `latest` or `dev` are not effective versions. ```console $ lego --version ``` placeholder: Paste output here render: console validations: required: true - type: textarea id: logs attributes: label: Logs value: |-
```console # paste output here ```
validations: required: true - type: textarea id: go-env attributes: label: Go environment (if applicable) value: |-
```console $ go version && go env # paste output here ```
validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: ❓ Questions url: https://github.com/go-acme/lego/discussions about: If you have a question, or are looking for advice, please post on our Discussions section! - name: 📖 Documentation url: https://go-acme.github.io/lego/ about: Please take a look to our documentation. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 💡 Feature request description: Suggest an idea for this project. body: - type: checkboxes id: terms attributes: label: Welcome options: - label: Yes, I've searched for similar issues on GitHub and didn't find any. required: true - type: dropdown id: type attributes: label: How do you use lego? options: - I don't know - Library - Binary - Docker image - Through Traefik - Through Caddy - Through Terraform ACME provider - Through Bitnami - Through 1Panel - Through Zoraxy - Through Certimate - go install - Other validations: required: true - type: input id: version attributes: label: Effective version of lego description: "`latest` or `dev` are not effective versions." validations: required: true - type: textarea id: description attributes: label: Detailed Description placeholder: Description. validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/new_dns_provider.yml ================================================ name: 🧩 New DNS provider support description: Request for the support of a new DNS provider. title: "Support for provider: " labels: [enhancement, new-provider] body: - type: checkboxes id: terms attributes: label: Welcome options: - label: Yes, I've searched for similar issues on GitHub and didn't find any. required: true - label: Yes, the DNS provider exposes a public API. required: true - label: Yes, I know that the lego maintainers don't have an account in all DNS providers in the world. required: true - type: checkboxes id: pr attributes: label: Implementation options: - label: Yes, I'm able to create a pull request and be able to maintain the implementation. required: false - label: Yes, I can test an implementation with the help of the maintainers if someone creates a pull request. required: false - type: dropdown id: type attributes: label: How do you use lego? options: - I don't know - Library - Binary - Docker image - Through Traefik - Through Caddy - Through Terraform ACME provider - Through Bitnami - Through 1Panel - Through Zoraxy - Through Certimate - go install - Other validations: required: true - type: dropdown id: profile attributes: label: Who are you? options: - A customer of this DNS provider - An employee of this DNS provider - Other (please explain) validations: required: true - type: input id: provider-link attributes: label: Link to the DNS provider placeholder: Put your link here. validations: required: true - type: input id: api-link attributes: label: Link to the API documentation placeholder: Put your link here. validations: required: true - type: textarea id: expected attributes: label: Additional Notes placeholder: Your notes. validations: required: false ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/mnp.md ================================================ PULL REQUEST TEMPLATE FOR MAINTAINERS ONLY. https://github.com/go-acme/lego/compare/master...ldez:branch?quick_pull=1&title=Add+DNS+provider+for+&labels=enhancement,area/dnsprovider,state/need-user-tests&template=mnp.md ?quick_pull=1&title=Add+DNS+provider+for+&labels=enhancement,area/dnsprovider,state/need-user-tests&template=mnp.md --- - [x] adds a description to your PR - [x] have a homogeneous design with the other providers - [ ] add tests (units) - [ ] add tests ("live") - [ ] add a provider descriptor - [ ] generate CLI help, documentation, and readme. - [ ] be able to do: _(and put the output of this command to a comment)_ ```bash make build rm -rf .lego EXAMPLE_USERNAME=xxx \ ./dist/lego -m your_email@example.com --dns EXAMPLE -d *.example.com -d example.com -s https://acme-staging-v02.api.letsencrypt.org/directory run ``` Note the wildcard domain is important. - [ ] pass the linter - [ ] do `go mod tidy` Ping @xxx, can you run the command (with your domain, email, credentials, etc.)? Closes # ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ================================================ FILE: .github/workflows/documentation.yml ================================================ name: Documentation on: push: branches: - master jobs: doc: name: Build and deploy documentation runs-on: ubuntu-latest env: GO_VERSION: stable HUGO_VERSION: 0.148.2 CGO_ENABLED: 0 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Generate DNS docs run: make generate-dns - name: Install Hugo run: | wget -O /tmp/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-amd64.deb sudo dpkg -i /tmp/hugo.deb - name: Build Documentation run: make docs-build # https://github.com/marketplace/actions/github-pages - name: Deploy to GitHub Pages uses: crazy-max/ghaction-github-pages@v4 with: target_branch: gh-pages build_dir: docs/public env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/go-cross.yml ================================================ name: Go Matrix on: push: branches: - master pull_request: jobs: cross: name: Go runs-on: ${{ matrix.os }} env: CGO_ENABLED: 0 strategy: matrix: go-version: [ oldstable, stable ] os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: Test run: go test -v -cover ./... - name: Build run: go build -v -ldflags "-s -w" -trimpath -o ./dist/lego ./cmd/lego/ ================================================ FILE: .github/workflows/pr.yml ================================================ name: Main on: push: branches: - master pull_request: jobs: main: name: Main Process runs-on: ubuntu-latest env: GO_VERSION: stable GOLANGCI_LINT_VERSION: v2.10 HUGO_VERSION: 0.148.2 CGO_ENABLED: 0 LEGO_E2E_TESTS: CI MEMCACHED_HOSTS: localhost:11211 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Check and get dependencies run: | go mod tidy --diff - name: Generate and Check generated elements run: | make generate-dns git diff --exit-code - uses: golangci/golangci-lint-action@v9 with: version: ${{ env.GOLANGCI_LINT_VERSION }} install-only: true - name: Install Pebble run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.9.0 - name: Install challtestsrv run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.9.0 - name: Set up a Memcached server run: docker run -d --rm -p 11211:11211 memcached:1.6-alpine - name: Make run: | make make clean - name: Install Hugo run: | wget -O /tmp/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-amd64.deb sudo dpkg -i /tmp/hugo.deb - name: Build Documentation run: make docs-build ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - v* permissions: # Allow the workflow to write attestations. id-token: write attestations: write jobs: release: name: Release version runs-on: ubuntu-latest env: GO_VERSION: stable CGO_ENABLED: 0 steps: # temporary workaround for an error in free disk space action # https://github.com/jlumbroso/free-disk-space/issues/14 - name: Update Package List and Remove Dotnet run: | sudo apt-get update sudo apt-get remove -y '^dotnet-.*' # https://github.com/marketplace/actions/free-disk-space-ubuntu - name: Free Disk Space uses: jlumbroso/free-disk-space@main with: # this might remove tools that are actually needed tool-cache: false # all of these default to true android: true dotnet: true haskell: true large-packages: true docker-images: true swap-storage: false - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Docker Login env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin - name: Install snapcraft run: sudo snap install snapcraft --classic - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 # https://goreleaser.com/ci/actions/ - name: Run GoReleaser id: goreleaser uses: goreleaser/goreleaser-action@v6 with: version: v2.13.0 args: release -p 1 --clean --timeout=90m env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN_REPO }} SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} AUR_KEY: ${{ secrets.AUR_KEY }} - uses: actions/attest-build-provenance@v3 with: subject-checksums: ./dist/lego_${{ fromJSON(steps.goreleaser.outputs.metadata).version }}_checksums.txt github-token: ${{ secrets.GH_TOKEN_REPO }} - uses: actions/attest-build-provenance@v3 with: subject-checksums: ./dist/digests.txt github-token: ${{ secrets.GH_TOKEN_REPO }} ================================================ FILE: .gitignore ================================================ .lego .gitcookies .idea .vscode/ dist/ builds/ ================================================ FILE: .golangci.yml ================================================ version: "2" formatters: enable: - gci - gofmt - gofumpt - goimports settings: gofumpt: extra-rules: true gofmt: rewrite-rules: - pattern: 'interface{}' replacement: 'any' linters: default: all disable: - wsl # Deprecated - bodyclose - canonicalheader - contextcheck - cyclop # duplicate of gocyclo - dupl # not relevant - err113 # not relevant - errchkjson - errname - exhaustive # not relevant - exhaustruct # not relevant - forbidigo - forcetypeassert - gosec - gosmopolitan # not relevant - ireturn # not relevant - lll - makezero # not relevant - mnd - musttag # false-positive https://github.com/junk1tm/musttag/issues/17 - nestif # too many false-positive - nilnil # not relevant - nlreturn # not relevant - noctx - noinlineerr # too strict - nonamedreturns - paralleltest # not relevant - prealloc # too many false-positive - rowserrcheck # not relevant (SQL) - sqlclosecheck # not relevant (SQL) - tagliatelle - testpackage # not relevant - tparallel # not relevant - varnamelen # not relevant - wrapcheck settings: depguard: rules: main: deny: - pkg: github.com/instana/testify desc: not allowed - pkg: github.com/pkg/errors desc: Should be replaced by standard lib errors package funlen: lines: -1 statements: 50 goconst: min-len: 3 min-occurrences: 3 gocritic: disabled-checks: - paramTypeCombine # already handle by gofumpt.extra-rules - whyNoLint # already handle by nonolint - unnamedResult - hugeParam - sloppyReassign - rangeValCopy - octalLiteral - ptrToRefParam - appendAssign - ruleguard - httpNoBody - exposedSyncMutex enabled-tags: - diagnostic - style - performance gocyclo: min-complexity: 12 godox: keywords: - FIXME govet: disable: - fieldalignment enable-all: true settings: printf: funcs: - Print - Printf - Warn - Warnf - Fatal - Fatalf misspell: locale: US ignore-rules: - internetbs perfsprint: err-error: true errorf: true sprintf1: true strconcat: false revive: rules: - name: struct-tag - name: blank-imports - name: context-as-argument - name: context-keys-type - name: dot-imports - name: error-return - name: error-strings - name: error-naming - name: exported disabled: true - name: if-return - name: increment-decrement - name: var-naming - name: var-declaration - name: package-comments disabled: true - name: range - name: receiver-naming - name: time-naming - name: unexported-return - name: indent-error-flow - name: errorf - name: empty-block - name: superfluous-else - name: unused-parameter disabled: true - name: unreachable-code - name: redefines-builtin-id tagalign: align: false order: - xml - json - yaml - yml - toml - mapstructure - url testifylint: disable: - require-error - go-require usetesting: os-setenv: false # we already have a test "framework" to handle env vars funcorder: struct-method: false exclusions: warn-unused: true presets: - comments - std-error-handling paths: # Those elements are related to code borrowed from the official HuaweiCloud API client. - providers/dns/huaweicloud/internal rules: - path: (.+)_test.go linters: - funlen - goconst - maintidx - path: (.+)_test.go text: Error return value of `fmt.Fprintln` is not checked linters: - errcheck - text: "var-naming: avoid meaningless package names" linters: - revive - text: "var-naming: avoid package names that conflict with Go standard library package names" linters: - revive - path: certcrypto/crypto.go text: (tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable linters: - gochecknoglobals - path: challenge/dns01/nameserver.go text: (defaultNameservers|recursiveNameservers|fqdnSoaCache|muFqdnSoaCache) is a global variable linters: - gochecknoglobals - path: challenge/dns01/nameserver_.+.go text: dnsTimeout is a global variable linters: - gochecknoglobals - path: challenge/dns01/precheck.go text: defaultNameserverPort is a global variable linters: - gochecknoglobals - path: challenge/http01/domain_matcher.go text: cyclomatic complexity \d+ of func `parseForwardedHeader` is high linters: - gocyclo - path: challenge/http01/domain_matcher.go text: Function 'parseForwardedHeader' has too many statements linters: - funlen - path: challenge/tlsalpn01/tls_alpn_challenge.go text: idPeAcmeIdentifierV1 is a global variable linters: - gochecknoglobals - path: log/logger.go text: Logger is a global variable linters: - gochecknoglobals - path: e2e/(dnschallenge/)?[\d\w]+_test.go text: load is a global variable linters: - gochecknoglobals - path: providers/(dns|http)/([\d\w]+/)*[\d\w]+_test.go text: envTest is a global variable linters: - gochecknoglobals - path: providers/dns/namecheap/namecheap_test.go text: testCases is a global variable linters: - gochecknoglobals - path: providers/dns/namecheap/transport.go text: (envProxyOnce|envProxyFuncValue) is a global variable linters: - gochecknoglobals - path: providers/dns/acmedns/mock_test.go text: egTestAccount is a global variable linters: - gochecknoglobals - path: providers/http/memcached/memcached_test.go text: memcachedHosts is a global variable linters: - gochecknoglobals - path: providers/dns/checkdomain/internal/types.go text: '`payed` is a misspelling of `paid`' linters: - misspell - path: platform/tester/env_test.go linters: - thelper - path: providers/dns/oraclecloud/oraclecloud_test.go text: 'SA1019: x509.EncryptPEMBlock has been deprecated since Go 1.16' linters: - staticcheck - path: providers/dns/sakuracloud/wrapper.go text: mu is a global variable linters: - gochecknoglobals - path: cmd/cmd_renew.go text: cyclomatic complexity \d+ of func `(renewForDomains|renewForCSR)` is high linters: - gocyclo - path: cmd/cmd_renew.go text: Function 'renewForDomains' has too many statements linters: - funlen - path: providers/dns/cpanel/cpanel.go text: cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high linters: - gocyclo - path: providers/dns/manual/manual.go text: 'SA1019: dns01.DNSProviderManual is deprecated' linters: - staticcheck # Those elements have been replaced by non-exposed structures. - path: providers/dns/linode/linode_test.go text: 'SA1019: linodego\.(DomainsPagedResponse|DomainRecordsPagedResponse) is deprecated' linters: - staticcheck issues: max-issues-per-linter: 0 max-same-issues: 0 ================================================ FILE: .goreleaser.yml ================================================ version: 2 project_name: lego builds: - binary: lego main: ./cmd/lego/ env: - CGO_ENABLED=0 flags: - -trimpath ldflags: - -s -w -X main.version={{.Version}} goos: - linux - darwin - windows - freebsd - openbsd - solaris goarch: - amd64 - 386 - arm - arm64 - mips - mipsle - mips64 - mips64le goarm: - 7 - 6 - 5 gomips: - hardfloat - softfloat ignore: - goos: darwin goarch: 386 - goos: openbsd goarch: arm # Deprecated in go1.25, Removed in go1.26 # https://go.dev/doc/go1.25#windows - goos: windows goarch: arm changelog: sort: asc filters: exclude: - '(?i)^chore:' - '(?i)^Detach v[\d|.]+' - '(?i)^Prepare release v[\d|.]+' release: skip_upload: false github: owner: 'go-acme' name: 'lego' header: | lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ Everybody thinks that the others will donate, but in the end, nobody does. So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev). For key updates, see the [changelog](https://github.com/go-acme/lego/blob/HEAD/CHANGELOG.md#v{{ .Major }}{{ .Minor }}{{ .Patch }}). archives: - id: lego name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}' formats: ['tar.gz'] format_overrides: - goos: windows formats: ['zip'] files: - LICENSE - CHANGELOG.md dockers_v2: - images: - 'goacme/lego' dockerfile: buildx.Dockerfile platforms: - linux/amd64 - linux/arm64 - linux/arm/v7 tags: - 'latest' - 'v{{ .Major }}' - 'v{{ .Major }}.{{ .Minor }}' - '{{ .Tag }}' labels: # https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys 'org.opencontainers.image.title': '{{.ProjectName}}' 'org.opencontainers.image.description': 'Lets Encrypt/ACME client and library written in Go' 'org.opencontainers.image.source': '{{.GitURL}}' 'org.opencontainers.image.url': '{{.GitURL}}' 'org.opencontainers.image.documentation': 'https://go-acme.github.io/lego' 'org.opencontainers.image.created': '{{.Date}}' 'org.opencontainers.image.revision': '{{.FullCommit}}' 'org.opencontainers.image.version': '{{.Version}}' snapcrafts: - name_template: "{{ .ProjectName }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" disable: false publish: true grade: stable confinement: strict license: MIT base: core22 summary: Lego is a Let's Encrypt/ACME client. description: | Lego is a Let's Encrypt/ACME client written in Go. The lego snap makes it easy to install and use Lego on any Linux distribution that supports snaps. Usage: * `sudo snap install lego` * `sudo lego --email="you@example.com" --domains="example.com" --server=https://acme-staging-v02.api.letsencrypt.org/directory --http --http.port :8080 run apps: lego: command: lego environment: LEGO_PATH: /var/snap/lego/common/.lego plugs: - network-bind aurs: - description: "Let s Encrypt client and ACME library written in Go" skip_upload: false homepage: https://go-acme.github.io/lego/ name: 'lego-bin' provides: - lego maintainers: - "Fernandez Ludovic " license: APACHE private_key: "{{ .Env.AUR_KEY }}" git_url: "ssh://aur@aur.archlinux.org/lego-bin.git" commit_author: name: ldez email: ldez@users.noreply.github.com package: |- # Bin install -Dm755 "./lego" "${pkgdir}/usr/bin/lego" # License install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/lego/LICENSE" ================================================ FILE: CHANGELOG.md ================================================ # Changelog lego is an independent, free, open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ Everybody thinks that the others will donate, but in the end, nobody does. So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev). ## v4.33.0 - Release date: 2026-03-19 - Tag: [v4.33.0](https://github.com/go-acme/lego/releases/tag/v4.33.0) ### Added - **[dnsprovider]** Add DNS provider for Excedo - **[dnsprovider]** Add DNS provider for EuroDNS - **[dnsprovider]** Add DNS provider for Czechia ### Changed - **[lib]** feat: allow to Unwrap obtainError ### Fixed - **[dnsprovider]** liara: add support for team ID - **[dnsprovider]** gigahostno: remove unused Zone fields ## v4.32.0 - Release date: 2026-02-19 - Tag: [v4.32.0](https://github.com/go-acme/lego/releases/tag/v4.32.0) ### Added - **[dnsprovider]** Add DNS provider for ArtFiles - **[dnsprovider]** Add DNS provider for Leaseweb - **[dnsprovider]** Add DNS provider for FusionLayer NameSurfer - **[dnsprovider]** Add DNS provider for DDNSS - **[dnsprovider]** Add DNS provider for Bluecat v2 - **[dnsprovider]** Add DNS provider for TodayNIC/时代互联 - **[dnsprovider]** Add DNS provider for DNSExit - **[dnsprovider]** alidns: add line record option ### Changed - **[dnsprovider]** azure: reinforces deprecation - **[dnsprovider]** allinkl: detect zone through API ### Fixed - **[ari]** fix: implement parsing for Retry-After header according to RFC 7231 - **[dnsprovider]** namesurfer: fix updateDNSHost - **[dnsprovider]** timewebcloud: fix subdomain support - **[dnsprovider]** fix: deduplicate authz for DNS01 challenge - **[lib,cli]** fix: use IPs to define the main domain - **[lib]** fix: preserve domain order ## v4.31.0 - Release date: 2026-01-08 - Tag: [v4.31.0](https://github.com/go-acme/lego/releases/tag/v4.31.0) ### Added - **[dnsprovider]** Add DNS provider for ISPConfig - **[dnsprovider]** Add DNS Provider for ISPConfig (DDNS Module) - **[dnsprovider]** Add DNS provider for Alwaysdata - **[dnsprovider]** Add DNS provider for JDCloud - **[dnsprovider]** Add DNS provider for 35.com/三五互联 - **[dnsprovider]** f5xc: add an option to configure the domain of the server ### Changed - **[lib]** feat: improve ACME error types - **[dnsprovider,cname]** namedotcom: follow CNAME ### Fixed - **[dnsprovider]** hetzner: fix compatibility with _FILE suffix - **[dnsprovider]** gandiv5: fix API Key header ## v4.30.1 - Release date: 2025-12-16 - Tag: [v4.30.1](https://github.com/go-acme/lego/releases/tag/v4.30.1) Due to an error related to `aliyun/credentials-go`, some artifacts of the v4.30.0 release have not been published. This release contains the same things as v4.30.0. ## v4.30.0 - Release date: 2025-12-16 - Tag: [v4.30.0](https://github.com/go-acme/lego/releases/tag/v4.30.0) ### Added - **[dnsprovider]** Add DNS provider for Ionos Cloud - **[dnsprovider]** Add DNS provider for Virtualname - **[dnsprovider]** Add DNS Provider for Neodigit - **[dnsprovider]** Add DNS provider for Syse.no - **[dnsprovider]** Add DNS provider for Gravity - **[dnsprovider]** Add DNS provider for hosting.nl ### Changed - **[cli]** feat: remove email requirement ### Fixed - **[dnsprovider]** autodns: use the right response structure ## v4.29.0 - Release date: 2025-11-29 - Tag: [v4.29.0](https://github.com/go-acme/lego/releases/tag/v4.29.0) ### Added - **[dnsprovider]** Add DNS provider for United-Domains - **[dnsprovider]** Add DNS provider for Gigahost.no - **[dnsprovider]** Add DNS provider for EdgeCenter - **[dnsprovider]** Add DNS provider for AlibabaCloud ESA - **[dnsprovider]** edgeone: add zones mapping - **[dnsprovider]** namecheap: add experimental proxy support ### Changed - **[dnsprovider]** gandiv5: update base API URL ### Fixed - **[dnsprovider]** hetzner: use int64 for IDs - **[dnsprovider]** baiducloud: pagination and TTL - **[dnsprovider]** inwx: fix API breaking changes with record IDs ## v4.28.1 - Release date: 2025-11-06 - Tag: [v4.28.1](https://github.com/go-acme/lego/releases/tag/v4.28.1) ### Fixed - **[cli]** fix: skip nil response ## v4.28.0 - Release date: 2025-10-31 - Tag: [v4.28.0](https://github.com/go-acme/lego/releases/tag/v4.28.0) ### Added - **[dnsprovider]** Add DNS provider for Anexia - **[dnsprovider]** Add DNS provider for webnames.ca - **[dnsprovider]** webnames: rename to webnamesru to avoid ambiguity with webnamesca ### Changed - **[dnsprovider,log]** hetzner: add deprecation logs - **[dnsprovider]** iwantmyname: provider deprecation - **[cli]** improve retryable HTTP client error handling ### Fixed - **[dnsprovider]** hostinger: fix record update ## v4.27.0 - Release date: 2025-10-17 - Tag: [v4.27.0](https://github.com/go-acme/lego/releases/tag/v4.27.0) ### Added - **[dnsprovider]** Add DNS provider for Octenium - **[dnsprovider]** Add DNS provider for Hostinger - **[dnsprovider]** Add DNS provider for Beget.com ### Changed - **[cli]** support `--private-key` with a PKCS#8 keypair - **[dnsprovider]** hetzner: update to new API - **[dnsprovider]** otc: adds option to use private zone ### Fixed - **[lib]** fix: deduplicate order identifiers ## v4.26.0 - Release date: 2025-09-13 - Tag: [v4.26.0](https://github.com/go-acme/lego/releases/tag/v4.26.0) ### Added - **[dnsprovider]** Add DNS provider for KeyHelp - **[dnsprovider]** Add DNS provider for Binary Lane - **[dnsprovider]** Add DNS provider for Tencent EdgeOne - **[dnsprovider]** azuredns: pipeline credential support - **[dnsprovider]** oraclecloud: handle instance_principal authentication ### Changed - **[dnsprovider]** oraclecloud: add env var aliases - **[dnsprovider]** simply: update to API v2 - **[lib,cli]** EAB: fallback to base64.URLEncoding ### Fixed - **[dnsprovider]** selectelv2: add missing options ## v4.25.2 - Release date: 2025-08-06 - Tag: [v4.25.2](https://github.com/go-acme/lego/releases/tag/v4.25.2) ### Changed - **[cli,log]** log when dynamic renew date not yet reached ### Fixed - **[cli]** fix: remove wrong env var - **[lib,cli]** fix: enforce HTTPS to the ACME server ## v4.25.1 - Release date: 2025-07-21 - Tag: [v4.25.1](https://github.com/go-acme/lego/releases/tag/v4.25.1) ### Fixed - **[cli]** fix: wrong CLI flag type ## v4.25.0 - Release date: 2025-07-21 - Tag: [v4.25.0](https://github.com/go-acme/lego/releases/tag/v4.25.0) The binary size of this release is about ~50% smaller compared to previous releases. This will also reduce the module cache usage by 320 MB (this will only affect users of lego as a library or who build lego themselves). ### Added - **[dnsprovider]** Add DNS provider for ZoneEdit - **[cli]** Add an option to define dynamically the renew date - **[lib,cli]** Add an option to disable common name in CSR ### Changed - **[dnsprovider]** vinyldns: add an option to add quotes around the TXT record value - **[dnsprovider]** ionos: increase default propagation timeout ### Fixed - **[cli]** fix: enforce domain into renewal command ## v4.24.0 - Release date: 2025-07-07 - Tag: [v4.24.0](https://github.com/go-acme/lego/releases/tag/v4.24.0) ### Added - **[dnsprovider]** Add DNS provider for Azion - **[dnsprovider]** Add DNS provider for DynDnsFree.de - **[dnsprovider]** Add DNS provider for ConoHa v3 - **[dnsprovider]** Add DNS provider for RU Center - **[dnsprovider]** gcloud: add service account impersonation ### Changed - **[dnsprovider]** pdns: improve error messages - **[dnsprovider]** cloudflare: add quotation marks to TXT record - **[dnsprovider]** googledomains: provider deprecation - **[dnsprovider]** mijnhost: improve record filter ### Fixed - **[dnsprovider]** exoscale: fix find record - **[dnsprovider]** nicmanager: fix mode env var name and value - **[lib,cli]** Check order identifiers difference between client and server ## v4.23.1 - Release date: 2025-04-16 - Tag: [v4.23.1](https://github.com/go-acme/lego/releases/tag/v4.23.1) Due to an error related to Snapcraft, some artifacts of the v4.23.0 release have not been published. This release contains the same things as v4.23.0. ## v4.23.0 - Release date: 2025-04-16 - Tag: [v4.23.0](https://github.com/go-acme/lego/releases/tag/v4.23.0) ### Added - **[dnsprovider]** Add DNS provider for Active24 - **[dnsprovider]** Add DNS provider for BookMyName - **[dnsprovider]** Add DNS provider for Axelname - **[dnsprovider]** Add DNS provider for Baidu Cloud - **[dnsprovider]** Add DNS provider for Metaregistrar - **[dnsprovider]** Add DNS provider for F5 XC - **[dnsprovider]** Add INFOBLOX_CA_CERTIFICATE option - **[dnsprovider]** route53: adds option to use private zone - **[dnsprovider]** edgedns: add account switch key option - **[dnsprovider]** infoblox: update API client to v2 - **[lib,cli]** Add delay option for TLSALPN challenge ### Changed - **[dnsprovider]** designate: speed up API requests by using filters - **[dnsprovider]** cloudflare: make base URL configurable - **[dnsprovider]** websupport: migrate to API v2 - **[dnsprovider]** dnssimple: use GetZone ### Fixed - **[ari]** Fix retry on `alreadyReplaced` error - **[cli,log]** Fix malformed log messages - **[cli]** Kill hook when the command is stuck - **[dnsprovider]** pdns: fix TXT record cleanup for wildcard domains - **[dnsprovider]** allinkl: remove `ReturnInfo` ## v4.22.2 - Release date: 2025-02-17 - Tag: [v4.22.2](https://github.com/go-acme/lego/releases/tag/v4.22.2) ### Fixed - **[dnsprovider]** acme-dns: use new registred account ## v4.22.1 - Release date: 2025-02-17 - Tag: [v4.22.1](https://github.com/go-acme/lego/releases/tag/v4.22.1) ### Fixed - **[dnsprovider]** acme-dns: continue the process when the CNAME is handled by the storage ### Added ## v4.22.0 - Release date: 2025-02-17 - Tag: [v4.22.0](https://github.com/go-acme/lego/releases/tag/v4.22.0) ### Added - **[cli]** Add `--private-key` flag to set the private key. - **[cli]** Add `LEGO_DEBUG_ACME_HTTP_CLIENT` environment variable to debug the calls to the ACME server. - **[cli]** Add `LEGO_EMAIL` environment variable for specifying email. - **[cli]** Add `--hook-timeout` flag to run and renew commands. - **[dnsprovider]** Add DNS provider for myaddr.{tools,dev,io} - **[dnsprovider]** Add DNS provider for Spaceship - **[dnsprovider]** acme-dns: add HTTP storage - **[lib,cli,httpprovider]** Add `--http.delay` option for HTTP challenge. - **[lib,cli,profiles]** Add support for Profiles Extension. - **[lib]** Add an option to set CSR email addresses ### Changed - **[lib]** rewrite status management - **[dnsprovider]** docs: improve units and default values ### Removed - **[dnsprovider]** netcup: remove TTL option ### Fixed - **[cli,log]** remove extra debug logs ## v4.21.0 - Release date: 2024-12-20 - Tag: [v4.21.0](https://github.com/go-acme/lego/releases/tag/v4.21.0) ### Added - **[dnsprovider]** Add DNS provider for Rainyun/雨云 - **[dnsprovider]** Add DNS provider for West.cn/西部数码 - **[dnsprovider]** Add DNS provider for ManageEngine CloudDNS - **[cli]** feat: add --force-cert-domains flag to renew ### Fixed - **[cli]** create client only when needed - **[cli]** clone the transport with tls-skip-verify - **[cli]** use retryable client for ACME server calls - **[dnsprovider]** bunny: fix zone detection - **[dnsprovider]** inwx: delete only the TXT record related to the DNS challenge - **[dnsprovider]** infomaniak: increase default propagation timeout - **[dnsprovider]** dnsmadeeasy: use default transport - **[dnsprovider]** netcup: increase default propagation values - **[dnsprovider]** otc: use default transport ## v4.20.4 - Release date: 2024-11-21 - Tag: [v4.20.4](https://github.com/go-acme/lego/releases/tag/v4.20.4) Publish the Snap to the Snapcraft stable channel. ## v4.20.3 - Release date: 2024-11-21 - Tag: [v4.20.3](https://github.com/go-acme/lego/releases/tag/v4.20.3) ### Fixed - **[dnsprovider]** technitium: fix status code handling - **[dnsprovider]** directadmin: fix timeout configuration - **[httpprovider]** fix: HTTP server IPv6 matching ## v4.20.2 - Release date: 2024-11-11 - Tag: [v4.20.2](https://github.com/go-acme/lego/releases/tag/v4.20.2) ### Added - **[dnsprovider]** Add DNS provider for Technitium - **[dnsprovider]** Add DNS provider for Regfish - **[dnsprovider]** Add DNS provider for Timeweb Cloud - **[dnsprovider]** Add DNS provider for Volcano Engine - **[dnsprovider]** Add DNS provider for Core-Networks - **[dnsprovider]** rfc2136: add support for tsig-keygen generated file - **[cli]** Add option to skip the TLS verification of the ACME server - Add documentation for env var only options ### Changed - **[cli,ari]** Attempt to check ARI unless explicitly disabled - **[dnsprovider]** Improve propagation check error messages - **[dnsprovider]** cloudxns: provider deprecation - **[dnsprovider]** brandit: provider deprecation ### Fixed - **[dnsprovider]** regru: update authentication method - **[dnsprovider]** selectelv2: fix non-ASCII domain - **[dnsprovider]** limacity: fix error message - **[dnsprovider]** volcengine: set API information within the default configuration - **[log]** Parse printf verbs in log line output ## v4.20.1 - Release date: 2024-11-11 Cancelled due to CI failure. ## v4.20.0 - Release date: 2024-11-11 Cancelled due to CI failure. ## v4.19.2 - Release date: 2024-10-06 - Tag: [v4.19.2](https://github.com/go-acme/lego/releases/tag/v4.19.2) ### Fixed - **[lib]** go1.22 compatibility ## v4.19.1 - Release date: 2024-10-06 - Tag: [v4.19.1](https://github.com/go-acme/lego/releases/tag/v4.19.1) ### Fixed - **[dnsprovider]** selectelv2: use baseURL from configuration - **[dnsprovider]** epik: add User-Agent ## v4.19.0 - Release date: 2024-10-03 - Tag: [v4.19.0](https://github.com/go-acme/lego/releases/tag/v4.19.0) ### Added - **[dnsprovider]** Add DNS provider for HuaweiCloud - **[dnsprovider]** Add DNS provider for SelfHost.(de|eu) - **[lib,cli,dnsprovider]** Add `dns.propagation-rns` option - **[cli,dnsprovider]** Add `dns.propagation-wait` flag - **[lib,dnsprovider]** Add `PropagationWait` function ### Changed - **[dnsprovider]** ionos: follow CNAME - **[lib,dnsprovider]** Reducing the lock strength of the soa cache entry - **[lib,cli,dnsprovider]** Deprecation of `dns.disable-cp`, replaced by `dns.propagation-disable-ans`. ### Fixed - **[dnsprovider]** Use UTC instead of GMT when possible - **[dnsprovider]** namesilo: restrict CleanUp - **[dnsprovider]** godaddy: fix cleanup ## v4.18.0 - Release date: 2024-08-30 - Tag: [v4.18.0](https://github.com/go-acme/lego/releases/tag/v4.18.0) ### Added - **[dnsprovider]** Add DNS provider for mijn.host - **[dnsprovider]** Add DNS provider for Lima-City - **[dnsprovider]** Add DNS provider for DirectAdmin - **[dnsprovider]** Add DNS provider for Mittwald - **[lib,cli]** feat: add option to handle the overall request limit - **[lib]** feat: expose certificates pool creation ### Changed - **[cli]** feat: add LEGO_ISSUER_CERT_PATH to run hook - **[dnsprovider]** bluecat: skip deploy - **[dnsprovider]** ovh: allow to use ovh.conf file - **[dnsprovider]** designate: allow manually overwriting DNS zone ### Fixed - **[ari]** fix: avoid Int63n panic in ShouldRenewAt() ## v4.17.4 - Release date: 2024-06-12 - Tag: [v4.17.4](https://github.com/go-acme/lego/releases/tag/v4.17.4) ### Fixed - **[dnsprovider]** Update dependencies ## v4.17.3 - Release date: 2024-05-28 - Tag: [v4.17.3](https://github.com/go-acme/lego/releases/tag/v4.17.3) ### Added - **[dnsprovider]** Add DNS provider for Selectel v2 - **[dnsprovider]** route53: adds option to not wait for changes - **[dnsprovider]** ovh: add OAuth2 authentication - **[dnsprovider]** azuredns: use TenantID also for cli authentication - **[dnsprovider]** godaddy: documentation about new API limitations - **[cli]** feat: add LEGO_ISSUER_CERT_PATH to hook ### Changed - **[dnsprovider]** dode: update API URL - **[dnsprovider]** exec: stream command output - **[dnsprovider]** oracle: update API client - **[dnsprovider]** azuredns: servicediscovery for zones - **[dnsprovider]** scaleway: add alternative env var names - **[dnsprovider]** exoscale: simplify record creation - **[dnsprovider]** httpnet: add provider to NewDNSChallengeProviderByName - **[cli]** feat: fills LEGO_CERT_PFX_PATH and LEGO_CERT_PEM_PATH only when needed - **[lib,ari]** feat: renewal retry after value ### Fixed - **[dnsprovider]** pdns: reconstruct zone URLs to enable non-root folder API endpoints - **[dnsprovider]** alidns: fix link to API documentation ## v4.17.2 - Release date: 2024-05-28 Canceled due to a release failure related to Snapcraft. The Snapcraft release are disabled for now. ## v4.17.1 - Release date: 2024-05-28 Canceled due to a release failure related to oci-go-sdk. The module `github.com/oracle/oci-go-sdk/v65` uses `github.com/gofrs/flock` but flock doesn't support some platform (like Solaris): - https://github.com/gofrs/flock/issues/60 Due to that we will remove the Solaris build. ## v4.17.0 - Release date: 2024-05-28 Canceled due to a release failure related to Snapcraft. ## v4.16.1 - Release date: 2024-03-10 - Tag: [v4.16.1](https://github.com/go-acme/lego/releases/tag/v4.16.1) ### Fixed - **[cli,ari]** fix: don't generate ARI cert ID if ARI is not enable ## v4.16.0 - Release date: 2024-03-09 - Tag: [v4.16.0](https://github.com/go-acme/lego/releases/tag/v4.16.0) ### Added - **[dnsprovider]** Add DNS provider for Shellrent - **[dnsprovider]** Add DNS provider for Mail-in-a-Box - **[dnsprovider]** Add DNS provider for CPanel and WHM ### Changed - **[lib,ari]** Implement 'replaces' field in newOrder and draft-ietf-acme-ari-03 CertID changes - **[log]** feat: improve errors and logs related to DNS call - **[lib]** update to go-jose/go-jose/v4 v4.0.1 ### Fixed - **[dnsprovider]** nifcloud: fix bug in case of same auth zone - **[dnsprovider]** bunny: Support delegated subdomains - **[dnsprovider]** easydns: fix zone detection - **[dnsprovider]** ns1: fix record creation ## v4.15.0 - Release date: 2024-01-28 - Tag: [v4.15.0](https://github.com/go-acme/lego/releases/tag/v4.15.0) ### Added - **[dnsprovider]** Add DNS provider for http.net - **[dnsprovider]** Add DNS provider for Webnames ### Changed - **[cli]** Add environment variable for specifying alternate directory URL - **[cli]** Add format option for PFX encoding - **[lib]** Support simplified issuance for very long domain names at Let's Encrypt - **[lib]** Update CertID format as per draft-ietf-acme-ari-02 - **[dnsprovider]** azuredns: allow OIDC authentication - **[dnsprovider]** azuredns: provide the ability to select authentication methods - **[dnsprovider]** efficientip: add insecure skip verify option - **[dnsprovider]** gandiv5: add Personal Access Token support - **[dnsprovider]** gcloud: support GCE_ZONE_ID to bypass zone list - **[dnsprovider]** liquidweb: add LWAPI_ prefix for env vars - **[dnsprovider]** liquidweb: detect zone automatically - **[dnsprovider]** pdns: optional custom API version - **[dnsprovider]** regru: client certificate support - **[dnsprovider]** regru: HTTP method changed to POST - **[dnsprovider]** scaleway: add cname support ### Fixed - **[dnsprovider]** cloudru: change default URLs - **[dnsprovider]** constellix: follow rate limiting headers - **[dnsprovider]** desec: increase default propagation interval - **[dnsprovider]** gandiv5: Add "Bearer" prefix to the auth header - **[dnsprovider]** inwx: improve sleep calculation - **[dnsprovider]** inwx: wait before generating new TOTP TANs - **[dnsprovider]** ionos: fix DNS record removal - **[dnsprovider]** ipv64: remove unused option - **[dnsprovider]** nifcloud: fix API requests - **[dnsprovider]** otc: sequential challenge ## v4.14.1 - Release date: 2023-09-20 - Tag: [v4.14.1](https://github.com/go-acme/lego/releases/tag/v4.14.1) ### Fixed - **[dnsprovider]** bunny: fix zone detection - **[dnsprovider]** bunny: use NRDCG fork - **[dnsprovider]** ovh: update client to v1.4.2 ## v4.14.1 - Release date: 2023-09-19 Cancelled due to CI failure. ## v4.14.0 - Release date: 2023-08-20 - Tag: [v4.14.0](https://github.com/go-acme/lego/releases/tag/v4.14.0) ### Added - **[dnsprovider]** Add DNS provider for Yandex 360 - **[dnsprovider]** Add DNS provider for cloud.ru - **[httpprovider]** Adding S3 support for HTTP domain validation ### Changed - **[cli]** Allow to set EAB kid and hmac via environment variables - **[dnsprovider]** Migrate to aws-sdk-go-v2 (lightsail, route53) ### Fixed - **[dnsprovider]** nearlyfreespeech: fix authentication - **[dnsprovider]** pdns: fix notify - **[dnsprovider]** route53: avoid unexpected records deletion ## v4.13.3 - Release date: 2023-07-25 - Tag: [v4.13.3](https://github.com/go-acme/lego/releases/tag/v4.13.3) ### Fixed - **[dnsprovider]** azuredns: fix configuration from env vars - **[dnsprovider]** gcore: change API domain ## v4.13.2 - Release date: 2023-07-21 - Tag: [v4.13.2](https://github.com/go-acme/lego/releases/tag/v4.13.2) ### Fixed - **[dnsprovider]** servercow: fix regression ## v4.13.1 - Release date: 2023-07-20 - Tag: [v4.13.1](https://github.com/go-acme/lego/releases/tag/v4.13.1) ### Added - **[dnsprovider]** Add DNS provider for IPv64 - **[dnsprovider]** Add DNS provider for Metaname - **[dnsprovider]** Add DNS provider for RcodeZero - **[dnsprovider]** Add DNS provider for Efficient IP - **[dnsprovider]** azure: new implementation based on the new API client - **[lib]** Experimental option to force DNS queries to use TCP ### Changed - **[dnsprovider]** cloudflare: update api client to v0.70.0 ### Fixed - **[dnsprovider,cname]** fix: ensure case-insensitive comparison of CNAME records - **[cli]** fix: list command - **[lib]** fix: ARI explanationURL ## v4.13.0 - Release date: 2023-07-20 Cancelled due to a CI issue (no space left on device). ## v4.12.2 - Release date: 2023-06-19 - Tag: [v4.12.2](https://github.com/go-acme/lego/releases/tag/v4.12.2) ### Fixed - **[dnsprovider]** dnsmadeeasy: fix DeleteRecord - **[lib]** fix: read status code from response ## v4.12.1 - Release date: 2023-06-06 - Tag: [v4.12.1](https://github.com/go-acme/lego/releases/tag/v4.12.1) ### Fixed - **[dnsprovider]** pdns: fix record value ## v4.12.0 - Release date: 2023-05-28 - Tag: [v4.12.0](https://github.com/go-acme/lego/releases/tag/v4.12.0) ### Added - **[lib,cli]** Initial ACME Renewal Info (ARI) Implementation - **[dnsprovider]** Add DNS provider for Derak Cloud - **[dnsprovider]** route53: pass ExternalID property to STS:AssumeRole API operation - **[lib,cli]** Support custom duration for certificate ### Changed - **[dnsprovider]** Refactor DNS provider and client implementations ### Fixed - **[dnsprovider]** autodns: fixes wrong zone in api call if CNAME is used - **[cli]** fix: archive only domain-related files on revoke ## v4.11.0 - Release date: 2023-05-02 - Tag: [v4.11.0](https://github.com/go-acme/lego/releases/tag/v4.11.0) ### Added - **[lib]** Support for certificate with raw IP SAN (RFC8738) - **[dnsprovider]** Add Brandit.com as DNS provider - **[dnsprovider]** Add DNS provider for Bunny - **[dnsprovider]** Add DNS provider for Nodion - **[dnsprovider]** Add Google Domains as DNS provider - **[dnsprovider]** Add DNS provider for Plesk ### Changed - **[cli]** feat: add LEGO_CERT_PEM_PATH and LEGO_CERT_PFX_PATH to run hook - **[lib,cli]** feat: add RSA 3072 - **[dnsprovider]** gcloud: update google APIs to latest version - **[lib,dnsprovider,cname]** chore: replace GetRecord by GetChallengeInfo ### Fixed - **[dnsprovider]** rimuhosting: fix API base URL ## v4.10.2 - Release date: 2023-02-26 - Tag: [v4.10.2](https://github.com/go-acme/lego/releases/tag/v4.10.2) Fix Docker image builds. ## v4.10.1 - Release date: 2023-02-25 - Tag: [v4.10.1](https://github.com/go-acme/lego/releases/tag/v4.10.1) ### Fixed - **[dnsprovider,cname]** acmedns: fix CNAME support - **[dnsprovider]** dynu: fix subdomain support ## v4.10.0 - Release date: 2023-02-10 - Tag: [v4.10.0](https://github.com/go-acme/lego/releases/tag/v4.10.0) ### Added - **[dnsprovider]** Add DNS provider for dnsHome.de - **[dnsprovider]** Add DNS provider for Liara - **[dnsprovider]** Add DNS provider for UltraDNS - **[dnsprovider]** Add DNS provider for Websupport ### Changed - **[dnsprovider]** ibmcloud: add support for subdomains - **[dnsprovider]** infomaniak: CNAME support - **[dnsprovider]** namesilo: add cleanup before add a DNS record - **[dnsprovider]** route53: Allow static credentials to be supplied - **[dnsprovider]** tencentcloud: support punycode domain ### Fixed - **[dnsprovider]** alidns: filter on record type - **[dnsprovider]** arvancloud: replace arvancloud.com by arvancloud.ir - **[dnsprovider]** hetzner: improve zone ID detection - **[dnsprovider]** luadns: removed dot suffix from authzone while searching for zone - **[dnsprovider]** pdns: fix usage of notify only when zone kind is Master or Slave - **[dnsprovider]** return an error when extracting record name ## v4.9.1 - Release date: 2022-11-25 - Tag: [v4.9.1](https://github.com/go-acme/lego/releases/tag/v4.9.1) ### Changed - **[lib,cname]** cname: add log about CNAME entries - **[dnsprovider]** regru: improve error handling ### Fixed - **[dnsprovider,cname]** fix CNAME support for multiple DNS providers - **[dnsprovider,cname]** duckdns: fix CNAME support - **[dnsprovider,cname]** oraclecloud: use fqdn to resolve zone - **[dnsprovider]** hurricane: fix CNAME support - **[lib,cname]** cname: stop trying to traverse cname if none have been found ## v4.9.0 - Release date: 2022-10-03 - Tag: [v4.9.0](https://github.com/go-acme/lego/releases/tag/v4.9.0) ### Added - **[dnsprovider]** Add DNS provider for CIVO - **[dnsprovider]** Add DNS provider for VK Cloud - **[dnsprovider]** Add DNS provider for YandexCloud - **[dnsprovider]** digitalocean: configurable base URL - **[dnsprovider]** loopia: add configurable API endpoint - **[dnsprovider]** pdns: notify secondary servers after updates ### Changed - **[dnsprovider]** allinkl: removed deprecated sha1 hashing - **[dnsprovider]** auroradns: update authentification - **[dnsprovider]** dnspod: deprecated. Use Tencent Cloud instead. - **[dnsprovider]** exoscale: migrate to API v2 endpoints - **[dnsprovider]** gcloud: update golang.org/x/oauth2 - **[dnsprovider]** lightsail: cleanup - **[dnsprovider]** sakuracloud: update api client library - **[cname]** take out CNAME support from experimental features - **[lib,cname]** add recursive CNAME lookup support - **[lib]** Remove embedded issuer certificates from issued certificate if bundle is false ### Fixed - **[dnsprovider]** luadns: fix cname support - **[dnsprovider]** njalla: fix record id unmarshal error - **[dnsprovider]** tencentcloud: fix subdomain error ## v4.8.0 - Release date: 2022-06-30 - Tag: [v4.8.0](https://github.com/go-acme/lego/releases/tag/v4.8.0) ### Added - **[dnsprovider]** Add DNS provider for Variomedia - **[dnsprovider]** Add NearlyFreeSpeech DNS Provider - **[cli]** Add a --user-agent flag to lego-cli ### Changed - new logo - **[cli]** feat: sleep at renewal - **[cli]** cli/renew: skip random sleep if stdout is a terminal - **[dnsprovider]** hetzner: set min TTL to 60s - **[docs]** refactoring and cleanup ## v4.7.0 - Release date: 2022-05-27 - Tag: [v4.7.0](https://github.com/go-acme/lego/releases/tag/v4.7.0) ### Added - **[dnsprovider]** Add DNS provider for iwantmyname - **[dnsprovider]** Add DNS Provider for IIJ DNS Platform Service - **[dnsprovider]** Add DNS provider for Vercel - **[dnsprovider]** route53: add assume role ARN - **[dnsprovider]** dnsimple: add debug option - **[cli]** feat: add `LEGO_CERT_PEM_PATH` and `LEGO_CERT_PFX_PATH` ### Changed - **[dnsprovider]** gcore: change dns api url - **[dnsprovider]** bluecat: rewrite provider implementation ### Fixed - **[dnsprovider]** rfc2136: fix TSIG secret - **[dnsprovider]** tencentcloud: fix InvalidParameter.DomainInvalid error when using DNS challenges - **[lib]** fix: panic in certcrypto.ParsePEMPrivateKey ## v4.6.0 - Release date: 2022-01-18 - Tag: [v4.6.0](https://github.com/go-acme/lego/releases/tag/v4.6.0) ### Added - **[dnsprovider]** Add DNS provider for UKFast SafeDNS - **[dnsprovider]** Add DNS Provider for Tencent Cloud - **[dnsprovider]** azure: add support for Azure Private Zone DNS - **[dnsprovider]** exec: add sequence interval - **[cli]** Add a `--pfx`, and `--pfx.pas`s option to generate a PKCS#12 (`.pfx`) file. - **[lib]** Extended support of cert pool (`LEGO_CA_CERTIFICATES` and `LEGO_CA_SYSTEM_CERT_POOL`) - **[lib,httpprovider]** added uds capability to http challenge server ### Changed - **[lib]** Extend validity of TLS-ALPN-01 certificates to 365 days - **[lib,cli]** Allows defining the reason for the certificate revocation ### Fixed - **[dnsprovider]** mythicbeasts: fix token expiration - **[dnsprovider]** rackspace: change zone ID to string ## v4.5.3 - Release date: 2021-09-06 - Tag: [v4.5.3](https://github.com/go-acme/lego/releases/tag/v4.5.3) ### Fixed - **[lib,cli]** fix: missing preferred chain param for renew request ## v4.5.2 - Release date: 2021-09-01 - Tag: [v4.5.2](https://github.com/go-acme/lego/releases/tag/v4.5.2) ### Added - **[dnsprovider]** Add DNS provider for all-inkl - **[dnsprovider]** Add DNS provider for Epik - **[dnsprovider]** Add DNS provider for freemyip.com - **[dnsprovider]** Add DNS provider for g-core labs - **[dnsprovider]** Add DNS provider for hosttech - **[dnsprovider]** Add DNS Provider for IBM Cloud (SoftLayer) - **[dnsprovider]** Add DNS provider for Internet.bs - **[dnsprovider]** Add DNS provider for nicmanager ### Changed - **[dnsprovider]** alidns: support ECS instance RAM role - **[dnsprovider]** alidns: support sts token credential - **[dnsprovider]** azure: zone name as environment variable - **[dnsprovider]** ovh: follow cname - **[lib,cli]** Add AlwaysDeactivateAuthorizations flag to ObtainRequest ### Fixed - **[dnsprovider]** infomaniak: fix subzone support - **[dnsprovider]** edgedns: fix Present and CleanUp logic - **[dnsprovider]** lightsail: wrong Region env var name - **[lib]** lib: fix backoff in SolverManager - **[lib]** lib: use permanent error instead of context cancellation - **[dnsprovider]** desec: bump to v0.6.0 ## v4.5.1 - Release date: 2021-10-01 Cancelled due to a CI issue, replaced by v4.5.2. ## v4.5.0 - Release date: 2021-09-30 Cancelled due to a CI issue, replaced by v4.5.2. ## v4.4.0 - Release date: 2021-06-08 - Tag: [v4.4.0](https://github.com/go-acme/lego/releases/tag/v4.4.0) ### Added - **[dnsprovider]** Add DNS provider for Infoblox - **[dnsprovider]** Add DNS provider for Porkbun - **[dnsprovider]** Add DNS provider for Simply.com - **[dnsprovider]** Add DNS provider for Sonic - **[dnsprovider]** Add DNS provider for VinylDNS - **[dnsprovider]** Add DNS provider for wedos ### Changed - **[cli]** log: Use stderr instead of stdout. - **[dnsprovider]** hostingde: autodetection of the zone name. - **[dnsprovider]** scaleway: use official SDK - **[dnsprovider]** powerdns: several improvements - **[lib]** lib: improve wait.For returns. ### Fixed - **[dnsprovider]** hurricane: add API rate limiter. - **[dnsprovider]** hurricane: only treat first word of response body as response code - **[dnsprovider]** exoscale: fix DNS provider debugging - **[dnsprovider]** wedos: fix api call parameters - **[dnsprovider]** nifcloud: Get zone info from dns01.FindZoneByFqdn - **[cli,lib]** csr: Support the type `NEW CERTIFICATE REQUEST` ## v4.3.1 - Release date: 2021-03-12 - Tag: [v4.3.1](https://github.com/go-acme/lego/releases/tag/v4.3.1) ### Fixed - **[dnsprovider]** exoscale: fix dependency version. ## v4.3.0 - Release date: 2021-03-10 - Tag: [v4.3.0](https://github.com/go-acme/lego/releases/tag/v4.3.0) ### Added - **[dnsprovider]** Add DNS provider for Njalla - **[dnsprovider]** Add DNS provider for Domeneshop - **[dnsprovider]** Add DNS provider for Hurricane Electric - **[dnsprovider]** designate: support for Openstack Application Credentials - **[dnsprovider]** edgedns: support for .edgerc file ### Changed - **[dnsprovider]** infomaniak: Make error message more meaningful - **[dnsprovider]** cloudns: Improve reliability - **[dnsprovider]** rfc2163: Removed support for MD5 algorithm. The default algorithm is now SHA1. ### Fixed - **[dnsprovider]** desec: fix error with default TTL - **[dnsprovider]** mythicbeasts: implement `ProviderTimeout` - **[dnsprovider]** dnspod: improve search accuracy when a domain have more than 100 records - **[lib]** Increase HTTP client timeouts - **[lib]** preferred chain only match root name ## v4.2.0 - Release date: 2021-01-24 - Tag: [v4.2.0](https://github.com/go-acme/lego/releases/tag/v4.2.0) ### Added - **[dnsprovider]** Add DNS provider for Loopia - **[dnsprovider]** Add DNS provider for Ionos. ### Changed - **[dnsprovider]** acme-dns: update cpu/goacmedns to v0.1.1. - **[dnsprovider]** inwx: Increase propagation timeout to 360s to improve robustness - **[dnsprovider]** vultr: Update to govultr v2 API - **[dnsprovider]** pdns: get exact zone instead of all zones ### Fixed - **[dnsprovider]** vult, dnspod: fix default HTTP timeout. - **[dnsprovider]** pdns: URL request creation. - **[lib]** errors: Fix instance not being printed ## v4.1.3 - Release date: 2020-11-25 - Tag: [v4.1.3](https://github.com/go-acme/lego/releases/tag/v4.1.3) ### Fixed - **[dnsprovider]** azure: fix error handling. ## v4.1.2 - Release date: 2020-11-21 - Tag: [v4.1.2](https://github.com/go-acme/lego/releases/tag/v4.1.2) ### Fixed - **[lib]** fix: preferred chain support. ## v4.1.1 - Release date: 2020-11-19 - Tag: [v4.1.1](https://github.com/go-acme/lego/releases/tag/v4.1.1) ### Fixed - **[dnsprovider]** otc: select correct zone if multiple returned - **[dnsprovider]** azure: fix target must be a non-nil pointer ## v4.1.0 - Release date: 2020-11-06 - Tag: [v4.1.0](https://github.com/go-acme/lego/releases/tag/v4.1.0) ### Added - **[dnsprovider]** Add DNS provider for Infomaniak - **[dnsprovider]** joker: add support for SVC API - **[dnsprovider]** gcloud: add an option to allow the use of private zones ### Changed - **[dnsprovider]** rfc2136: ensure TSIG algorithm is fully qualified - **[dnsprovider]** designate: Deprecate OS_TENANT_NAME as required field ### Fixed - **[lib]** acme/api: use postAsGet instead of post for AccountService.Get - **[lib]** fix: use http.Header.Set method instead of Add. ## v4.0.1 - Release date: 2020-09-03 - Tag: [v4.0.1](https://github.com/go-acme/lego/releases/tag/v4.0.1) ### Fixed - **[dnsprovider]** exoscale: change dependency version. ## v4.0.0 - Release date: 2020-09-02 - Tag: [v4.0.0](https://github.com/go-acme/lego/releases/tag/v4.0.0) ### Added - **[cli], [lib]** Support "alternate" certificate links for selecting different signing Chains ### Changed - **[cli]** Replaces `ec384` by `ec256` as default key-type - **[lib]** Changes `ObtainForCSR` method signature ### Removed - **[dnsprovider]** Replaces FastDNS by EdgeDNS - **[dnsprovider]** Removes old Linode provider - **[lib]** Removes `AddPreCheck` function ## v3.9.0 - Release date: 2020-09-01 - Tag: [v3.9.0](https://github.com/go-acme/lego/releases/tag/v3.9.0) ### Added - **[dnsprovider]** Add Akamai Edgedns. Deprecate FastDNS - **[dnsprovider]** Add DNS provider for HyperOne ### Changed - **[dnsprovider]** designate: add support for Openstack clouds.yaml - **[dnsprovider]** azure: allow selecting environments - **[dnsprovider]** desec: applies API rate limits. ### Fixed - **[dnsprovider]** namesilo: fix cleanup. ## v3.8.0 - Release date: 2020-07-02 - Tag: [v3.8.0](https://github.com/go-acme/lego/releases/tag/v3.8.0) ### Added - **[cli]** cli: add hook on the run command. - **[dnsprovider]** inwx: Two-Factor-Authentication - **[dnsprovider]** Add DNS provider for ArvanCloud ### Changed - **[dnsprovider]** vultr: bumping govultr version - **[dnsprovider]** desec: improve error logs. - **[lib]** Ensures the return of a location during account updates - **[dnsprovider]** route53: Document all AWS credential environment variables ### Fixed - **[dnsprovider]** stackpath: fix subdomain support. - **[dnsprovider]** arvandcloud: fix record name. - **[dnsprovider]** fix: multi-va. - **[dnsprovider]** constellix: fix search records API call. - **[dnsprovider]** hetzner: fix record name. - **[lib]** Registrar.ResolveAccountByKey: Fix malformed request ## v3.7.0 - Release date: 2020-05-11 - Tag: [v3.7.0](https://github.com/go-acme/lego/releases/tag/v3.7.0) ### Added - **[dnsprovider]** Add DNS provider for Netlify. - **[dnsprovider]** Add DNS provider for deSEC.io - **[dnsprovider]** Add DNS provider for LuaDNS - **[dnsprovider]** Adding Hetzner DNS provider - **[dnsprovider]** Add DNS provider for Mythic beasts DNSv2 - **[dnsprovider]** Add DNS provider for Yandex. ### Changed - **[dnsprovider]** Upgrade DNSimple client to 0.60.0 - **[dnsprovider]** update aws sdk ### Fixed - **[dnsprovider]** autodns: removes TXT records during CleanUp. - **[dnsprovider]** Fix exoscale HTTP timeout - **[cli]** fix: renew path information. - **[cli]** Fix account storage location warning message ## v3.6.0 - Release date: 2020-04-24 - Tag: [v3.6.0](https://github.com/go-acme/lego/releases/tag/v3.6.0) ### Added - **[dnsprovider]** Add DNS provider for CloudDNS. - **[dnsprovider]** alicloud: add support for domain with punycode - **[dnsprovider]** cloudns: Add subuser support - **[cli]** Information about renewed certificates are now passed to the renew hook ### Changed - **[dnsprovider]** acmedns: Update cpu/goacmedns v0.0.1 -> v0.0.2 - **[dnsprovider]** alicloud: update sdk dependency version to v1.61.112 - **[dnsprovider]** azure: Allow for the use of MSI - **[dnsprovider]** constellix: improve challenge. - **[dnsprovider]** godaddy: allow parallel solve. - **[dnsprovider]** namedotcom: get the actual registered domain, so we can remove just that from the hostname to be created - **[dnsprovider]** transip: updated the client to v6 ### Fixed - **[dnsprovider]** ns1: fix missing domain in log - **[dnsprovider]** rimuhosting: use HTTP client from config. ## v3.5.0 - Release date: 2020-03-15 - Tag: [v3.5.0](https://github.com/go-acme/lego/releases/tag/v3.5.0) ### Added - **[dnsprovider]** Add DNS provider for Dynu. - **[dnsprovider]** Add DNS provider for reg.ru - **[dnsprovider]** Add DNS provider for Zonomi and RimuHosting. - **[cli]** Building binaries for arm 6 and 5 - **[cli]** Uses CGO_ENABLED=0 - **[cli]** Multi-arch Docker image. - **[cli]** Adds `--name` flag to list command. ### Changed - **[lib]** lib: Improve cleanup log messages. - **[lib]** Wrap errors. ### Fixed - **[dnsprovider]** azure: pass AZURE_CLIENT_SECRET_FILE to autorest.Authorizer - **[dnsprovider]** gcloud: fixes issues when used with GKE Workload Identity - **[dnsprovider]** oraclecloud: fix subdomain support ## v3.4.0 - Release date: 2020-02-25 - Tag: [v3.4.0](https://github.com/go-acme/lego/releases/tag/v3.4.0) ### Added - **[dnsprovider]** Add DNS provider for Constellix - **[dnsprovider]** Add DNS provider for Servercow. - **[dnsprovider]** Add DNS provider for Scaleway - **[cli]** Add "LEGO_PATH" environment variable ### Changed - **[dnsprovider]** route53: allow custom client to be provided - **[dnsprovider]** namecheap: allow external domains - **[dnsprovider]** namecheap: add sandbox support. - **[dnsprovider]** ovh: Improve provider documentation - **[dnsprovider]** route53: Improve provider documentation ### Fixed - **[dnsprovider]** zoneee: fix subdomains. - **[dnsprovider]** designate: Don't clean up managed records like SOA and NS - **[dnsprovider]** dnspod: update lib. - **[lib]** crypto: Treat CommonName as optional - **[lib]** chore: update cenkalti/backoff to v4. ## v3.3.0 - Release date: 2020-01-08 - Tag: [v3.3.0](https://github.com/go-acme/lego/releases/tag/v3.3.0) ### Added - **[dnsprovider]** Add DNS provider for Checkdomain - **[lib]** Add support to update account ### Changed - **[dnsprovider]** gcloud: Auto-detection of the project ID. - **[lib]** Successfully parse private key PEM blocks ### Fixed - **[dnsprovider]** Update dnspod, because of API breaking changes. ## v3.2.0 - Release date: 2019-11-10 - Tag: [v3.2.0](https://github.com/go-acme/lego/releases/tag/v3.2.0) ### Added - **[dnsprovider]** Add support for autodns ### Changed - **[dnsprovider]** httpreq: Allow use environment vars from a `_FILE` file - **[lib]** Don't deactivate valid authorizations - **[lib]** Expose more SOA fields found by dns01.FindZoneByFqdn ### Fixed - **[dnsprovider]** use token as unique ID. ## v3.1.0 - Release date: 2019-10-07 - Tag: [v3.1.0](https://github.com/go-acme/lego/releases/tag/v3.1.0) ### Added - **[dnsprovider]** Add DNS provider for Liquid Web - **[dnsprovider]** cloudflare: add support for API tokens - **[cli]** feat: ease operation behind proxy servers ### Changed - **[dnsprovider]** cloudflare: update client - **[dnsprovider]** linodev4: propagation timeout configuration. ### Fixed - **[dnsprovider]** ovh: fix int overflow. - **[dnsprovider]** bindman: fix client version. ## v3.0.2 - Release date: 2019-08-15 - Tag: [v3.0.2](https://github.com/go-acme/lego/releases/tag/v3.0.2) ### Fixed - Invalid pseudo version (related to Cloudflare client). ## v3.0.1 - Release date: 2019-08-14 - Tag: [v3.0.1](https://github.com/go-acme/lego/releases/tag/v3.0.1) There was a problem when creating the tag v3.0.1, this tag has been invalidated. ## v3.0.0 - Release date: 2019-08-05 - Tag: [v3.0.0](https://github.com/go-acme/lego/releases/tag/v3.0.0) ### Changed - migrate to go module (new import github.com/go-acme/lego/v3/) - update DNS clients ## v2.7.2 - Release date: 2019-07-30 - Tag: [v2.7.2](https://github.com/go-acme/lego/releases/tag/v2.7.2) ### Fixed - **[dnsprovider]** vultr: quote TXT record ## v2.7.1 - Release date: 2019-07-22 - Tag: [v2.7.1](https://github.com/go-acme/lego/releases/tag/v2.7.1) ### Fixed - **[dnsprovider]** vultr: invalid record type. ## v2.7.0 - Release date: 2019-07-17 - Tag: [v2.7.0](https://github.com/go-acme/lego/releases/tag/v2.7.0) ### Added - **[dnsprovider]** Add DNS provider for namesilo - **[dnsprovider]** Add DNS provider for versio.nl ### Changed - **[dnsprovider]** Update DNS providers libs. - **[dnsprovider]** joker: support username and password. - **[dnsprovider]** Vultr: Switch to official client ### Fixed - **[dnsprovider]** otc: Prevent sending empty body. ## v2.6.0 - Release date: 2019-05-27 - Tag: [v2.6.0](https://github.com/go-acme/lego/releases/tag/v2.6.0) ### Added - **[dnsprovider]** Add support for Joker.com DMAPI - **[dnsprovider]** Add support for Bindman DNS provider - **[dnsprovider]** Add support for EasyDNS - **[lib]** Get an existing certificate by URL ### Changed - **[dnsprovider]** digitalocean: LEGO_EXPERIMENTAL_CNAME_SUPPORT support - **[dnsprovider]** gcloud: Use fqdn to get zone Present/CleanUp - **[dnsprovider]** exec: serial behavior - **[dnsprovider]** manual: serial behavior. - **[dnsprovider]** Strip newlines when reading environment variables from `_FILE` suffixed files. ### Fixed - **[cli]** fix: cli disable-cp option. - **[dnsprovider]** gcloud: fix zone visibility. ## v2.5.0 - Release date: 2019-04-17 - Tag: [v2.5.0](https://github.com/go-acme/lego/releases/tag/v2.5.0) ### Added - **[cli]** Adds renew hook - **[dnsprovider]** Adds 'Since' to DNS providers documentation ### Changed - **[dnsprovider]** gcloud: use public DNS zones - **[dnsprovider]** route53: enhance documentation. ### Fixed - **[dnsprovider]** cloudns: fix TTL and status validation - **[dnsprovider]** sakuracloud: supports concurrent update - **[dnsprovider]** Disable authz when solve fail. - Add tzdata to the Docker image. ## v2.4.0 - Release date: 2019-03-25 - Tag: [v2.4.0](https://github.com/go-acme/lego/releases/tag/v2.4.0) Migrate from xenolf/lego to go-acme/lego. ### Added - **[dnsprovider]** Add DNS Provider for Domain Offensive (do.de) - **[dnsprovider]** Adds information about '_FILE' suffix. ### Fixed - **[cli,dnsprovider]** Add 'manual' provider to the output of dnshelp - **[dnsprovider]** hostingde: Use provided ZoneName instead of domain - **[dnsprovider]** pdns: fix wildcard with SANs ## v2.3.0 - Release date: 2019-03-11 - Tag: [v2.3.0](https://github.com/go-acme/lego/releases/tag/v2.3.0) ### Added - **[dnsprovider]** Add DNS Provider for ClouDNS.net - **[dnsprovider]** Add DNS Provider for Oracle Cloud ### Changed - **[cli]** Adds log when no renewal. - **[dnsprovider,lib]** Add a mechanism to wrap a PreCheckFunc - **[dnsprovider]** oraclecloud: better way to get private key. - **[dnsprovider]** exoscale: update library ### Fixed - **[dnsprovider]** OVH: Refresh zone after deleting challenge record - **[dnsprovider]** oraclecloud: ttl config and timeout - **[dnsprovider]** hostingde: fix client fails if customer has no access to dns-groups - **[dnsprovider]** vscale: getting sub-domain - **[dnsprovider]** selectel: getting sub-domain - **[dnsprovider]** vscale: fix TXT records clean up - **[dnsprovider]** selectel: fix TXT records clean up ## v2.2.0 - Release date: 2019-02-08 - Tag: [v2.2.0](https://github.com/go-acme/lego/releases/tag/v2.2.0) ### Added - **[dnsprovider]** Add support for Openstack Designate as a DNS provider - **[dnsprovider]** gcloud: Option to specify gcloud service account json by env as string - **[experimental feature]** Resolve CNAME when creating dns-01 challenge. To enable: set `LEGO_EXPERIMENTAL_CNAME_SUPPORT` to `true`. ### Changed - **[cli]** Applies Let’s Encrypt’s recommendation about renew. The option `--days` of the command `renew` has a new default value (`30`) - **[lib]** Uses a jittered exponential backoff ### Fixed - **[cli]** CLI and key type. - **[dnsprovider]** httpreq: Endpoint with path. - **[dnsprovider]** fastdns: Do not overwrite existing TXT records - Log wildcard domain correctly in validation ## v2.1.0 - Release date: 2019-01-24 - Tag: [v2.1.0](https://github.com/go-acme/lego/releases/tag/v2.1.0) ### Added - **[dnsprovider]** Add support for zone.ee as a DNS provider. ### Changed - **[dnsprovider]** nifcloud: Change DNS base url. - **[dnsprovider]** gcloud: More detailed information about Google Cloud DNS. ### Fixed - **[lib]** fix: OCSP, set HTTP client. - **[dnsprovider]** alicloud: fix pagination. - **[dnsprovider]** namecheap: fix panic. ## v2.0.0 - Release date: 2019-01-09 - Tag: [v2.0.0](https://github.com/go-acme/lego/releases/tag/v2.0.0) ### Added - **[cli,lib]** Option to disable the complete propagation Requirement - **[lib,cli]** Support non-ascii domain name (punnycode) - **[cli,lib]** Add configurable timeout when obtaining certificates - **[cli]** Archive revoked certificates - **[cli]** Add command to list certificates. - **[cli]** support for renew with CSR - **[cli]** add SAN on renew - **[lib]** Adds `Remove` for challenges - **[lib]** Add version to xenolf-acme in User-Agent. - **[dnsprovider]** The ability for a DNS provider to solve the challenge sequentially - **[dnsprovider]** Add DNS provider for "HTTP request". - **[dnsprovider]** Add DNS Provider for Vscale - **[dnsprovider]** Add DNS Provider for TransIP - **[dnsprovider]** Add DNS Provider for inwx - **[dnsprovider]** alidns: add support to handle more than 20 domains ### Changed - **[lib]** Check all challenges in a predictable order - **[lib]** Poll authz URL instead of challenge URL - **[lib]** Check all nameservers in a predictable order - **[lib]** Logs every iteration of waiting for the propagation - **[cli]** `--http`: enable HTTP challenge **important** - **[cli]** `--http.port`: previously named `--http` - **[cli]** `--http.webroot`: previously named `--webroot` - **[cli]** `--http.memcached-host`: previously named `--memcached-host` - **[cli]** `--tls`: enable TLS challenge **important** - **[cli]** `--tls.port`: previously named `--tls` - **[cli]** `--dns.resolvers`: previously named `--dns-resolvers` - **[cli]** the option `--days` of the command `renew` has default value (`15`) - **[dnsprovider]** gcloud: Use GCE_PROJECT for project always, if specified ### Removed - **[lib]** Remove `SetHTTP01Address` - **[lib]** Remove `SetTLSALPN01Address` - **[lib]** Remove `Exclude` - **[cli]** Remove `--exclude`, `-x` ### Fixed - **[lib]** Fixes revocation for subdomains and non-ascii domains - **[lib]** Disable pending authorizations - **[dnsprovider]** transip: concurrent access to the API. - **[dnsprovider]** gcloud: fix for wildcard - **[dnsprovider]** Azure: Do not overwrite existing TXT records - **[dnsprovider]** fix: Cloudflare error. ## v1.2.0 - Release date: 2018-11-04 - Tag: [v1.2.0](https://github.com/go-acme/lego/releases/tag/v1.2.0) ### Added - **[dnsprovider]** Add DNS Provider for ConoHa DNS - **[dnsprovider]** Add DNS Provider for MyDNS.jp - **[dnsprovider]** Add DNS Provider for Selectel ### Fixed - **[dnsprovider]** netcup: make unmarshalling of api-responses more lenient. ### Changed - **[dnsprovider]** aurora: change DNS client - **[dnsprovider]** azure: update auth to support instance metadata service - **[dnsprovider]** dnsmadeeasy: log response body on error - **[lib]** TLS-ALPN-01: Update idPeAcmeIdentifierV1, draft refs. - **[lib]** Do not send a JWS body when POSTing challenges. - **[lib]** Support POST-as-GET. ## v1.1.0 - Release date: 2018-10-16 - Tag: [v1.1.0](https://github.com/go-acme/lego/releases/tag/v1.1.0) ### Added - **[lib]** TLS-ALPN-01 Challenge - **[cli]** Add filename parameter - **[dnsprovider]** Allow to configure TTL, interval and timeout - **[dnsprovider]** Add support for reading DNS provider setup from files - **[dnsprovider]** Add DNS Provider for ACME-DNS - **[dnsprovider]** Add DNS Provider for ALIYUN DNS - **[dnsprovider]** Add DNS Provider for DreamHost - **[dnsprovider]** Add DNS provider for hosting.de - **[dnsprovider]** Add DNS Provider for IIJ - **[dnsprovider]** Add DNS Provider for netcup - **[dnsprovider]** Add DNS Provider for NIFCLOUD DNS - **[dnsprovider]** Add DNS Provider for SAKURA Cloud - **[dnsprovider]** Add DNS Provider for Stackpath - **[dnsprovider]** Add DNS Provider for VegaDNS - **[dnsprovider]** exec: add EXEC_MODE=RAW support. - **[dnsprovider]** cloudflare: support for CF_API_KEY and CF_API_EMAIL ### Fixed - **[lib]** Don't trust identifiers order. - **[lib]** Fix missing issuer certificates from Let's Encrypt - **[dnsprovider]** duckdns: fix TXT record update url - **[dnsprovider]** duckdns: fix subsubdomain - **[dnsprovider]** gcloud: update findTxtRecords to use Name=fqdn and Type=TXT - **[dnsprovider]** lightsail: Fix Domain does not exist error - **[dnsprovider]** ns1: use the authoritative zone and not the domain name - **[dnsprovider]** ovh: check error to avoid panic due to nil client ### Changed - **[lib]** Submit all dns records up front, then validate serially ## v1.0.0 - Release date: 2018-05-30 - Tag: [v1.0.0](https://github.com/go-acme/lego/releases/tag/v1.0.0) ### Changed - **[lib]** ACME v2 Support. - **[dnsprovider]** Renamed `/providers/dns/googlecloud` to `/providers/dns/gcloud`. - **[dnsprovider]** Modified Google Cloud provider `gcloud.NewDNSProviderServiceAccount` function to extract the project id directly from the service account file. - **[dnsprovider]** Made errors more verbose for the Cloudflare provider. ## v0.5.0 - Release date: 2018-05-29 - Tag: [v0.5.0](https://github.com/go-acme/lego/releases/tag/v0.5.0) ### Added - **[dnsprovider]** Add DNS challenge provider `exec` - **[dnsprovider]** Add DNS Provider for Akamai FastDNS - **[dnsprovider]** Add DNS Provider for Bluecat DNS - **[dnsprovider]** Add DNS Provider for CloudXNS - **[dnsprovider]** Add DNS Provider for Duck DNS - **[dnsprovider]** Add DNS Provider for Gandi Beta Platform (LiveDNS) - **[dnsprovider]** Add DNS Provider for GleSYS API - **[dnsprovider]** Add DNS Provider for GoDaddy - **[dnsprovider]** Add DNS Provider for Lightsail - **[dnsprovider]** Add DNS Provider for Name.com ### Fixed - **[dnsprovider]** Azure: Added missing environment variable in the comments - **[dnsprovider]** PowerDNS: Fix zone URL, add leading slash. - **[dnsprovider]** DNSimple: Fix api - **[cli]** Correct help text for `--dns-resolvers` default. - **[cli]** renew/revoke - don't panic on wrong account. - **[lib]** Fix zone detection for cross-zone cnames. - **[lib]** Use proxies from environment when making outbound http connections. ### Changed - **[lib]** Users of an effective top-level domain can use the DNS challenge. - **[dnsprovider]** Azure: Refactor to work with new Azure SDK version. - **[dnsprovider]** Cloudflare and Azure: Adding output of which envvars are missing. - **[dnsprovider]** Dyn DNS: Slightly improve provider error reporting. - **[dnsprovider]** Exoscale: update to latest egoscale version. - **[dnsprovider]** Route53: Use NewSessionWithOptions instead of deprecated New. ## 0.4.1 - Release date: 2017-09-26 - Tag: [0.4.1](https://github.com/go-acme/lego/releases/tag/0.4.1) ### Added - lib: A new DNS provider for OTC. - lib: The `AWS_HOSTED_ZONE_ID` environment variable for the Route53 DNS provider to directly specify the zone. - lib: The `RFC2136_TIMEOUT` environment variable to make the timeout for the RFC2136 provider configurable. - lib: The `GCE_SERVICE_ACCOUNT_FILE` environment variable to specify a service account file for the Google Cloud DNS provider. ### Fixed - lib: Fixed an authentication issue with the latest Azure SDK. ## 0.4.0 - Release date: 2017-07-13 - Tag: [0.4.0](https://github.com/go-acme/lego/releases/tag/0.4.0) ### Added - CLI: The `--http-timeout` switch. This allows for an override of the default client HTTP timeout. - lib: The `HTTPClient` field. This allows for an override of the default HTTP timeout for library HTTP requests. - CLI: The `--dns-timeout` switch. This allows for an override of the default DNS timeout for library DNS requests. - lib: The `DNSTimeout` switch. This allows for an override of the default client DNS timeout. - lib: The `QueryRegistration` function on `acme.Client`. This performs a POST on the client registration's URI and gets the updated registration info. - lib: The `DeleteRegistration` function on `acme.Client`. This deletes the registration as currently configured in the client. - lib: The `ObtainCertificateForCSR` function on `acme.Client`. The function allows to request a certificate for an already existing CSR. - CLI: The `--csr` switch. Allows to use already existing CSRs for certificate requests on the command line. - CLI: The `--pem` flag. This will change the certificate output, so it outputs a .pem file concatanating the .key and .crt files together. - CLI: The `--dns-resolvers` flag. Allows for users to override the default DNS servers used for recursive lookup. - lib: Added a memcached provider for the HTTP challenge. - CLI: The `--memcached-host` flag. This allows to use memcached for challenge storage. - CLI: The `--must-staple` flag. This enables OCSP must staple in the generated CSR. - lib: The library will now honor entries in your resolv.conf. - lib: Added a field `IssuerCertificate` to the `CertificateResource` struct. - lib: A new DNS provider for OVH. - lib: A new DNS provider for DNSMadeEasy. - lib: A new DNS provider for Linode. - lib: A new DNS provider for AuroraDNS. - lib: A new DNS provider for NS1. - lib: A new DNS provider for Azure DNS. - lib: A new DNS provider for Rackspace DNS. - lib: A new DNS provider for Exoscale DNS. - lib: A new DNS provider for DNSPod. ### Changed - lib: Exported the `PreCheckDNS` field so library users can manage the DNS check in tests. - lib: The library will now skip challenge solving if a valid Authz already exists. ### Removed - lib: The library will no longer check for auto-renewed certificates. This has been removed from the spec and is not supported in Boulder. ### Fixed - lib: Fix a problem with the Route53 provider where it was possible the verification was published to a private zone. - lib: Loading an account from file should fail if an integral part is nil - lib: Fix a potential issue where the Dyn provider could resolve to an incorrect zone. - lib: If a registration encounteres a conflict, the old registration is now recovered. - CLI: The account.json file no longer has the executable flag set. - lib: Made the client registration more robust in case of a 403 HTTP response. - lib: Fixed an issue with zone lookups when they have a CNAME in another zone. - lib: Fixed the lookup for the authoritative zone for Google Cloud. - lib: Fixed a race condition in the nonce store. - lib: The Google Cloud provider now removes old entries before trying to add new ones. - lib: Fixed a condition where we could stall due to an early error condition. - lib: Fixed an issue where Authz object could end up in an active state after an error condition. ## 0.3.1 - Release date: 2016-04-19 - Tag: [0.3.1](https://github.com/go-acme/lego/releases/tag/0.3.1) ### Added - lib: A new DNS provider for Vultr. ### Fixed - lib: DNS Provider for DigitalOcean could not handle subdomains properly. - lib: handleHTTPError should only try to JSON decode error messages with the right content type. - lib: The propagation checker for the DNS challenge would not retry on send errors. ## 0.3.0 - Release date: 2016-03-19 - Tag: [0.3.0](https://github.com/go-acme/lego/releases/tag/0.3.0) ### Added - CLI: The `--dns` switch. To include the DNS challenge for consideration. When using this switch, all other solvers are disabled. Supported are the following solvers: cloudflare, digitalocean, dnsimple, dyn, gandi, googlecloud, namecheap, route53, rfc2136 and manual. - CLI: The `--accept-tos` switch. Indicates your acceptance of the Let's Encrypt terms of service without prompting you. - CLI: The `--webroot` switch. The HTTP-01 challenge may now be completed by dropping a file into a webroot. When using this switch, all other solvers are disabled. - CLI: The `--key-type` switch. This replaces the `--rsa-key-size` switch and supports the following key types: EC256, EC384, RSA2048, RSA4096 and RSA8192. - CLI: The `--dnshelp` switch. This displays a more in-depth help topic for DNS solvers. - CLI: The `--no-bundle` sub switch for the `run` and `renew` commands. When this switch is set, the CLI will not bundle the issuer certificate with your certificate. - lib: A new type for challenge identifiers `Challenge` - lib: A new interface for custom challenge providers `acme.ChallengeProvider` - lib: A new interface for DNS-01 providers to allow for custom timeouts for the validation function `acme.ChallengeProviderTimeout` - lib: SetChallengeProvider function. Pass a challenge identifier and a Provider to replace the default behaviour of a challenge. - lib: The DNS-01 challenge has been implemented with modular solvers using the `ChallengeProvider` interface. Included solvers are: cloudflare, digitalocean, dnsimple, gandi, namecheap, route53, rfc2136 and manual. - lib: The `acme.KeyType` type was added and is used for the configuration of crypto parameters for RSA and EC keys. Valid KeyTypes are: EC256, EC384, RSA2048, RSA4096 and RSA8192. ### Changed - lib: ExcludeChallenges now expects to be passed an array of `Challenge` types. - lib: HTTP-01 now supports custom solvers using the `ChallengeProvider` interface. - lib: TLS-SNI-01 now supports custom solvers using the `ChallengeProvider` interface. - lib: The `GetPrivateKey` function in the `acme.User` interface is now expected to return a `crypto.PrivateKey` instead of an `rsa.PrivateKey` for EC compat. - lib: The `acme.NewClient` function now expects an `acme.KeyType` instead of the keyBits parameter. ### Removed - CLI: The `rsa-key-size` switch was removed in favor of `key-type` to support EC keys. ### Fixed - lib: Fixed a race condition in HTTP-01 - lib: Fixed an issue where status codes on ACME challenge responses could lead to no action being taken. - lib: Fixed a regression when calling the Renew function with a SAN certificate. ## 0.2.0 - Release date: 2016-01-09 - Tag: [0.2.0](https://github.com/go-acme/lego/releases/tag/0.2.0) ### Added - CLI: The `--exclude` or `-x` switch. To exclude a challenge from being solved. - CLI: The `--http` switch. To set the listen address and port of HTTP based challenges. Supports `host:port` and `:port` for any interface. - CLI: The `--tls` switch. To set the listen address and port of TLS based challenges. Supports `host:port` and `:port` for any interface. - CLI: The `--reuse-key` switch for the `renew` operation. This lets you reuse an existing private key for renewals. - lib: ExcludeChallenges function. Pass an array of challenge identifiers to exclude them from solving. - lib: SetHTTPAddress function. Pass a port to set the listen port for HTTP based challenges. - lib: SetTLSAddress function. Pass a port to set the listen port of TLS based challenges. - lib: acme.UserAgent variable. Use this to customize the user agent on all requests sent by lego. ### Changed - lib: NewClient does no longer accept the optPort parameter - lib: ObtainCertificate now returns a SAN certificate if you pass more than one domain. - lib: GetOCSPForCert now returns the parsed OCSP response instead of just the status. - lib: ObtainCertificate has a new parameter `privKey crypto.PrivateKey` which lets you reuse an existing private key for new certificates. - lib: RenewCertificate now expects the PrivateKey property of the CertificateResource to be set only if you want to reuse the key. ### Removed - CLI: The `--port` switch was removed. - lib: RenewCertificate does no longer offer to also revoke your old certificate. ### Fixed - CLI: Fix logic using the `--days` parameter for renew ## 0.1.1 - Release date: 2015-12-18 - Tag: [0.1.1](https://github.com/go-acme/lego/releases/tag/0.1.1) ### Added - CLI: Added a way to automate renewal through a cronjob using the --days parameter to renew ### Changed - lib: Improved log output on challenge failures. ### Fixed - CLI: The short parameter for domains would not get accepted - CLI: The cli did not return proper exit codes on error library errors. - lib: RenewCertificate did not properly renew SAN certificates. ### Security - lib: Fix possible DOS on GetOCSPForCert ## 0.1.0 - Release date: 2015-12-03 - Tag: [0.1.0](https://github.com/go-acme/lego/releases/tag/0.1.0) Initial release ================================================ FILE: CONTRIBUTING.md ================================================ # How to contribute to lego Contributions in the form of patches and proposals are essential to keep lego great and to make it even better. To ensure a great and easy experience for everyone, please review the few guidelines in this document. ## Bug reports - Use the issue search to see if the issue has already been reported. - Also look for closed issues to see if your issue has already been fixed. - If both of the above do not apply, create a new issue and include as much information as possible. Bug reports should include all information a person could need to reproduce your problem without the need to follow up for more information. If possible, provide detailed steps for us to reproduce it, the expected behavior and the actual behavior. ## Feature proposals and requests Feature requests are welcome and should be discussed in an issue. Please keep proposals focused on one thing at a time and be as detailed as possible. It is up to you to make a strong point about your proposal and convince us of the merits and the added complexity of this feature. ## Pull requests Create an issue and wait for a maintainer to approve it BEFORE opening a pull request. Patches, new features and improvements are a great way to help the project. Please keep them focused on one thing and do not include unrelated commits. All pull requests that alter the behavior of the program, add new behavior or somehow alter code in a non-trivial way should **always** include tests. **IMPORTANT**: By submitting a patch, you agree to allow the project owners to license your work under the terms of the [MIT License](LICENSE). ### How to create a pull request Requirements: - `go` v1.24+ - environment variable: `GO111MODULE=on` First, you have to install [GoLang](https://golang.org/doc/install) and [golangci-lint](https://github.com/golangci/golangci-lint#install). ```bash # clone your fork git clone git@github.com:YOUR_USERNAME/lego.git cd lego # Add the go-acme/lego remote git remote add upstream git@github.com:go-acme/lego.git git fetch upstream ``` ```bash # Create your branch git switch -c my-feature ## Create your code ## ``` ```bash # Linters make checks # Tests make test # Compile make build ``` ```bash # push your branch git push -u origin my-feature ## create a pull request on GitHub ## ``` ================================================ FILE: Dockerfile ================================================ FROM golang:1-alpine as builder RUN apk --no-cache --no-progress add make git WORKDIR /go/lego ENV GO111MODULE on # Download go modules COPY go.mod . COPY go.sum . RUN go mod download COPY . . RUN make build FROM alpine:3 RUN apk update \ && apk add --no-cache ca-certificates tzdata \ && update-ca-certificates COPY --from=builder /go/lego/dist/lego /usr/bin/lego ENTRYPOINT [ "/usr/bin/lego" ] ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2017-2024 Ludovic Fernandez Copyright (c) 2015-2017 Sebastian Erhart Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: clean checks test build image e2e fmt export GO111MODULE=on export CGO_ENABLED=0 LEGO_IMAGE := goacme/lego MAIN_DIRECTORY := ./cmd/lego/ BIN_OUTPUT := $(if $(filter $(shell go env GOOS), windows), dist/lego.exe, dist/lego) TAG_NAME := $(shell git tag -l --contains HEAD) SHA := $(shell git rev-parse HEAD) VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA)) default: clean generate-dns checks test build clean: @echo BIN_OUTPUT: ${BIN_OUTPUT} rm -rf dist/ builds/ cover.out build: clean @echo Version: $(VERSION) go build -trimpath -ldflags '-X "main.version=${VERSION}"' -o ${BIN_OUTPUT} ${MAIN_DIRECTORY} image: @echo Version: $(VERSION) docker build -t $(LEGO_IMAGE) . test: clean go test -v -cover ./... e2e: clean LEGO_E2E_TESTS=local go test -count=1 -v ./e2e/... checks: golangci-lint run # Release helper .PHONY: patch minor major detach patch: go run ./internal/releaser/ release -m patch minor: go run ./internal/releaser/ release -m minor major: go run ./internal/releaser/ release -m major detach: go run ./internal/releaser/ detach # Docs .PHONY: docs-build docs-serve docs-themes docs-build: generate-dns @make -C ./docs build docs-serve: generate-dns @make -C ./docs serve docs-themes: @make -C ./docs hugo-themes # DNS Documentation .PHONY: generate-dns validate-doc generate-dns: go generate ./... validate-doc: generate-dns validate-doc: DOC_DIRECTORIES := ./docs/ ./cmd/ validate-doc: @if git diff --exit-code --quiet $(DOC_DIRECTORIES) 2>/dev/null; then \ echo 'All documentation changes are done the right way.'; \ else \ echo 'The documentation must be regenerated, please use `make generate-dns`.'; \ git status --porcelain -- $(DOC_DIRECTORIES) 2>/dev/null; \ exit 2; \ fi ================================================ FILE: README.md ================================================
lego logo

Automatic Certificates and HTTPS for everyone.

# Lego [ACME](https://www.rfc-editor.org/rfc/rfc8555.html) client and library for Let's Encrypt and other ACME CAs written in Go. [![Go Reference](https://pkg.go.dev/badge/github.com/go-acme/lego/v4.svg)](https://pkg.go.dev/github.com/go-acme/lego/v4) [![Build Status](https://github.com//go-acme/lego/workflows/Main/badge.svg?branch=master)](https://github.com//go-acme/lego/actions) [![Docker Pulls](https://img.shields.io/docker/pulls/goacme/lego.svg)](https://hub.docker.com/r/goacme/lego/) lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ Everybody thinks that the others will donate, but in the end, nobody does. So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev). ## Features - ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html) - Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension - Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses - Support [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html): Renewal Information (ARI) Extension - Support [draft-ietf-acme-profiles-00](https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/): Profiles Extension - Comes with about [180 DNS providers](https://go-acme.github.io/lego/dns) - Register with CA - Obtain certificates, both from scratch or with an existing CSR - Renew certificates - Revoke certificates - Robust implementation of ACME challenges: - HTTP (http-01) - DNS (dns-01) - TLS (tls-alpn-01) - SAN certificate support - [CNAME support](https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme.html) by default - [Custom challenge solvers](https://go-acme.github.io/lego/usage/library/writing-a-challenge-solver/) - Certificate bundling - OCSP helper function ## Installation How to [install](https://go-acme.github.io/lego/installation/). ## Usage - as a [CLI](https://go-acme.github.io/lego/usage/cli) - as a [library](https://go-acme.github.io/lego/usage/library) ## Documentation Documentation is hosted live at https://go-acme.github.io/lego/. ## DNS providers Detailed documentation is available [here](https://go-acme.github.io/lego/dns). If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.yml).
35.com/三五互联 Active24 Akamai EdgeDNS Alibaba Cloud DNS
AlibabaCloud ESA all-inkl Alwaysdata Amazon Lightsail
Amazon Route 53 Anexia CloudDNS ANS SafeDNS ArtFiles
ArvanCloud Aurora DNS Autodns Axelname
Azion Azure (deprecated) Azure DNS Baidu Cloud
Beget.com Binary Lane Bindman Bluecat
Bluecat v2 BookMyName Brandit (deprecated) Bunny
Checkdomain Civo Cloud.ru CloudDNS
Cloudflare ClouDNS CloudXNS (Deprecated) ConoHa v2
ConoHa v3 Constellix Core-Networks CPanel/WHM
Czechia DDnss (DynDNS Service) Derak Cloud deSEC.io
Designate DNSaaS for Openstack Digital Ocean DirectAdmin DNS Made Easy
DNSExit dnsHome.de DNSimple DNSPod (deprecated)
Domain Offensive (do.de) Domeneshop DreamHost Duck DNS
Dyn DynDnsFree.de Dynu EasyDNS
EdgeCenter Efficient IP Epik EuroDNS
Excedo Exoscale External program F5 XC
freemyip.com FusionLayer NameSurfer G-Core Gandi
Gandi Live DNS (v5) Gigahost.no Glesys Go Daddy
Google Cloud Google Domains Gravity Hetzner
Hosting.de Hosting.nl Hostinger Hosttech
HTTP request http.net Huawei Cloud Hurricane Electric DNS
HyperOne IBM Cloud (SoftLayer) IIJ DNS Platform Service Infoblox
Infomaniak Internet Initiative Japan Internet.bs INWX
Ionos Ionos Cloud IPv64 ISPConfig 3
ISPConfig 3 - Dynamic DNS (DDNS) Module iwantmyname (Deprecated) JD Cloud Joker
Joohoi's ACME-DNS KeyHelp Leaseweb Liara
Lima-City Linode (v4) Liquid Web Loopia
LuaDNS Mail-in-a-Box ManageEngine CloudDNS Manual
Metaname Metaregistrar mijn.host Mittwald
myaddr.{tools,dev,io} MyDNS.jp MythicBeasts Name.com
Namecheap Namesilo NearlyFreeSpeech.NET Neodigit
Netcup Netlify Nicmanager NIFCloud
Njalla Nodion NS1 Octenium
Open Telekom Cloud Oracle Cloud OVH plesk.com
Porkbun PowerDNS Rackspace Rain Yun/雨云
RcodeZero reg.ru Regfish RFC2136
RimuHosting RU CENTER Sakura Cloud Scaleway
Selectel Selectel v2 SelfHost.(de|eu) Servercow
Shellrent Simply.com Sonic Spaceship
Stackpath Syse Technitium Tencent Cloud DNS
Tencent EdgeOne Timeweb Cloud TodayNIC/时代互联 TransIP
Ultradns United-Domains Variomedia VegaDNS
Vercel Versio.[nl|eu|uk] VinylDNS Virtualname
VK Cloud Volcano Engine/火山引擎 Vscale Vultr
webnames.ca webnames.ru Websupport WEDOS
West.cn/西部数码 Yandex 360 Yandex Cloud Yandex PDD
Zone.ee ZoneEdit Zonomi
If your DNS provider is not supported, please open an [issue](https://github.com/go-acme/lego/issues/new?assignees=&labels=enhancement%2C+new-provider&template=new_dns_provider.yml). ================================================ FILE: acme/api/account.go ================================================ package api import ( "encoding/base64" "errors" "fmt" "github.com/go-acme/lego/v4/acme" ) type AccountService service // New Creates a new account. func (a *AccountService) New(req acme.Account) (acme.ExtendedAccount, error) { var account acme.Account resp, err := a.core.post(a.core.GetDirectory().NewAccountURL, req, &account) location := getLocation(resp) if location != "" { a.core.jws.SetKid(location) } if err != nil { return acme.ExtendedAccount{Location: location}, err } return acme.ExtendedAccount{Account: account, Location: location}, nil } // NewEAB Creates a new account with an External Account Binding. func (a *AccountService) NewEAB(accMsg acme.Account, kid, hmacEncoded string) (acme.ExtendedAccount, error) { hmac, err := decodeEABHmac(hmacEncoded) if err != nil { return acme.ExtendedAccount{}, err } eabJWS, err := a.core.signEABContent(a.core.GetDirectory().NewAccountURL, kid, hmac) if err != nil { return acme.ExtendedAccount{}, fmt.Errorf("acme: error signing eab content: %w", err) } accMsg.ExternalAccountBinding = eabJWS return a.New(accMsg) } // Get Retrieves an account. func (a *AccountService) Get(accountURL string) (acme.Account, error) { if accountURL == "" { return acme.Account{}, errors.New("account[get]: empty URL") } var account acme.Account _, err := a.core.postAsGet(accountURL, &account) if err != nil { return acme.Account{}, err } return account, nil } // Update Updates an account. func (a *AccountService) Update(accountURL string, req acme.Account) (acme.Account, error) { if accountURL == "" { return acme.Account{}, errors.New("account[update]: empty URL") } var account acme.Account _, err := a.core.post(accountURL, req, &account) if err != nil { return acme.Account{}, err } return account, nil } // Deactivate Deactivates an account. func (a *AccountService) Deactivate(accountURL string) error { if accountURL == "" { return errors.New("account[deactivate]: empty URL") } req := acme.Account{Status: acme.StatusDeactivated} _, err := a.core.post(accountURL, req, nil) return err } func decodeEABHmac(hmacEncoded string) ([]byte, error) { hmac, errRaw := base64.RawURLEncoding.DecodeString(hmacEncoded) if errRaw == nil { return hmac, nil } hmac, err := base64.URLEncoding.DecodeString(hmacEncoded) if err == nil { return hmac, nil } return nil, fmt.Errorf("acme: could not decode hmac key: %w", errors.Join(errRaw, err)) } ================================================ FILE: acme/api/account_test.go ================================================ package api import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_decodeEABHmac(t *testing.T) { testCases := []struct { desc string hmac string }{ { desc: "RawURLEncoding", hmac: "BAEDAgQCBQcGCAUDDDMBAAIRAwQhEjEFQVFhEyJxgTIGFJGhsUIjJBVSwWIzNHKC0UMHJZJT8OHx", }, { desc: "URLEncoding", hmac: "nKTo9Hu8fpCqWPXx-25LVbZrJWxcHISsr4qHrRR0j5U=", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() v, err := decodeEABHmac(test.hmac) require.NoError(t, err) assert.NotEmpty(t, v) }) } } ================================================ FILE: acme/api/api.go ================================================ package api import ( "bytes" "context" "crypto" "encoding/json" "errors" "fmt" "net/http" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api/internal/nonces" "github.com/go-acme/lego/v4/acme/api/internal/secure" "github.com/go-acme/lego/v4/acme/api/internal/sender" "github.com/go-acme/lego/v4/log" ) // Core ACME/LE core API. type Core struct { doer *sender.Doer nonceManager *nonces.Manager jws *secure.JWS directory acme.Directory HTTPClient *http.Client common service // Reuse a single struct instead of allocating one for each service on the heap. Accounts *AccountService Authorizations *AuthorizationService Certificates *CertificateService Challenges *ChallengeService Orders *OrderService } // New Creates a new Core. func New(httpClient *http.Client, userAgent, caDirURL, kid string, privateKey crypto.PrivateKey) (*Core, error) { doer := sender.NewDoer(httpClient, userAgent) dir, err := getDirectory(doer, caDirURL) if err != nil { return nil, err } nonceManager := nonces.NewManager(doer, dir.NewNonceURL) jws := secure.NewJWS(privateKey, kid, nonceManager) c := &Core{doer: doer, nonceManager: nonceManager, jws: jws, directory: dir, HTTPClient: httpClient} c.common.core = c c.Accounts = (*AccountService)(&c.common) c.Authorizations = (*AuthorizationService)(&c.common) c.Certificates = (*CertificateService)(&c.common) c.Challenges = (*ChallengeService)(&c.common) c.Orders = (*OrderService)(&c.common) return c, nil } // post performs an HTTP POST request and parses the response body as JSON, // into the provided respBody object. func (a *Core) post(uri string, reqBody, response any) (*http.Response, error) { content, err := json.Marshal(reqBody) if err != nil { return nil, errors.New("failed to marshal message") } return a.retrievablePost(uri, content, response) } // postAsGet performs an HTTP POST ("POST-as-GET") request. // https://www.rfc-editor.org/rfc/rfc8555.html#section-6.3 func (a *Core) postAsGet(uri string, response any) (*http.Response, error) { return a.retrievablePost(uri, []byte{}, response) } func (a *Core) retrievablePost(uri string, content []byte, response any) (*http.Response, error) { ctx := context.Background() // during tests, allow to support ~90% of bad nonce with a minimum of attempts. bo := backoff.NewExponentialBackOff() bo.InitialInterval = 200 * time.Millisecond bo.MaxInterval = 5 * time.Second operation := func() (*http.Response, error) { resp, err := a.signedPost(uri, content, response) if err != nil { // Retry if the nonce was invalidated var e *acme.NonceError if errors.As(err, &e) { return resp, err } return resp, backoff.Permanent(err) } return resp, nil } notify := func(err error, duration time.Duration) { log.Infof("retry due to: %v", err) } return backoff.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithMaxElapsedTime(20*time.Second), backoff.WithNotify(notify)) } func (a *Core) signedPost(uri string, content []byte, response any) (*http.Response, error) { signedContent, err := a.jws.SignContent(uri, content) if err != nil { return nil, fmt.Errorf("failed to post JWS message: failed to sign content: %w", err) } signedBody := bytes.NewBufferString(signedContent.FullSerialize()) resp, err := a.doer.Post(uri, signedBody, "application/jose+json", response) // nonceErr is ignored to keep the root error. nonce, nonceErr := nonces.GetFromResponse(resp) if nonceErr == nil { a.nonceManager.Push(nonce) } return resp, err } func (a *Core) signEABContent(newAccountURL, kid string, hmac []byte) ([]byte, error) { eabJWS, err := a.jws.SignEABContent(newAccountURL, kid, hmac) if err != nil { return nil, err } return []byte(eabJWS.FullSerialize()), nil } // GetKeyAuthorization Gets the key authorization. func (a *Core) GetKeyAuthorization(token string) (string, error) { return a.jws.GetKeyAuthorization(token) } func (a *Core) GetDirectory() acme.Directory { return a.directory } func getDirectory(do *sender.Doer, caDirURL string) (acme.Directory, error) { var dir acme.Directory if _, err := do.Get(caDirURL, &dir); err != nil { return dir, fmt.Errorf("get directory at '%s': %w", caDirURL, err) } if dir.NewAccountURL == "" { return dir, errors.New("directory missing new registration URL") } if dir.NewOrderURL == "" { return dir, errors.New("directory missing new order URL") } return dir, nil } ================================================ FILE: acme/api/authorization.go ================================================ package api import ( "errors" "github.com/go-acme/lego/v4/acme" ) type AuthorizationService service // Get Gets an authorization. func (c *AuthorizationService) Get(authzURL string) (acme.Authorization, error) { if authzURL == "" { return acme.Authorization{}, errors.New("authorization[get]: empty URL") } var authz acme.Authorization _, err := c.core.postAsGet(authzURL, &authz) if err != nil { return acme.Authorization{}, err } return authz, nil } // Deactivate Deactivates an authorization. func (c *AuthorizationService) Deactivate(authzURL string) error { if authzURL == "" { return errors.New("authorization[deactivate]: empty URL") } var disabledAuth acme.Authorization _, err := c.core.post(authzURL, acme.Authorization{Status: acme.StatusDeactivated}, &disabledAuth) return err } ================================================ FILE: acme/api/certificate.go ================================================ package api import ( "bytes" "encoding/pem" "errors" "io" "net/http" "github.com/go-acme/lego/v4/acme" ) // maxBodySize is the maximum size of body that we will read. const maxBodySize = 1024 * 1024 type CertificateService service // Get Returns the certificate and the issuer certificate. // 'bundle' is only applied if the issuer is provided by the 'up' link. func (c *CertificateService) Get(certURL string, bundle bool) ([]byte, []byte, error) { cert, _, err := c.get(certURL, bundle) if err != nil { return nil, nil, err } return cert.Cert, cert.Issuer, nil } // GetAll the certificates and the alternate certificates. // bundle' is only applied if the issuer is provided by the 'up' link. func (c *CertificateService) GetAll(certURL string, bundle bool) (map[string]*acme.RawCertificate, error) { cert, headers, err := c.get(certURL, bundle) if err != nil { return nil, err } certs := map[string]*acme.RawCertificate{certURL: cert} // URLs of "alternate" link relation // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2 alts := getLinks(headers, "alternate") for _, alt := range alts { altCert, _, err := c.get(alt, bundle) if err != nil { return nil, err } certs[alt] = altCert } return certs, nil } // Revoke Revokes a certificate. func (c *CertificateService) Revoke(req acme.RevokeCertMessage) error { _, err := c.core.post(c.core.GetDirectory().RevokeCertURL, req, nil) return err } // get Returns the certificate and the "up" link. func (c *CertificateService) get(certURL string, bundle bool) (*acme.RawCertificate, http.Header, error) { if certURL == "" { return nil, nil, errors.New("certificate[get]: empty URL") } resp, err := c.core.postAsGet(certURL, nil) if err != nil { return nil, nil, err } data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxBodySize)) if err != nil { return nil, resp.Header, err } cert := c.getCertificateChain(data, bundle) return cert, resp.Header, err } // getCertificateChain Returns the certificate and the issuer certificate. func (c *CertificateService) getCertificateChain(cert []byte, bundle bool) *acme.RawCertificate { // Get issuerCert from bundled response from Let's Encrypt // See https://community.letsencrypt.org/t/acme-v2-no-up-link-in-response/64962 _, issuer := pem.Decode(cert) // If bundle is false, we want to return a single certificate. // To do this, we remove the issuer cert(s) from the issued cert. if !bundle { cert = bytes.TrimSuffix(cert, issuer) } return &acme.RawCertificate{Cert: cert, Issuer: issuer} } ================================================ FILE: acme/api/certificate_test.go ================================================ package api import ( "crypto/rand" "crypto/rsa" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const certResponseMock = `-----BEGIN CERTIFICATE----- MIIDEDCCAfigAwIBAgIHPhckqW5fPDANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQD Ex1QZWJibGUgSW50ZXJtZWRpYXRlIENBIDM5NWU2MTAeFw0xODExMDcxNzQ2NTZa Fw0yMzExMDcxNzQ2NTZaMBMxETAPBgNVBAMTCGFjbWUud3RmMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwtLNKvZXD20XPUQCWYSK9rUSKxD9Eb0c9fag bxOxOkLRTgL8LH6yln+bxc3MrHDou4PpDUdeo2CyOQu3CKsTS5mrH3NXYHu0H7p5 y3riOJTHnfkGKLT9LciGz7GkXd62nvNP57bOf5Sk4P2M+Qbxd0hPTSfu52740LSy 144cnxe2P1aDYehrEp6nYCESuyD/CtUHTo0qwJmzIy163Sp3rSs15BuCPyhySnE3 BJ8Ggv+qC6D5I1932DfSqyQJ79iq/HRm0Fn84am3KwvRlUfWxabmsUGARXoqCgnE zcbJVOZKewv0zlQJpfac+b+Imj6Lvt1TGjIz2mVyefYgLx8gwwIDAQABo1QwUjAO BgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwG A1UdEwEB/wQCMAAwEwYDVR0RBAwwCoIIYWNtZS53dGYwDQYJKoZIhvcNAQELBQAD ggEBABB/0iYhmfPSQot5RaeeovQnsqYjI5ryQK2cwzW6qcTJfv8N6+p6XkqF1+W4 jXZjrQP8MvgO9KNWlvx12vhINE6wubk88L+2piAi5uS2QejmZbXpyYB9s+oPqlk9 IDvfdlVYOqvYAhSx7ggGi+j73mjZVtjAavP6dKuu475ZCeq+NIC15RpbbikWKtYE HBJ7BW8XQKx67iHGx8ygHTDLbREL80Bck3oUm7wIYGMoNijD6RBl25p4gYl9dzOd TqGl5hW/1P5hMbgEzHbr4O3BfWqU2g7tV36TASy3jbC3ONFRNNYrpEZ1AL3+cUri OPPkKtAKAbQkKbUIfsHpBZjKZMU= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDDDCCAfSgAwIBAgIIOV5hkYJx0JwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE AxMVUGViYmxlIFJvb3QgQ0EgNTBmZmJkMB4XDTE4MTEwNzE3NDY0N1oXDTQ4MTEw NzE3NDY0N1owKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAzOTVl NjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCacwXN4LmyRTgYS8TT SZYgz758npHiPTBDKgeN5WVmkkwW0TuN4W2zXhEmcM82uxOEjWS2drvK0+iJKneh 0fQR8ZF35dIYFe8WXTg3kEmqcizSgh4LxlOntsXvatfX/6GU/ADo3xAFoBKCijen SRBIY65yq5m00cWx3RMIcQq1B0X8nJS0O1P7MYE/Vvidz5St/36RXVu1oWLeS5Fx HAezW0lqxEUzvC+uLTFWC6f/CilzmI7SsPAkZBk7dO5Qs0d7m/zWF588vlGS+0pt D1on+lU85Ma2zuAd0qmB6LY66N8pEKKtMk93wF/o4Z5i58ahbwNvTKAzz4JSRWSu mB9LAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcD AQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA upU0DjzvIvoCOYKbq1RRN7rPdYad39mfjxgkeV0iOF5JoIdO6y1C7XAm9lT69Wjm iUPvnCTMDYft40N2SvmXuuMaPOm4zjNwn4K33jw5XBnpwxC7By/Y0oV+Sl10fBsd QqXC6H7LcSGkv+4eJbgY33P4uH5ZAy+2TkHUuZDkpufkAshzBust7nDAjfv3AIuQ wlPoyZfI11eqyiOqRzOq+B5dIBr1JzKnEzSL6n0JLNQiPO7iN03rud/wYD3gbmcv rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2 7R4IbHGnj0BJA2vMYC4hSw== -----END CERTIFICATE----- ` const issuerMock = `-----BEGIN CERTIFICATE----- MIIDDDCCAfSgAwIBAgIIOV5hkYJx0JwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE AxMVUGViYmxlIFJvb3QgQ0EgNTBmZmJkMB4XDTE4MTEwNzE3NDY0N1oXDTQ4MTEw NzE3NDY0N1owKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAzOTVl NjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCacwXN4LmyRTgYS8TT SZYgz758npHiPTBDKgeN5WVmkkwW0TuN4W2zXhEmcM82uxOEjWS2drvK0+iJKneh 0fQR8ZF35dIYFe8WXTg3kEmqcizSgh4LxlOntsXvatfX/6GU/ADo3xAFoBKCijen SRBIY65yq5m00cWx3RMIcQq1B0X8nJS0O1P7MYE/Vvidz5St/36RXVu1oWLeS5Fx HAezW0lqxEUzvC+uLTFWC6f/CilzmI7SsPAkZBk7dO5Qs0d7m/zWF588vlGS+0pt D1on+lU85Ma2zuAd0qmB6LY66N8pEKKtMk93wF/o4Z5i58ahbwNvTKAzz4JSRWSu mB9LAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcD AQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA upU0DjzvIvoCOYKbq1RRN7rPdYad39mfjxgkeV0iOF5JoIdO6y1C7XAm9lT69Wjm iUPvnCTMDYft40N2SvmXuuMaPOm4zjNwn4K33jw5XBnpwxC7By/Y0oV+Sl10fBsd QqXC6H7LcSGkv+4eJbgY33P4uH5ZAy+2TkHUuZDkpufkAshzBust7nDAjfv3AIuQ wlPoyZfI11eqyiOqRzOq+B5dIBr1JzKnEzSL6n0JLNQiPO7iN03rud/wYD3gbmcv rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2 7R4IbHGnj0BJA2vMYC4hSw== -----END CERTIFICATE----- ` func TestCertificateService_Get_issuerRelUp(t *testing.T) { server := tester.MockACMEServer(). Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) cert, issuer, err := core.Certificates.Get(server.URL+"/certificate", true) require.NoError(t, err) assert.Equal(t, certResponseMock, string(cert), "Certificate") assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate") } func TestCertificateService_Get_embeddedIssuer(t *testing.T) { server := tester.MockACMEServer(). Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) cert, issuer, err := core.Certificates.Get(server.URL+"/certificate", true) require.NoError(t, err) assert.Equal(t, certResponseMock, string(cert), "Certificate") assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate") } ================================================ FILE: acme/api/challenge.go ================================================ package api import ( "errors" "github.com/go-acme/lego/v4/acme" ) type ChallengeService service // New Creates a challenge. func (c *ChallengeService) New(chlgURL string) (acme.ExtendedChallenge, error) { if chlgURL == "" { return acme.ExtendedChallenge{}, errors.New("challenge[new]: empty URL") } // Challenge initiation is done by sending a JWS payload containing the trivial JSON object `{}`. // We use an empty struct instance as the postJSON payload here to achieve this result. var chlng acme.ExtendedChallenge resp, err := c.core.post(chlgURL, struct{}{}, &chlng) if err != nil { return acme.ExtendedChallenge{}, err } chlng.AuthorizationURL = getLink(resp.Header, "up") chlng.RetryAfter = getRetryAfter(resp) return chlng, nil } // Get Gets a challenge. func (c *ChallengeService) Get(chlgURL string) (acme.ExtendedChallenge, error) { if chlgURL == "" { return acme.ExtendedChallenge{}, errors.New("challenge[get]: empty URL") } var chlng acme.ExtendedChallenge resp, err := c.core.postAsGet(chlgURL, &chlng) if err != nil { return acme.ExtendedChallenge{}, err } chlng.AuthorizationURL = getLink(resp.Header, "up") chlng.RetryAfter = getRetryAfter(resp) return chlng, nil } ================================================ FILE: acme/api/identifier.go ================================================ package api import ( "cmp" "net" "slices" "github.com/go-acme/lego/v4/acme" ) func createIdentifiers(domains []string) []acme.Identifier { uniqIdentifiers := make(map[string]struct{}) var identifiers []acme.Identifier for _, domain := range domains { if _, ok := uniqIdentifiers[domain]; ok { continue } ident := acme.Identifier{Value: domain, Type: "dns"} if net.ParseIP(domain) != nil { ident.Type = "ip" } identifiers = append(identifiers, ident) uniqIdentifiers[domain] = struct{}{} } return identifiers } // compareIdentifiers compares 2 slices of [acme.Identifier]. func compareIdentifiers(a, b []acme.Identifier) int { // Clones slices to avoid modifying original slices. right := slices.Clone(a) left := slices.Clone(b) slices.SortStableFunc(right, compareIdentifier) slices.SortStableFunc(left, compareIdentifier) return slices.CompareFunc(right, left, compareIdentifier) } func compareIdentifier(right, left acme.Identifier) int { return cmp.Or( cmp.Compare(right.Type, left.Type), cmp.Compare(right.Value, left.Value), ) } ================================================ FILE: acme/api/identifier_test.go ================================================ package api import ( "testing" "github.com/go-acme/lego/v4/acme" "github.com/stretchr/testify/assert" ) func Test_compareIdentifiers(t *testing.T) { testCases := []struct { desc string a, b []acme.Identifier expected int }{ { desc: "identical identifiers", a: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, }, b: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, }, expected: 0, }, { desc: "identical identifiers but different order", a: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, }, b: []acme.Identifier{ {Type: "dns", Value: "*.example.com"}, {Type: "dns", Value: "example.com"}, }, expected: 0, }, { desc: "duplicate identifiers", a: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, }, b: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "example.com"}, }, expected: -1, }, { desc: "different identifier values", a: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, }, b: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.org"}, }, expected: -1, }, { desc: "different identifier types", a: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, }, b: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "ip", Value: "*.example.com"}, }, expected: -1, }, { desc: "different number of identifiers a>b", a: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, {Type: "dns", Value: "example.org"}, }, b: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, }, expected: 1, }, { desc: "different number of identifiers b>a", a: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, }, b: []acme.Identifier{ {Type: "dns", Value: "example.com"}, {Type: "dns", Value: "*.example.com"}, {Type: "dns", Value: "example.org"}, }, expected: -1, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() assert.Equal(t, test.expected, compareIdentifiers(test.a, test.b)) }) } } ================================================ FILE: acme/api/internal/nonces/nonce_manager.go ================================================ package nonces import ( "errors" "fmt" "net/http" "sync" "github.com/go-acme/lego/v4/acme/api/internal/sender" ) // Manager Manages nonces. type Manager struct { sync.Mutex do *sender.Doer nonceURL string nonces []string } // NewManager Creates a new Manager. func NewManager(do *sender.Doer, nonceURL string) *Manager { return &Manager{ do: do, nonceURL: nonceURL, } } // Pop Pops a nonce. func (n *Manager) Pop() (string, bool) { n.Lock() defer n.Unlock() if len(n.nonces) == 0 { return "", false } nonce := n.nonces[len(n.nonces)-1] n.nonces = n.nonces[:len(n.nonces)-1] return nonce, true } // Push Pushes a nonce. func (n *Manager) Push(nonce string) { n.Lock() defer n.Unlock() n.nonces = append(n.nonces, nonce) } // Nonce implement jose.NonceSource. func (n *Manager) Nonce() (string, error) { if nonce, ok := n.Pop(); ok { return nonce, nil } return n.getNonce() } func (n *Manager) getNonce() (string, error) { resp, err := n.do.Head(n.nonceURL) if err != nil { return "", fmt.Errorf("failed to get nonce from HTTP HEAD: %w", err) } return GetFromResponse(resp) } // GetFromResponse Extracts a nonce from an HTTP response. func GetFromResponse(resp *http.Response) (string, error) { if resp == nil { return "", errors.New("nil response") } nonce := resp.Header.Get("Replay-Nonce") if nonce == "" { return "", errors.New("server did not respond with a proper nonce header") } return nonce, nil } ================================================ FILE: acme/api/internal/nonces/nonce_manager_test.go ================================================ package nonces import ( "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api/internal/sender" "github.com/go-acme/lego/v4/platform/tester/servermock" ) func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) { manager := servermock.NewBuilder( func(server *httptest.Server) (*Manager, error) { doer := sender.NewDoer(server.Client(), "lego-test") return NewManager(doer, server.URL), nil }). Route("HEAD /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { time.Sleep(250 * time.Millisecond) rw.Header().Set("Replay-Nonce", "12345") rw.Header().Set("Retry-After", "0") servermock.JSONEncode(&acme.Challenge{Type: "http-01", Status: "Valid", URL: "https://example.com/", Token: "token"}).ServeHTTP(rw, req) })). BuildHTTPS(t) ch := make(chan bool) resultCh := make(chan bool) go func() { _, errN := manager.Nonce() if errN != nil { t.Log(errN) } ch <- true }() go func() { _, errN := manager.Nonce() if errN != nil { t.Log(errN) } ch <- true }() go func() { <-ch <-ch resultCh <- true }() select { case <-resultCh: case <-time.After(500 * time.Millisecond): t.Fatal("JWS is probably holding a lock while making HTTP request") } } ================================================ FILE: acme/api/internal/secure/jws.go ================================================ package secure import ( "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rsa" "encoding/base64" "fmt" "github.com/go-acme/lego/v4/acme/api/internal/nonces" jose "github.com/go-jose/go-jose/v4" ) // JWS Represents a JWS. type JWS struct { privKey crypto.PrivateKey kid string // Key identifier nonces *nonces.Manager } // NewJWS Create a new JWS. func NewJWS(privateKey crypto.PrivateKey, kid string, nonceManager *nonces.Manager) *JWS { return &JWS{ privKey: privateKey, nonces: nonceManager, kid: kid, } } // SetKid Sets a key identifier. func (j *JWS) SetKid(kid string) { j.kid = kid } // SignContent Signs a content with the JWS. func (j *JWS) SignContent(url string, content []byte) (*jose.JSONWebSignature, error) { var alg jose.SignatureAlgorithm switch k := j.privKey.(type) { case *rsa.PrivateKey: alg = jose.RS256 case *ecdsa.PrivateKey: if k.Curve == elliptic.P256() { alg = jose.ES256 } else if k.Curve == elliptic.P384() { alg = jose.ES384 } } signKey := jose.SigningKey{ Algorithm: alg, Key: jose.JSONWebKey{Key: j.privKey, KeyID: j.kid}, } options := jose.SignerOptions{ NonceSource: j.nonces, ExtraHeaders: map[jose.HeaderKey]any{ "url": url, }, } if j.kid == "" { options.EmbedJWK = true } signer, err := jose.NewSigner(signKey, &options) if err != nil { return nil, fmt.Errorf("failed to create jose signer: %w", err) } signed, err := signer.Sign(content) if err != nil { return nil, fmt.Errorf("failed to sign content: %w", err) } return signed, nil } // SignEABContent Signs an external account binding content with the JWS. func (j *JWS) SignEABContent(url, kid string, hmac []byte) (*jose.JSONWebSignature, error) { jwk := jose.JSONWebKey{Key: j.privKey} jwkJSON, err := jwk.Public().MarshalJSON() if err != nil { return nil, fmt.Errorf("acme: error encoding eab jwk key: %w", err) } signer, err := jose.NewSigner( jose.SigningKey{Algorithm: jose.HS256, Key: hmac}, &jose.SignerOptions{ EmbedJWK: false, ExtraHeaders: map[jose.HeaderKey]any{ "kid": kid, "url": url, }, }, ) if err != nil { return nil, fmt.Errorf("failed to create External Account Binding jose signer: %w", err) } signed, err := signer.Sign(jwkJSON) if err != nil { return nil, fmt.Errorf("failed to External Account Binding sign content: %w", err) } return signed, nil } // GetKeyAuthorization Gets the key authorization for a token. func (j *JWS) GetKeyAuthorization(token string) (string, error) { var publicKey crypto.PublicKey switch k := j.privKey.(type) { case *ecdsa.PrivateKey: publicKey = k.Public() case *rsa.PrivateKey: publicKey = k.Public() } // Generate the Key Authorization for the challenge jwk := &jose.JSONWebKey{Key: publicKey} thumbBytes, err := jwk.Thumbprint(crypto.SHA256) if err != nil { return "", err } // unpad the base64URL keyThumb := base64.RawURLEncoding.EncodeToString(thumbBytes) return token + "." + keyThumb, nil } ================================================ FILE: acme/api/internal/secure/jws_test.go ================================================ package secure import ( "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api/internal/nonces" "github.com/go-acme/lego/v4/acme/api/internal/sender" "github.com/go-acme/lego/v4/platform/tester/servermock" ) func TestNotHoldingLockWhileMakingHTTPRequests(t *testing.T) { manager := servermock.NewBuilder( func(server *httptest.Server) (*nonces.Manager, error) { doer := sender.NewDoer(server.Client(), "lego-test") return nonces.NewManager(doer, server.URL), nil }). Route("HEAD /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { time.Sleep(250 * time.Millisecond) rw.Header().Set("Replay-Nonce", "12345") rw.Header().Set("Retry-After", "0") servermock.JSONEncode(&acme.Challenge{Type: "http-01", Status: "Valid", URL: "https://example.com/", Token: "token"}).ServeHTTP(rw, req) })). BuildHTTPS(t) ch := make(chan bool) resultCh := make(chan bool) go func() { _, errN := manager.Nonce() if errN != nil { t.Log(errN) } ch <- true }() go func() { _, errN := manager.Nonce() if errN != nil { t.Log(errN) } ch <- true }() go func() { <-ch <-ch resultCh <- true }() select { case <-resultCh: case <-time.After(500 * time.Millisecond): t.Fatal("JWS is probably holding a lock while making HTTP request") } } ================================================ FILE: acme/api/internal/sender/sender.go ================================================ package sender import ( "encoding/json" "fmt" "io" "net/http" "runtime" "strings" "github.com/go-acme/lego/v4/acme" ) type RequestOption func(*http.Request) error func contentType(ct string) RequestOption { return func(req *http.Request) error { req.Header.Set("Content-Type", ct) return nil } } type Doer struct { httpClient *http.Client userAgent string } // NewDoer Creates a new Doer. func NewDoer(client *http.Client, userAgent string) *Doer { client.Transport = newHTTPSOnly(client) return &Doer{ httpClient: client, userAgent: userAgent, } } // Get performs a GET request with a proper User-Agent string. // If "response" is not provided, callers should close resp.Body when done reading from it. func (d *Doer) Get(url string, response any) (*http.Response, error) { req, err := d.newRequest(http.MethodGet, url, nil) if err != nil { return nil, err } return d.do(req, response) } // Head performs a HEAD request with a proper User-Agent string. // The response body (resp.Body) is already closed when this function returns. func (d *Doer) Head(url string) (*http.Response, error) { req, err := d.newRequest(http.MethodHead, url, nil) if err != nil { return nil, err } return d.do(req, nil) } // Post performs a POST request with a proper User-Agent string. // If "response" is not provided, callers should close resp.Body when done reading from it. func (d *Doer) Post(url string, body io.Reader, bodyType string, response any) (*http.Response, error) { req, err := d.newRequest(http.MethodPost, url, body, contentType(bodyType)) if err != nil { return nil, err } return d.do(req, response) } func (d *Doer) newRequest(method, uri string, body io.Reader, opts ...RequestOption) (*http.Request, error) { req, err := http.NewRequest(method, uri, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", d.formatUserAgent()) for _, opt := range opts { err = opt(req) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } } return req, nil } func (d *Doer) do(req *http.Request, response any) (*http.Response, error) { resp, err := d.httpClient.Do(req) if err != nil { return nil, err } if err = checkError(req, resp); err != nil { return resp, err } if response != nil { raw, err := io.ReadAll(resp.Body) if err != nil { return resp, err } defer resp.Body.Close() err = json.Unmarshal(raw, response) if err != nil { return resp, fmt.Errorf("failed to unmarshal %q to type %T: %w", raw, response, err) } } return resp, nil } // formatUserAgent builds and returns the User-Agent string to use in requests. func (d *Doer) formatUserAgent() string { ua := fmt.Sprintf("%s %s (%s; %s; %s)", d.userAgent, ourUserAgent, ourUserAgentComment, runtime.GOOS, runtime.GOARCH) return strings.TrimSpace(ua) } func checkError(req *http.Request, resp *http.Response) error { if resp.StatusCode < http.StatusBadRequest { return nil } body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("%d :: %s :: %s :: %w", resp.StatusCode, req.Method, req.URL, err) } var errorDetails *acme.ProblemDetails err = json.Unmarshal(body, &errorDetails) if err != nil { return fmt.Errorf("%d ::%s :: %s :: %w :: %s", resp.StatusCode, req.Method, req.URL, err, string(body)) } errorDetails.Method = req.Method errorDetails.URL = req.URL.String() if errorDetails.HTTPStatus == 0 { errorDetails.HTTPStatus = resp.StatusCode } // Check for errors we handle specifically switch { case errorDetails.HTTPStatus == http.StatusBadRequest && errorDetails.Type == acme.BadNonceErr: return &acme.NonceError{ProblemDetails: errorDetails} case errorDetails.HTTPStatus == http.StatusConflict && errorDetails.Type == acme.AlreadyReplacedErr: return &acme.AlreadyReplacedError{ProblemDetails: errorDetails} case errorDetails.HTTPStatus == http.StatusTooManyRequests && errorDetails.Type == acme.RateLimitedErr: return &acme.RateLimitedError{ ProblemDetails: errorDetails, RetryAfter: resp.Header.Get("Retry-After"), } default: return errorDetails } } type httpsOnly struct { rt http.RoundTripper } func newHTTPSOnly(client *http.Client) *httpsOnly { if client.Transport == nil { return &httpsOnly{rt: http.DefaultTransport} } return &httpsOnly{rt: client.Transport} } // RoundTrip ensure HTTPS is used. // Each ACME function is accomplished by the client sending a sequence of HTTPS requests to the server [RFC2818], // carrying JSON messages [RFC8259]. // Use of HTTPS is REQUIRED. // https://datatracker.ietf.org/doc/html/rfc8555#section-6.1 func (r *httpsOnly) RoundTrip(req *http.Request) (*http.Response, error) { if req.URL.Scheme != "https" { return nil, fmt.Errorf("HTTPS is required: %s", req.URL) } return r.rt.RoundTrip(req) } ================================================ FILE: acme/api/internal/sender/sender_test.go ================================================ package sender import ( "bytes" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-acme/lego/v4/acme" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDo_UserAgentOnAllHTTPMethod(t *testing.T) { var ua, method string server := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { ua = r.Header.Get("User-Agent") method = r.Method })) t.Cleanup(server.Close) doer := NewDoer(server.Client(), "") testCases := []struct { method string call func(u string) (*http.Response, error) }{ { method: http.MethodGet, call: func(u string) (*http.Response, error) { return doer.Get(u, nil) }, }, { method: http.MethodHead, call: doer.Head, }, { method: http.MethodPost, call: func(u string) (*http.Response, error) { return doer.Post(u, strings.NewReader("falalalala"), "text/plain", nil) }, }, } for _, test := range testCases { t.Run(test.method, func(t *testing.T) { _, err := test.call(server.URL) require.NoError(t, err) assert.Equal(t, test.method, method) assert.Contains(t, ua, ourUserAgent, "User-Agent") }) } } func TestDo_CustomUserAgent(t *testing.T) { customUA := "MyApp/1.2.3" doer := NewDoer(http.DefaultClient, customUA) ua := doer.formatUserAgent() assert.Contains(t, ua, ourUserAgent) assert.Contains(t, ua, customUA) if strings.HasSuffix(ua, " ") { t.Errorf("UA should not have trailing spaces; got '%s'", ua) } assert.Len(t, strings.Split(ua, " "), 5) } func TestDo_failWithHTTP(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) t.Cleanup(server.Close) sender := NewDoer(server.Client(), "test") _, err := sender.Post(server.URL, strings.NewReader("data"), "text/plain", nil) require.ErrorContains(t, err, "HTTPS is required: http://") } func Test_checkError(t *testing.T) { testCases := []struct { desc string resp *http.Response assert func(t *testing.T, err error) }{ { desc: "default", resp: &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:example","detail":"message","status":404}`)), }, assert: errorAs[*acme.ProblemDetails], }, { desc: "badNonce", resp: &http.Response{ StatusCode: http.StatusBadRequest, Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:badNonce","detail":"message","status":400}`)), }, assert: errorAs[*acme.NonceError], }, { desc: "alreadyReplaced", resp: &http.Response{ StatusCode: http.StatusConflict, Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:alreadyReplaced","detail":"message","status":409}`)), }, assert: errorAs[*acme.AlreadyReplacedError], }, { desc: "rateLimited", resp: &http.Response{ StatusCode: http.StatusConflict, Header: http.Header{ "Retry-After": []string{"1"}, }, Body: io.NopCloser(bytes.NewBufferString(`{"type":"urn:ietf:params:acme:error:rateLimited","detail":"message","status":429}`)), }, assert: errorAs[*acme.RateLimitedError], }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "https://example.com", nil) err := checkError(req, test.resp) require.Error(t, err) pb := &acme.ProblemDetails{} assert.ErrorAs(t, err, &pb) test.assert(t, err) }) } } func errorAs[T error](t *testing.T, err error) { t.Helper() var zero T assert.ErrorAs(t, err, &zero) } ================================================ FILE: acme/api/internal/sender/useragent.go ================================================ // Code generated by 'internal/releaser'; DO NOT EDIT. package sender const ( // ourUserAgent is the User-Agent of this underlying library package. ourUserAgent = "xenolf-acme/4.33.0" // ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package. // values: detach|release // NOTE: Update this with each tagged release. ourUserAgentComment = "detach" ) ================================================ FILE: acme/api/order.go ================================================ package api import ( "encoding/base64" "errors" "fmt" "slices" "time" "github.com/go-acme/lego/v4/acme" ) // OrderOptions used to create an order (optional). type OrderOptions struct { NotBefore time.Time NotAfter time.Time // A string uniquely identifying the profile // which will be used to affect issuance of the certificate requested by this Order. // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4 Profile string // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 ReplacesCertID string } type OrderService service // New Creates a new order. func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) { return o.NewWithOptions(domains, nil) } // NewWithOptions Creates a new order. func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acme.ExtendedOrder, error) { orderReq := acme.Order{Identifiers: createIdentifiers(domains)} if opts != nil { if !opts.NotAfter.IsZero() { orderReq.NotAfter = opts.NotAfter.Format(time.RFC3339) } if !opts.NotBefore.IsZero() { orderReq.NotBefore = opts.NotBefore.Format(time.RFC3339) } if o.core.GetDirectory().RenewalInfo != "" { orderReq.Replaces = opts.ReplacesCertID } if opts.Profile != "" { orderReq.Profile = opts.Profile } } var order acme.Order resp, err := o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order) if err != nil { are := &acme.AlreadyReplacedError{} if !errors.As(err, &are) { return acme.ExtendedOrder{}, err } // If the Server rejects the request because the identified certificate has already been marked as replaced, // it MUST return an HTTP 409 (Conflict) with a problem document of type "alreadyReplaced" (see Section 7.4). // https://www.rfc-editor.org/rfc/rfc9773.html#section-5 orderReq.Replaces = "" resp, err = o.core.post(o.core.GetDirectory().NewOrderURL, orderReq, &order) if err != nil { return acme.ExtendedOrder{}, err } } // The server MUST return an error if it cannot fulfill the request as specified, // and it MUST NOT issue a certificate with contents other than those requested. // If the server requires the request to be modified in a certain way, // it should indicate the required changes using an appropriate error type and description. // https://www.rfc-editor.org/rfc/rfc8555#section-7.4 // // Some ACME servers don't return an error, // and/or change the order identifiers in the response, // so we need to ensure that the identifiers are the same as requested. // Deduplication by the server is allowed. if compareIdentifiers(orderReq.Identifiers, order.Identifiers) != 0 { // Sorts identifiers to avoid error message ambiguities about the order of the identifiers. slices.SortStableFunc(orderReq.Identifiers, compareIdentifier) slices.SortStableFunc(order.Identifiers, compareIdentifier) return acme.ExtendedOrder{}, fmt.Errorf("order identifiers have been modified by the ACME server (RFC8555 §7.4): %+v != %+v", orderReq.Identifiers, order.Identifiers) } return acme.ExtendedOrder{ Order: order, Location: resp.Header.Get("Location"), }, nil } // Get Gets an order. func (o *OrderService) Get(orderURL string) (acme.ExtendedOrder, error) { if orderURL == "" { return acme.ExtendedOrder{}, errors.New("order[get]: empty URL") } var order acme.Order _, err := o.core.postAsGet(orderURL, &order) if err != nil { return acme.ExtendedOrder{}, err } return acme.ExtendedOrder{Order: order}, nil } // UpdateForCSR Updates an order for a CSR. func (o *OrderService) UpdateForCSR(orderURL string, csr []byte) (acme.ExtendedOrder, error) { csrMsg := acme.CSRMessage{ Csr: base64.RawURLEncoding.EncodeToString(csr), } var order acme.Order _, err := o.core.post(orderURL, csrMsg, &order) if err != nil { return acme.ExtendedOrder{}, err } if order.Status == acme.StatusInvalid { return acme.ExtendedOrder{}, fmt.Errorf("invalid order: %w", order.Err()) } return acme.ExtendedOrder{Order: order}, nil } ================================================ FILE: acme/api/order_test.go ================================================ package api import ( "crypto/rand" "crypto/rsa" "encoding/json" "io" "net/http" "testing" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestOrderService_NewWithOptions(t *testing.T) { // small value keeps test fast privateKey, errK := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, errK, "Could not generate test key") server := tester.MockACMEServer(). Route("POST /newOrder", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { body, err := readSignedBody(req, privateKey) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } order := acme.Order{} err = json.Unmarshal(body, &order) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } servermock.JSONEncode(acme.Order{ Status: acme.StatusValid, Expires: order.Expires, Identifiers: order.Identifiers, Profile: order.Profile, NotBefore: order.NotBefore, NotAfter: order.NotAfter, Error: order.Error, Authorizations: order.Authorizations, Finalize: order.Finalize, Certificate: order.Certificate, Replaces: order.Replaces, }).ServeHTTP(rw, req) })). BuildHTTPS(t) core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { desc string opts *OrderOptions expected acme.ExtendedOrder }{ { desc: "simple", expected: acme.ExtendedOrder{ Order: acme.Order{ Status: "valid", Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}}, }, }, }, { desc: "with options", opts: &OrderOptions{ NotBefore: time.Date(2023, 1, 1, 1, 0, 0, 0, time.UTC), NotAfter: time.Date(2023, 1, 2, 1, 0, 0, 0, time.UTC), }, expected: acme.ExtendedOrder{ Order: acme.Order{ Status: "valid", Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}}, NotBefore: "2023-01-01T01:00:00Z", NotAfter: "2023-01-02T01:00:00Z", }, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() order, err := core.Orders.NewWithOptions([]string{"example.com"}, test.opts) require.NoError(t, err) assert.Equal(t, test.expected, order) }) } } func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) { reqBody, err := io.ReadAll(r.Body) if err != nil { return nil, err } sigAlgs := []jose.SignatureAlgorithm{jose.RS256} jws, err := jose.ParseSigned(string(reqBody), sigAlgs) if err != nil { return nil, err } body, err := jws.Verify(&jose.JSONWebKey{ Key: privateKey.Public(), Algorithm: "RSA", }) if err != nil { return nil, err } return body, nil } ================================================ FILE: acme/api/renewal.go ================================================ package api import ( "errors" "net/http" ) // ErrNoARI is returned when the server does not advertise a renewal info endpoint. var ErrNoARI = errors.New("renewalInfo[get/post]: server does not advertise a renewal info endpoint") // GetRenewalInfo GETs renewal information for a certificate from the renewalInfo endpoint. // This is used to determine if a certificate needs to be renewed. // // Note: this endpoint is part of a draft specification, not all ACME servers will implement it. // This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint. // // https://www.rfc-editor.org/rfc/rfc9773.html func (c *CertificateService) GetRenewalInfo(certID string) (*http.Response, error) { if c.core.GetDirectory().RenewalInfo == "" { return nil, ErrNoARI } if certID == "" { return nil, errors.New("renewalInfo[get]: 'certID' cannot be empty") } return c.core.HTTPClient.Get(c.core.GetDirectory().RenewalInfo + "/" + certID) } ================================================ FILE: acme/api/service.go ================================================ package api import ( "fmt" "net/http" "regexp" "strconv" "time" ) type service struct { core *Core } // getLink get a rel into the Link header. func getLink(header http.Header, rel string) string { links := getLinks(header, rel) if len(links) < 1 { return "" } return links[0] } func getLinks(header http.Header, rel string) []string { linkExpr := regexp.MustCompile(`<(.+?)>(?:;[^;]+)*?;\s*rel="(.+?)"`) var links []string for _, link := range header["Link"] { for _, m := range linkExpr.FindAllStringSubmatch(link, -1) { if len(m) != 3 { continue } if m[2] == rel { links = append(links, m[1]) } } } return links } // getLocation get the value of the header Location. func getLocation(resp *http.Response) string { if resp == nil { return "" } return resp.Header.Get("Location") } // getRetryAfter get the value of the header Retry-After. func getRetryAfter(resp *http.Response) string { if resp == nil { return "" } return resp.Header.Get("Retry-After") } // ParseRetryAfter parses the Retry-After header value according to RFC 7231. // The header can be either delay-seconds (numeric) or HTTP-date (RFC 1123 format). // https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3 // Returns the duration until the retry time. // TODO(ldez): unexposed this function in v5. func ParseRetryAfter(value string) (time.Duration, error) { if value == "" { return 0, nil } if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { return time.Duration(seconds) * time.Second, nil } if retryTime, err := time.Parse(time.RFC1123, value); err == nil { duration := time.Until(retryTime) if duration < 0 { return 0, nil } return duration, nil } return 0, fmt.Errorf("invalid Retry-After value: %q", value) } ================================================ FILE: acme/api/service_test.go ================================================ package api import ( "net/http" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_getLink(t *testing.T) { testCases := []struct { desc string header http.Header relName string expected string }{ { desc: "success", header: http.Header{ "Link": []string{`; rel="next", ; rel="up"`}, }, relName: "up", expected: "https://acme-staging-v02.api.letsencrypt.org/up?query", }, { desc: "success several lines", header: http.Header{ "Link": []string{`; rel="next"`, `; rel="up"`}, }, relName: "up", expected: "https://acme-staging-v02.api.letsencrypt.org/up?query", }, { desc: "no link", header: http.Header{}, relName: "up", expected: "", }, { desc: "no header", relName: "up", expected: "", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() link := getLink(test.header, test.relName) assert.Equal(t, test.expected, link) }) } } func TestParseRetryAfter(t *testing.T) { testCases := []struct { desc string value string expected time.Duration }{ { desc: "empty header value", value: "", expected: time.Duration(0), }, { desc: "delay-seconds", value: "123", expected: 123 * time.Second, }, { desc: "HTTP-date", value: time.Now().Add(3 * time.Second).Format(time.RFC1123), expected: 3 * time.Second, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() rt, err := ParseRetryAfter(test.value) require.NoError(t, err) assert.InDelta(t, test.expected.Seconds(), rt.Seconds(), 1) }) } } ================================================ FILE: acme/commons.go ================================================ // Package acme contains all objects related the ACME endpoints. // https://www.rfc-editor.org/rfc/rfc8555.html package acme import ( "encoding/json" "time" ) // ACME status values of Account, Order, Authorization and Challenge objects. // See https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.6 for details. const ( StatusDeactivated = "deactivated" StatusExpired = "expired" StatusInvalid = "invalid" StatusPending = "pending" StatusProcessing = "processing" StatusReady = "ready" StatusRevoked = "revoked" StatusUnknown = "unknown" StatusValid = "valid" ) // CRL reason codes as defined in RFC 5280. // https://datatracker.ietf.org/doc/html/rfc5280#section-5.3.1 const ( CRLReasonUnspecified uint = 0 CRLReasonKeyCompromise uint = 1 CRLReasonCACompromise uint = 2 CRLReasonAffiliationChanged uint = 3 CRLReasonSuperseded uint = 4 CRLReasonCessationOfOperation uint = 5 CRLReasonCertificateHold uint = 6 CRLReasonRemoveFromCRL uint = 8 CRLReasonPrivilegeWithdrawn uint = 9 CRLReasonAACompromise uint = 10 ) // Directory the ACME directory object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1 // - https://www.rfc-editor.org/rfc/rfc9773.html type Directory struct { NewNonceURL string `json:"newNonce"` NewAccountURL string `json:"newAccount"` NewOrderURL string `json:"newOrder"` NewAuthzURL string `json:"newAuthz"` RevokeCertURL string `json:"revokeCert"` KeyChangeURL string `json:"keyChange"` Meta Meta `json:"meta"` RenewalInfo string `json:"renewalInfo"` } // Meta the ACME meta object (related to Directory). // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1 type Meta struct { // termsOfService (optional, string): // A URL identifying the current terms of service. TermsOfService string `json:"termsOfService"` // website (optional, string): // An HTTP or HTTPS URL locating a website providing more information about the ACME server. Website string `json:"website"` // caaIdentities (optional, array of string): // The hostnames that the ACME server recognizes as referring to itself // for the purposes of CAA record validation as defined in [RFC6844]. // Each string MUST represent the same sequence of ASCII code points // that the server will expect to see as the "Issuer Domain Name" in a CAA issue or issuewild property tag. // This allows clients to determine the correct issuer domain name to use when configuring CAA records. CaaIdentities []string `json:"caaIdentities"` // externalAccountRequired (optional, boolean): // If this field is present and set to "true", // then the CA requires that all new-account requests include an "externalAccountBinding" field // associating the new account with an external account. ExternalAccountRequired bool `json:"externalAccountRequired"` // profiles (optional, object): // A map of profile names to human-readable descriptions of those profiles. // https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-3 Profiles map[string]string `json:"profiles"` } // ExtendedAccount an extended Account. type ExtendedAccount struct { Account // Contains the value of the response header `Location` Location string `json:"-"` } // Account the ACME account Object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.2 // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3 type Account struct { // status (required, string): // The status of this account. // Possible values are: "valid", "deactivated", and "revoked". // The value "deactivated" should be used to indicate client-initiated deactivation // whereas "revoked" should be used to indicate server-initiated deactivation. (See Section 7.1.6) Status string `json:"status,omitempty"` // contact (optional, array of string): // An array of URLs that the server can use to contact the client for issues related to this account. // For example, the server may wish to notify the client about server-initiated revocation or certificate expiration. // For information on supported URL schemes, see Section 7.3 Contact []string `json:"contact,omitempty"` // termsOfServiceAgreed (optional, boolean): // Including this field in a new-account request, // with a value of true, indicates the client's agreement with the terms of service. // This field is not updateable by the client. TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"` // orders (required, string): // A URL from which a list of orders submitted by this account can be fetched via a POST-as-GET request, // as described in Section 7.1.2.1. Orders string `json:"orders,omitempty"` // onlyReturnExisting (optional, boolean): // If this field is present with the value "true", // then the server MUST NOT create a new account if one does not already exist. // This allows a client to look up an account URL based on an account key (see Section 7.3.1). OnlyReturnExisting bool `json:"onlyReturnExisting,omitempty"` // externalAccountBinding (optional, object): // An optional field for binding the new account with an existing non-ACME account (see Section 7.3.4). ExternalAccountBinding json.RawMessage `json:"externalAccountBinding,omitempty"` } // ExtendedOrder a extended Order. type ExtendedOrder struct { Order // The order URL, contains the value of the response header `Location` Location string `json:"-"` } // Order the ACME order Object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.3 type Order struct { // status (required, string): // The status of this order. // Possible values are: "pending", "ready", "processing", "valid", and "invalid". Status string `json:"status,omitempty"` // expires (optional, string): // The timestamp after which the server will consider this order invalid, // encoded in the format specified in RFC 3339 [RFC3339]. // This field is REQUIRED for objects with "pending" or "valid" in the status field. Expires string `json:"expires,omitempty"` // identifiers (required, array of object): // An array of identifier objects that the order pertains to. Identifiers []Identifier `json:"identifiers"` // profile (string, optional): // A string uniquely identifying the profile // which will be used to affect issuance of the certificate requested by this Order. // https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4 Profile string `json:"profile,omitempty"` // notBefore (optional, string): // The requested value of the notBefore field in the certificate, // in the date format defined in [RFC3339]. NotBefore string `json:"notBefore,omitempty"` // notAfter (optional, string): // The requested value of the notAfter field in the certificate, // in the date format defined in [RFC3339]. NotAfter string `json:"notAfter,omitempty"` // error (optional, object): // The error that occurred while processing the order, if any. // This field is structured as a problem document [RFC7807]. Error *ProblemDetails `json:"error,omitempty"` // authorizations (required, array of string): // For pending orders, // the authorizations that the client needs to complete before the requested certificate can be issued (see Section 7.5), // including unexpired authorizations that the client has completed in the past for identifiers specified in the order. // The authorizations required are dictated by server policy // and there may not be a 1:1 relationship between the order identifiers and the authorizations required. // For final orders (in the "valid" or "invalid" state), the authorizations that were completed. // Each entry is a URL from which an authorization can be fetched with a POST-as-GET request. Authorizations []string `json:"authorizations,omitempty"` // finalize (required, string): // A URL that a CSR must be POSTed to once all of the order's authorizations are satisfied to finalize the order. // The result of a successful finalization will be the population of the certificate URL for the order. Finalize string `json:"finalize,omitempty"` // certificate (optional, string): // A URL for the certificate that has been issued in response to this order Certificate string `json:"certificate,omitempty"` // replaces (optional, string): // replaces (string, optional): A string uniquely identifying a // previously-issued certificate which this order is intended to replace. // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 Replaces string `json:"replaces,omitempty"` } func (r *Order) Err() error { if r.Error != nil { return r.Error } return nil } // Authorization the ACME authorization object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.4 type Authorization struct { // status (required, string): // The status of this authorization. // Possible values are: "pending", "valid", "invalid", "deactivated", "expired", and "revoked". Status string `json:"status"` // expires (optional, string): // The timestamp after which the server will consider this authorization invalid, // encoded in the format specified in RFC 3339 [RFC3339]. // This field is REQUIRED for objects with "valid" in the "status" field. Expires time.Time `json:"expires,omitzero"` // identifier (required, object): // The identifier that the account is authorized to represent Identifier Identifier `json:"identifier"` // challenges (required, array of objects): // For pending authorizations, the challenges that the client can fulfill in order to prove possession of the identifier. // For valid authorizations, the challenge that was validated. // For invalid authorizations, the challenge that was attempted and failed. // Each array entry is an object with parameters required to validate the challenge. // A client should attempt to fulfill one of these challenges, // and a server should consider any one of the challenges sufficient to make the authorization valid. Challenges []Challenge `json:"challenges,omitempty"` // wildcard (optional, boolean): // For authorizations created as a result of a newOrder request containing a DNS identifier // with a value that contained a wildcard prefix this field MUST be present, and true. Wildcard bool `json:"wildcard,omitempty"` } // ExtendedChallenge a extended Challenge. type ExtendedChallenge struct { Challenge // Contains the value of the response header `Retry-After` RetryAfter string `json:"-"` // Contains the value of the response header `Link` rel="up" AuthorizationURL string `json:"-"` } // Challenge the ACME challenge object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.5 // - https://www.rfc-editor.org/rfc/rfc8555.html#section-8 type Challenge struct { // type (required, string): // The type of challenge encoded in the object. Type string `json:"type"` // url (required, string): // The URL to which a response can be posted. URL string `json:"url"` // status (required, string): // The status of this challenge. Possible values are: "pending", "processing", "valid", and "invalid". Status string `json:"status"` // validated (optional, string): // The time at which the server validated this challenge, // encoded in the format specified in RFC 3339 [RFC3339]. // This field is REQUIRED if the "status" field is "valid". Validated time.Time `json:"validated,omitzero"` // error (optional, object): // Error that occurred while the server was validating the challenge, if any, // structured as a problem document [RFC7807]. // Multiple errors can be indicated by using subproblems Section 6.7.1. // A challenge object with an error MUST have status equal to "invalid". Error *ProblemDetails `json:"error,omitempty"` // token (required, string): // A random value that uniquely identifies the challenge. // This value MUST have at least 128 bits of entropy. // It MUST NOT contain any characters outside the base64url alphabet, // and MUST NOT include base64 padding characters ("="). // See [RFC4086] for additional information on randomness requirements. // https://www.rfc-editor.org/rfc/rfc8555.html#section-8.3 // https://www.rfc-editor.org/rfc/rfc8555.html#section-8.4 Token string `json:"token"` // https://www.rfc-editor.org/rfc/rfc8555.html#section-8.1 KeyAuthorization string `json:"keyAuthorization"` } func (c *Challenge) Err() error { if c.Error != nil { return c.Error } return nil } // Identifier the ACME identifier object. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-9.7.7 type Identifier struct { Type string `json:"type"` Value string `json:"value"` } // CSRMessage Certificate Signing Request. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4 type CSRMessage struct { // csr (required, string): // A CSR encoding the parameters for the certificate being requested [RFC2986]. // The CSR is sent in the base64url-encoded version of the DER format. // (Note: Because this field uses base64url, and does not include headers, it is different from PEM.). Csr string `json:"csr"` } // RevokeCertMessage a certificate revocation message. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.6 // - https://www.rfc-editor.org/rfc/rfc5280.html#section-5.3.1 type RevokeCertMessage struct { // certificate (required, string): // The certificate to be revoked, in the base64url-encoded version of the DER format. // (Note: Because this field uses base64url, and does not include headers, it is different from PEM.) Certificate string `json:"certificate"` // reason (optional, int): // One of the revocation reasonCodes defined in Section 5.3.1 of [RFC5280] to be used when generating OCSP responses and CRLs. // If this field is not set the server SHOULD omit the reasonCode CRL entry extension when generating OCSP responses and CRLs. // The server MAY disallow a subset of reasonCodes from being used by the user. // If a request contains a disallowed reasonCode the server MUST reject it with the error type "urn:ietf:params:acme:error:badRevocationReason". // The problem document detail SHOULD indicate which reasonCodes are allowed. Reason *uint `json:"reason,omitempty"` } // RawCertificate raw data of a certificate. type RawCertificate struct { Cert []byte Issuer []byte } // Window is a window of time. type Window struct { Start time.Time `json:"start"` End time.Time `json:"end"` } // RenewalInfoResponse is the response to GET requests made the renewalInfo endpoint. // - (4.1. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html type RenewalInfoResponse struct { // SuggestedWindow contains two fields, start and end, // whose values are timestamps which bound the window of time in which the CA recommends renewing the certificate. SuggestedWindow Window `json:"suggestedWindow"` // ExplanationURL is an optional URL pointing to a page which may explain why the suggested renewal window is what it is. // For example, it may be a page explaining the CA's dynamic load-balancing strategy, // or a page documenting which certificates are affected by a mass revocation event. // Callers SHOULD provide this URL to their operator, if present. ExplanationURL string `json:"explanationURL"` } // RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint. // - (4.2. RenewalInfo Objects) https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2 type RenewalInfoUpdateRequest struct { // CertID is a composite string in the format: base64url(AKI) || '.' || base64url(Serial), where AKI is the // certificate's authority key identifier and Serial is the certificate's serial number. For details, see: // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.1 CertID string `json:"certID"` // Replaced is required and indicates whether or not the client considers the certificate to have been replaced. // A certificate is considered replaced when its revocation would not disrupt any ongoing services, // for instance because it has been renewed and the new certificate is in use, or because it is no longer in use. // Clients SHOULD NOT send a request where this value is false. Replaced bool `json:"replaced"` } ================================================ FILE: acme/errors.go ================================================ package acme import ( "fmt" "strings" ) // Errors types. const ( errNS = "urn:ietf:params:acme:error:" BadNonceErr = errNS + "badNonce" AlreadyReplacedErr = errNS + "alreadyReplaced" RateLimitedErr = errNS + "rateLimited" ) // ProblemDetails the problem details object. // - https://www.rfc-editor.org/rfc/rfc7807.html#section-3.1 // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.3 type ProblemDetails struct { Type string `json:"type,omitempty"` Detail string `json:"detail,omitempty"` HTTPStatus int `json:"status,omitempty"` Instance string `json:"instance,omitempty"` SubProblems []SubProblem `json:"subproblems,omitempty"` // additional values to have a better error message (Not defined by the RFC) Method string `json:"method,omitempty"` URL string `json:"url,omitempty"` } func (p *ProblemDetails) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "acme: error: %d", p.HTTPStatus) if p.Method != "" || p.URL != "" { _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Method, p.URL) } _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Type, p.Detail) for _, sub := range p.SubProblems { _, _ = fmt.Fprintf(msg, ", problem: %q :: %s", sub.Type, sub.Detail) } if p.Instance != "" { msg.WriteString(", url: " + p.Instance) } return msg.String() } // SubProblem a "subproblems". // - https://www.rfc-editor.org/rfc/rfc8555.html#section-6.7.1 type SubProblem struct { Type string `json:"type,omitempty"` Detail string `json:"detail,omitempty"` Identifier Identifier `json:"identifier"` } // NonceError represents the error which is returned // if the nonce sent by the client was not accepted by the server. type NonceError struct { *ProblemDetails } func (e *NonceError) Unwrap() error { return e.ProblemDetails } // AlreadyReplacedError represents the error which is returned // if the Server rejects the request because the identified certificate has already been marked as replaced. // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 type AlreadyReplacedError struct { *ProblemDetails } func (e *AlreadyReplacedError) Unwrap() error { return e.ProblemDetails } // RateLimitedError represents the error which is returned // if the server rejects the request because the client has exceeded the rate limit. // - https://www.rfc-editor.org/rfc/rfc8555.html#section-6.6 type RateLimitedError struct { *ProblemDetails RetryAfter string } func (e *RateLimitedError) Unwrap() error { return e.ProblemDetails } ================================================ FILE: buildx.Dockerfile ================================================ # syntax=docker/dockerfile:1.4 FROM alpine:3 ARG TARGETPLATFORM RUN apk --no-cache --no-progress add git ca-certificates tzdata \ && rm -rf /var/cache/apk/* COPY $TARGETPLATFORM/lego / ENTRYPOINT ["/lego"] EXPOSE 80 ================================================ FILE: certcrypto/crypto.go ================================================ package certcrypto import ( "crypto" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" "encoding/pem" "errors" "fmt" "math/big" "net" "slices" "strings" "time" "golang.org/x/crypto/ocsp" ) // Constants for all key types we support. const ( EC256 = KeyType("P256") EC384 = KeyType("P384") RSA2048 = KeyType("2048") RSA3072 = KeyType("3072") RSA4096 = KeyType("4096") RSA8192 = KeyType("8192") ) const ( // OCSPGood means that the certificate is valid. OCSPGood = ocsp.Good // OCSPRevoked means that the certificate has been deliberately revoked. OCSPRevoked = ocsp.Revoked // OCSPUnknown means that the OCSP responder doesn't know about the certificate. OCSPUnknown = ocsp.Unknown // OCSPServerFailed means that the OCSP responder failed to process the request. OCSPServerFailed = ocsp.ServerFailed ) // Constants for OCSP must staple. var ( tlsFeatureExtensionOID = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24} ocspMustStapleFeature = []byte{0x30, 0x03, 0x02, 0x01, 0x05} ) // KeyType represents the key algo as well as the key size or curve to use. type KeyType string type DERCertificateBytes []byte // ParsePEMBundle parses a certificate bundle from top to bottom and returns // a slice of x509 certificates. This function will error if no certificates are found. func ParsePEMBundle(bundle []byte) ([]*x509.Certificate, error) { var ( certificates []*x509.Certificate certDERBlock *pem.Block ) for { certDERBlock, bundle = pem.Decode(bundle) if certDERBlock == nil { break } if certDERBlock.Type == "CERTIFICATE" { cert, err := x509.ParseCertificate(certDERBlock.Bytes) if err != nil { return nil, err } certificates = append(certificates, cert) } } if len(certificates) == 0 { return nil, errors.New("no certificates were found while parsing the bundle") } return certificates, nil } // ParsePEMPrivateKey parses a private key from key, which is a PEM block. // Borrowed from Go standard library, to handle various private key and PEM block types. // https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L291-L308 // https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L238 func ParsePEMPrivateKey(key []byte) (crypto.PrivateKey, error) { keyBlockDER, _ := pem.Decode(key) if keyBlockDER == nil { return nil, errors.New("invalid PEM block") } if keyBlockDER.Type != "PRIVATE KEY" && !strings.HasSuffix(keyBlockDER.Type, " PRIVATE KEY") { return nil, fmt.Errorf("unknown PEM header %q", keyBlockDER.Type) } if key, err := x509.ParsePKCS1PrivateKey(keyBlockDER.Bytes); err == nil { return key, nil } if key, err := x509.ParsePKCS8PrivateKey(keyBlockDER.Bytes); err == nil { switch key := key.(type) { case *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey: return key, nil default: return nil, fmt.Errorf("found unknown private key type in PKCS#8 wrapping: %T", key) } } if key, err := x509.ParseECPrivateKey(keyBlockDER.Bytes); err == nil { return key, nil } return nil, errors.New("failed to parse private key") } func GeneratePrivateKey(keyType KeyType) (crypto.PrivateKey, error) { switch keyType { case EC256: return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) case EC384: return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) case RSA2048: return rsa.GenerateKey(rand.Reader, 2048) case RSA3072: return rsa.GenerateKey(rand.Reader, 3072) case RSA4096: return rsa.GenerateKey(rand.Reader, 4096) case RSA8192: return rsa.GenerateKey(rand.Reader, 8192) } return nil, fmt.Errorf("invalid KeyType: %s", keyType) } // Deprecated: uses [CreateCSR] instead. func GenerateCSR(privateKey crypto.PrivateKey, domain string, san []string, mustStaple bool) ([]byte, error) { return CreateCSR(privateKey, CSROptions{ Domain: domain, SAN: san, MustStaple: mustStaple, }) } type CSROptions struct { Domain string SAN []string MustStaple bool EmailAddresses []string } func CreateCSR(privateKey crypto.PrivateKey, opts CSROptions) ([]byte, error) { var ( dnsNames []string ipAddresses []net.IP ) for _, altname := range opts.SAN { if ip := net.ParseIP(altname); ip != nil { ipAddresses = append(ipAddresses, ip) } else { dnsNames = append(dnsNames, altname) } } template := x509.CertificateRequest{ Subject: pkix.Name{CommonName: opts.Domain}, DNSNames: dnsNames, EmailAddresses: opts.EmailAddresses, IPAddresses: ipAddresses, } if opts.MustStaple { template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{ Id: tlsFeatureExtensionOID, Value: ocspMustStapleFeature, }) } return x509.CreateCertificateRequest(rand.Reader, &template, privateKey) } func PEMEncode(data any) []byte { return pem.EncodeToMemory(PEMBlock(data)) } func PEMBlock(data any) *pem.Block { var pemBlock *pem.Block switch key := data.(type) { case *ecdsa.PrivateKey: keyBytes, _ := x509.MarshalECPrivateKey(key) pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes} case *rsa.PrivateKey: pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} case *x509.CertificateRequest: pemBlock = &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: key.Raw} case DERCertificateBytes: pemBlock = &pem.Block{Type: "CERTIFICATE", Bytes: []byte(data.(DERCertificateBytes))} } return pemBlock } func pemDecode(data []byte) (*pem.Block, error) { pemBlock, _ := pem.Decode(data) if pemBlock == nil { return nil, errors.New("PEM decode did not yield a valid block. Is the certificate in the right format?") } return pemBlock, nil } func PemDecodeTox509CSR(data []byte) (*x509.CertificateRequest, error) { pemBlock, err := pemDecode(data) if pemBlock == nil { return nil, err } if pemBlock.Type != "CERTIFICATE REQUEST" && pemBlock.Type != "NEW CERTIFICATE REQUEST" { return nil, errors.New("PEM block is not a certificate request") } return x509.ParseCertificateRequest(pemBlock.Bytes) } // ParsePEMCertificate returns Certificate from a PEM encoded certificate. // The certificate has to be PEM encoded. Any other encodings like DER will fail. func ParsePEMCertificate(cert []byte) (*x509.Certificate, error) { pemBlock, err := pemDecode(cert) if pemBlock == nil { return nil, err } // from a DER encoded certificate return x509.ParseCertificate(pemBlock.Bytes) } func GetCertificateMainDomain(cert *x509.Certificate) (string, error) { return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses) } func GetCSRMainDomain(cert *x509.CertificateRequest) (string, error) { return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses) } func getMainDomain(subject pkix.Name, dnsNames []string, ips []net.IP) (string, error) { if subject.CommonName == "" && len(dnsNames) == 0 && len(ips) == 0 { return "", errors.New("missing domain") } if subject.CommonName != "" { return subject.CommonName, nil } if len(dnsNames) > 0 { return dnsNames[0], nil } return ips[0].String(), nil } func ExtractDomains(cert *x509.Certificate) []string { var domains []string if cert.Subject.CommonName != "" { domains = append(domains, cert.Subject.CommonName) } // Check for SAN certificate for _, sanDomain := range cert.DNSNames { if sanDomain == cert.Subject.CommonName { continue } domains = append(domains, sanDomain) } commonNameIP := net.ParseIP(cert.Subject.CommonName) for _, sanIP := range cert.IPAddresses { if !commonNameIP.Equal(sanIP) { domains = append(domains, sanIP.String()) } } return domains } func ExtractDomainsCSR(csr *x509.CertificateRequest) []string { var domains []string if csr.Subject.CommonName != "" { domains = append(domains, csr.Subject.CommonName) } // loop over the SubjectAltName DNS names for _, sanName := range csr.DNSNames { if slices.Contains(domains, sanName) { // Duplicate; skip this name continue } // Name is unique domains = append(domains, sanName) } cnip := net.ParseIP(csr.Subject.CommonName) for _, sanIP := range csr.IPAddresses { if !cnip.Equal(sanIP) { domains = append(domains, sanIP.String()) } } return domains } func GeneratePemCert(privateKey *rsa.PrivateKey, domain string, extensions []pkix.Extension) ([]byte, error) { derBytes, err := generateDerCert(privateKey, time.Time{}, domain, extensions) if err != nil { return nil, err } return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}), nil } func generateDerCert(privateKey *rsa.PrivateKey, expiration time.Time, domain string, extensions []pkix.Extension) ([]byte, error) { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { return nil, err } if expiration.IsZero() { expiration = time.Now().AddDate(1, 0, 0) } template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ CommonName: "ACME Challenge TEMP", }, NotBefore: time.Now(), NotAfter: expiration, KeyUsage: x509.KeyUsageKeyEncipherment, BasicConstraintsValid: true, ExtraExtensions: extensions, } // handling SAN filling as type suspected if ip := net.ParseIP(domain); ip != nil { template.IPAddresses = []net.IP{ip} } else { template.DNSNames = []string{domain} } return x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) } ================================================ FILE: certcrypto/crypto_test.go ================================================ package certcrypto import ( "bytes" "crypto" "crypto/rand" "crypto/rsa" "encoding/pem" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testDomain1 = "lego.example" testDomain2 = "a.lego.example" testDomain3 = "b.lego.example" testDomain4 = "c.lego.example" ) func TestGeneratePrivateKey(t *testing.T) { key, err := GeneratePrivateKey(RSA2048) require.NoError(t, err, "Error generating private key") assert.NotNil(t, key) } func TestGenerateCSR(t *testing.T) { privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Error generating private key") type expected struct { len int error bool } testCases := []struct { desc string privateKey crypto.PrivateKey opts CSROptions expected expected }{ { desc: "without SAN (nil)", privateKey: privateKey, opts: CSROptions{ Domain: testDomain1, MustStaple: true, }, expected: expected{len: 382}, }, { desc: "without SAN (empty)", privateKey: privateKey, opts: CSROptions{ Domain: testDomain1, SAN: []string{}, MustStaple: true, }, expected: expected{len: 382}, }, { desc: "with SAN", privateKey: privateKey, opts: CSROptions{ Domain: testDomain1, SAN: []string{testDomain2, testDomain3, testDomain4}, MustStaple: true, }, expected: expected{len: 442}, }, { desc: "no domain", privateKey: privateKey, opts: CSROptions{ Domain: "", MustStaple: true, }, expected: expected{len: 359}, }, { desc: "no domain with SAN", privateKey: privateKey, opts: CSROptions{ Domain: "", SAN: []string{testDomain2, testDomain3, testDomain4}, MustStaple: true, }, expected: expected{len: 419}, }, { desc: "private key nil", privateKey: nil, opts: CSROptions{ Domain: testDomain1, MustStaple: true, }, expected: expected{error: true}, }, { desc: "with email addresses", privateKey: privateKey, opts: CSROptions{ Domain: "example.com", SAN: []string{"example.org"}, EmailAddresses: []string{"foo@example.com", "bar@example.com"}, }, expected: expected{len: 421}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() csr, err := CreateCSR(test.privateKey, test.opts) if test.expected.error { require.Error(t, err) } else { require.NoError(t, err, "Error generating CSR") assert.NotEmpty(t, csr) assert.Len(t, csr, test.expected.len) } }) } } func TestPEMEncode(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Error generating private key") data := PEMEncode(key) require.NotNil(t, data) p, rest := pem.Decode(data) assert.Equal(t, "RSA PRIVATE KEY", p.Type) assert.Empty(t, rest) assert.Empty(t, p.Headers) } func TestParsePEMCertificate(t *testing.T) { privateKey, err := GeneratePrivateKey(RSA2048) require.NoError(t, err, "Error generating private key") expiration := time.Now().Add(365).Round(time.Second) certBytes, err := generateDerCert(privateKey.(*rsa.PrivateKey), expiration, "test.com", nil) require.NoError(t, err, "Error generating cert") buf := bytes.NewBufferString("TestingRSAIsSoMuchFun") // Some random string should return an error. cert, err := ParsePEMCertificate(buf.Bytes()) require.Errorf(t, err, "returned %v", cert) // A DER encoded certificate should return an error. _, err = ParsePEMCertificate(certBytes) require.Error(t, err, "Expected to return an error for DER certificates") // A PEM encoded certificate should work ok. pemCert := PEMEncode(DERCertificateBytes(certBytes)) cert, err = ParsePEMCertificate(pemCert) require.NoError(t, err) assert.Equal(t, expiration.UTC(), cert.NotAfter) } func TestParsePEMPrivateKey(t *testing.T) { privateKey, err := GeneratePrivateKey(RSA2048) require.NoError(t, err, "Error generating private key") pemPrivateKey := PEMEncode(privateKey) // Decoding a key should work and create an identical RSA key to the original, // ignoring precomputed values. decoded, err := ParsePEMPrivateKey(pemPrivateKey) require.NoError(t, err) decodedRsaPrivateKey := decoded.(*rsa.PrivateKey) require.True(t, decodedRsaPrivateKey.Equal(privateKey)) // Decoding a PEM block that doesn't contain a private key should error _, err = ParsePEMPrivateKey(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE"})) require.Errorf(t, err, "Expected to return an error for non-private key input") // Decoding a PEM block that doesn't actually contain a key should error _, err = ParsePEMPrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY"})) require.Errorf(t, err, "Expected to return an error for empty input") // Decoding non-PEM input should return an error _, err = ParsePEMPrivateKey([]byte("This is not PEM")) require.Errorf(t, err, "Expected to return an error for non-PEM input") } ================================================ FILE: certificate/authorization.go ================================================ package certificate import ( "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/log" ) func (c *Certifier) getAuthorizations(order acme.ExtendedOrder) ([]acme.Authorization, error) { resc, errc := make(chan acme.Authorization), make(chan domainError) delay := time.Second / time.Duration(c.overallRequestLimit) for _, authzURL := range order.Authorizations { time.Sleep(delay) go func(authzURL string) { authz, err := c.core.Authorizations.Get(authzURL) if err != nil { errc <- domainError{Domain: authz.Identifier.Value, Error: err} return } resc <- authz }(authzURL) } var responses []acme.Authorization failures := newObtainError() for range len(order.Authorizations) { select { case res := <-resc: responses = append(responses, res) case err := <-errc: failures.Add(err.Domain, err.Error) } } for i, auth := range order.Authorizations { log.Infof("[%s] AuthURL: %s", order.Identifiers[i].Value, auth) } close(resc) close(errc) return responses, failures.Join() } func (c *Certifier) deactivateAuthorizations(order acme.ExtendedOrder, force bool) { for _, authzURL := range order.Authorizations { auth, err := c.core.Authorizations.Get(authzURL) if err != nil { log.Infof("Unable to get the authorization for %s: %v", authzURL, err) continue } if auth.Status == acme.StatusValid && !force { log.Infof("Skipping deactivating of valid auth: %s", authzURL) continue } log.Infof("Deactivating auth: %s", authzURL) if c.core.Authorizations.Deactivate(authzURL) != nil { log.Infof("Unable to deactivate the authorization: %s", authzURL) } } } ================================================ FILE: certificate/certificates.go ================================================ package certificate import ( "bytes" "crypto" "crypto/x509" "encoding/base64" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/wait" "golang.org/x/crypto/ocsp" "golang.org/x/net/idna" ) const ( // DefaultOverallRequestLimit is the overall number of request per second // limited on the "new-reg", "new-authz" and "new-cert" endpoints. // From the documentation the limitation is 20 requests per second, // but using 20 as value doesn't work but 18 do. // https://letsencrypt.org/docs/rate-limits/ // ZeroSSL has a limit of 7. // https://help.zerossl.com/hc/en-us/articles/17864245480093-Advantages-over-Using-Let-s-Encrypt#h_01HT4Z1JCJFJQFJ1M3P7S085Q9 DefaultOverallRequestLimit = 18 ) // maxBodySize is the maximum size of body that we will read. const maxBodySize = 1024 * 1024 // Resource represents a CA issued certificate. // PrivateKey, Certificate and IssuerCertificate are all // already PEM encoded and can be directly written to disk. // Certificate may be a certificate bundle, // depending on the options supplied to create it. type Resource struct { Domain string `json:"domain"` CertURL string `json:"certUrl"` CertStableURL string `json:"certStableUrl"` PrivateKey []byte `json:"-"` Certificate []byte `json:"-"` IssuerCertificate []byte `json:"-"` CSR []byte `json:"-"` } // ObtainRequest The request to obtain certificate. // // The first domain in domains is used for the CommonName field of the certificate, // all other domains are added using the Subject Alternate Names extension. // // A new private key is generated for every invocation of the function Obtain. // If you do not want that you can supply your own private key in the privateKey parameter. // If this parameter is non-nil it will be used instead of generating a new one. // // If `Bundle` is true, the `[]byte` contains both the issuer certificate and your issued certificate as a bundle. // // If `AlwaysDeactivateAuthorizations` is true, the authorizations are also relinquished if the obtain request was successful. // See https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2. type ObtainRequest struct { Domains []string PrivateKey crypto.PrivateKey MustStaple bool EmailAddresses []string NotBefore time.Time NotAfter time.Time Bundle bool PreferredChain string // A string uniquely identifying the profile // which will be used to affect issuance of the certificate requested by this Order. // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4 Profile string AlwaysDeactivateAuthorizations bool // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 ReplacesCertID string } // ObtainForCSRRequest The request to obtain a certificate matching the CSR passed into it. // // If `Bundle` is true, the `[]byte` contains both the issuer certificate and your issued certificate as a bundle. // // If `AlwaysDeactivateAuthorizations` is true, the authorizations are also relinquished if the obtain request was successful. // See https://datatracker.ietf.org/doc/html/rfc8555#section-7.5.2. type ObtainForCSRRequest struct { CSR *x509.CertificateRequest PrivateKey crypto.PrivateKey NotBefore time.Time NotAfter time.Time Bundle bool PreferredChain string // A string uniquely identifying the profile // which will be used to affect issuance of the certificate requested by this Order. // - https://www.ietf.org/id/draft-ietf-acme-profiles-00.html#section-4 Profile string AlwaysDeactivateAuthorizations bool // A string uniquely identifying a previously-issued certificate which this // order is intended to replace. // - https://www.rfc-editor.org/rfc/rfc9773.html#section-5 ReplacesCertID string } type resolver interface { Solve(authorizations []acme.Authorization) error } type CertifierOptions struct { KeyType certcrypto.KeyType Timeout time.Duration OverallRequestLimit int DisableCommonName bool } // Certifier A service to obtain/renew/revoke certificates. type Certifier struct { core *api.Core resolver resolver options CertifierOptions overallRequestLimit int } // NewCertifier creates a Certifier. func NewCertifier(core *api.Core, resolver resolver, options CertifierOptions) *Certifier { c := &Certifier{ core: core, resolver: resolver, options: options, } c.overallRequestLimit = options.OverallRequestLimit if c.overallRequestLimit <= 0 { c.overallRequestLimit = DefaultOverallRequestLimit } return c } // Obtain tries to obtain a single certificate using all domains passed into it. // // This function will never return a partial certificate. // If one domain in the list fails, the whole certificate will fail. func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) { if len(request.Domains) == 0 { return nil, errors.New("no domains to obtain a certificate for") } domains := sanitizeDomain(request.Domains) if request.Bundle { log.Infof("[%s] acme: Obtaining bundled SAN certificate", strings.Join(domains, ", ")) } else { log.Infof("[%s] acme: Obtaining SAN certificate", strings.Join(domains, ", ")) } orderOpts := &api.OrderOptions{ NotBefore: request.NotBefore, NotAfter: request.NotAfter, Profile: request.Profile, ReplacesCertID: request.ReplacesCertID, } order, err := c.core.Orders.NewWithOptions(domains, orderOpts) if err != nil { return nil, err } authz, err := c.getAuthorizations(order) if err != nil { // If any challenge fails, return. Do not generate partial SAN certificates. c.deactivateAuthorizations(order, request.AlwaysDeactivateAuthorizations) return nil, err } err = c.resolver.Solve(authz) if err != nil { // If any challenge fails, return. Do not generate partial SAN certificates. c.deactivateAuthorizations(order, request.AlwaysDeactivateAuthorizations) return nil, err } log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) failures := newObtainError() cert, err := c.getForOrder(domains, order, request) if err != nil { for _, auth := range authz { failures.Add(challenge.GetTargetedDomain(auth), err) } } if request.AlwaysDeactivateAuthorizations { c.deactivateAuthorizations(order, true) } return cert, failures.Join() } // ObtainForCSR tries to obtain a certificate matching the CSR passed into it. // // The domains are inferred from the CommonName and SubjectAltNames, if any. // The private key for this CSR is not required. // // If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle. // // This function will never return a partial certificate. // If one domain in the list fails, the whole certificate will fail. func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error) { if request.CSR == nil { return nil, errors.New("cannot obtain resource for CSR: CSR is missing") } // figure out what domains it concerns // start with the common name domains := certcrypto.ExtractDomainsCSR(request.CSR) if request.Bundle { log.Infof("[%s] acme: Obtaining bundled SAN certificate given a CSR", strings.Join(domains, ", ")) } else { log.Infof("[%s] acme: Obtaining SAN certificate given a CSR", strings.Join(domains, ", ")) } orderOpts := &api.OrderOptions{ NotBefore: request.NotBefore, NotAfter: request.NotAfter, Profile: request.Profile, ReplacesCertID: request.ReplacesCertID, } order, err := c.core.Orders.NewWithOptions(domains, orderOpts) if err != nil { return nil, err } authz, err := c.getAuthorizations(order) if err != nil { // If any challenge fails, return. Do not generate partial SAN certificates. c.deactivateAuthorizations(order, request.AlwaysDeactivateAuthorizations) return nil, err } err = c.resolver.Solve(authz) if err != nil { // If any challenge fails, return. Do not generate partial SAN certificates. c.deactivateAuthorizations(order, request.AlwaysDeactivateAuthorizations) return nil, err } log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) failures := newObtainError() var privateKey []byte if request.PrivateKey != nil { privateKey = certcrypto.PEMEncode(request.PrivateKey) } cert, err := c.getForCSR(domains, order, request.Bundle, request.CSR.Raw, privateKey, request.PreferredChain) if err != nil { for _, auth := range authz { failures.Add(challenge.GetTargetedDomain(auth), err) } } if request.AlwaysDeactivateAuthorizations { c.deactivateAuthorizations(order, true) } if cert != nil { // Add the CSR to the certificate so that it can be used for renewals. cert.CSR = certcrypto.PEMEncode(request.CSR) } return cert, failures.Join() } func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, request ObtainRequest) (*Resource, error) { privateKey := request.PrivateKey if privateKey == nil { var err error privateKey, err = certcrypto.GeneratePrivateKey(c.options.KeyType) if err != nil { return nil, err } } commonName := "" if len(domains[0]) <= 64 && !c.options.DisableCommonName { commonName = domains[0] } // RFC8555 Section 7.4 "Applying for Certificate Issuance" // https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4 // says: // Clients SHOULD NOT make any assumptions about the sort order of // "identifiers" or "authorizations" elements in the returned order // object. var san []string if commonName != "" { san = append(san, commonName) } for _, auth := range order.Identifiers { if auth.Value != commonName { san = append(san, auth.Value) } } csrOptions := certcrypto.CSROptions{ Domain: commonName, SAN: san, MustStaple: request.MustStaple, EmailAddresses: request.EmailAddresses, } csr, err := certcrypto.CreateCSR(privateKey, csrOptions) if err != nil { return nil, err } return c.getForCSR(domains, order, request.Bundle, csr, certcrypto.PEMEncode(privateKey), request.PreferredChain) } func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr, privateKeyPem []byte, preferredChain string) (*Resource, error) { respOrder, err := c.core.Orders.UpdateForCSR(order.Finalize, csr) if err != nil { return nil, err } certRes := &Resource{ Domain: domains[0], CertURL: respOrder.Certificate, PrivateKey: privateKeyPem, } if respOrder.Status == acme.StatusValid { // if the certificate is available right away, shortcut! ok, errR := c.checkResponse(respOrder, certRes, bundle, preferredChain) if errR != nil { return nil, errR } if ok { return certRes, nil } } timeout := c.options.Timeout if c.options.Timeout <= 0 { timeout = 30 * time.Second } err = wait.For("certificate", timeout, timeout/60, func() (bool, error) { ord, errW := c.core.Orders.Get(order.Location) if errW != nil { return false, errW } done, errW := c.checkResponse(ord, certRes, bundle, preferredChain) if errW != nil { return false, errW } return done, nil }) return certRes, err } // checkResponse checks to see if the certificate is ready and a link is contained in the response. // // If so, loads it into certRes and returns true. // If the cert is not yet ready, it returns false. // // The certRes input should already have the Domain (common name) field populated. // // If bundle is true, the certificate will be bundled with the issuer's cert. func (c *Certifier) checkResponse(order acme.ExtendedOrder, certRes *Resource, bundle bool, preferredChain string) (bool, error) { valid, err := checkOrderStatus(order) if err != nil || !valid { return valid, err } certs, err := c.core.Certificates.GetAll(order.Certificate, bundle) if err != nil { return false, err } // Set the default certificate certRes.IssuerCertificate = certs[order.Certificate].Issuer certRes.Certificate = certs[order.Certificate].Cert certRes.CertURL = order.Certificate certRes.CertStableURL = order.Certificate if preferredChain == "" { log.Infof("[%s] Server responded with a certificate.", certRes.Domain) return true, nil } for link, cert := range certs { ok, err := hasPreferredChain(cert.Issuer, preferredChain) if err != nil { return false, err } if ok { log.Infof("[%s] Server responded with a certificate for the preferred certificate chains %q.", certRes.Domain, preferredChain) certRes.IssuerCertificate = cert.Issuer certRes.Certificate = cert.Cert certRes.CertURL = link certRes.CertStableURL = link return true, nil } } log.Infof("lego has been configured to prefer certificate chains with issuer %q, but no chain from the CA matched this issuer. Using the default certificate chain instead.", preferredChain) return true, nil } // Revoke takes a PEM encoded certificate or bundle and tries to revoke it at the CA. func (c *Certifier) Revoke(cert []byte) error { return c.RevokeWithReason(cert, nil) } // RevokeWithReason takes a PEM encoded certificate or bundle and tries to revoke it at the CA. func (c *Certifier) RevokeWithReason(cert []byte, reason *uint) error { certificates, err := certcrypto.ParsePEMBundle(cert) if err != nil { return err } x509Cert := certificates[0] if x509Cert.IsCA { return errors.New("certificate bundle starts with a CA certificate") } revokeMsg := acme.RevokeCertMessage{ Certificate: base64.RawURLEncoding.EncodeToString(x509Cert.Raw), Reason: reason, } return c.core.Certificates.Revoke(revokeMsg) } // RenewOptions options used by Certifier.RenewWithOptions. type RenewOptions struct { NotBefore time.Time NotAfter time.Time // If true, the []byte contains both the issuer certificate and your issued certificate as a bundle. Bundle bool PreferredChain string Profile string AlwaysDeactivateAuthorizations bool // Not supported for CSR request. MustStaple bool EmailAddresses []string } // Renew takes a Resource and tries to renew the certificate. // // If the renewal process succeeds, the new certificate will be returned in a new CertResource. // Please be aware that this function will return a new certificate in ANY case that is not an error. // If the server does not provide us with a new cert on a GET request to the CertURL // this function will start a new-cert flow where a new certificate gets generated. // // If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle. // // For private key reuse the PrivateKey property of the passed in Resource should be non-nil. // // Deprecated: use RenewWithOptions instead. func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool, preferredChain string) (*Resource, error) { return c.RenewWithOptions(certRes, &RenewOptions{ Bundle: bundle, PreferredChain: preferredChain, MustStaple: mustStaple, }) } // RenewWithOptions takes a Resource and tries to renew the certificate. // // If the renewal process succeeds, the new certificate will be returned in a new CertResource. // Please be aware that this function will return a new certificate in ANY case that is not an error. // If the server does not provide us with a new cert on a GET request to the CertURL // this function will start a new-cert flow where a new certificate gets generated. // // If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle. // // For private key reuse the PrivateKey property of the passed in Resource should be non-nil. func (c *Certifier) RenewWithOptions(certRes Resource, options *RenewOptions) (*Resource, error) { // Input certificate is PEM encoded. // Decode it here as we may need the decoded cert later on in the renewal process. // The input may be a bundle or a single certificate. certificates, err := certcrypto.ParsePEMBundle(certRes.Certificate) if err != nil { return nil, err } x509Cert := certificates[0] if x509Cert.IsCA { return nil, fmt.Errorf("[%s] Certificate bundle starts with a CA certificate", certRes.Domain) } // This is just meant to be informal for the user. timeLeft := x509Cert.NotAfter.Sub(time.Now().UTC()) log.Infof("[%s] acme: Trying renewal with %d hours remaining", certRes.Domain, int(timeLeft.Hours())) // We always need to request a new certificate to renew. // Start by checking to see if the certificate was based off a CSR, // and use that if it's defined. if len(certRes.CSR) > 0 { csr, errP := certcrypto.PemDecodeTox509CSR(certRes.CSR) if errP != nil { return nil, errP } request := ObtainForCSRRequest{CSR: csr} if options != nil { request.NotBefore = options.NotBefore request.NotAfter = options.NotAfter request.Bundle = options.Bundle request.PreferredChain = options.PreferredChain request.Profile = options.Profile request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations } return c.ObtainForCSR(request) } var privateKey crypto.PrivateKey if certRes.PrivateKey != nil { privateKey, err = certcrypto.ParsePEMPrivateKey(certRes.PrivateKey) if err != nil { return nil, err } } request := ObtainRequest{ Domains: certcrypto.ExtractDomains(x509Cert), PrivateKey: privateKey, } if options != nil { request.MustStaple = options.MustStaple request.NotBefore = options.NotBefore request.NotAfter = options.NotAfter request.Bundle = options.Bundle request.PreferredChain = options.PreferredChain request.EmailAddresses = options.EmailAddresses request.Profile = options.Profile request.AlwaysDeactivateAuthorizations = options.AlwaysDeactivateAuthorizations } return c.Obtain(request) } // GetOCSP takes a PEM encoded cert or cert bundle returning the raw OCSP response, // the parsed response, and an error, if any. // // The returned []byte can be passed directly into the OCSPStaple property of a tls.Certificate. // If the bundle only contains the issued certificate, // this function will try to get the issuer certificate from the IssuingCertificateURL in the certificate. // // If the []byte and/or ocsp.Response return values are nil, the OCSP status may be assumed OCSPUnknown. func (c *Certifier) GetOCSP(bundle []byte) ([]byte, *ocsp.Response, error) { certificates, err := certcrypto.ParsePEMBundle(bundle) if err != nil { return nil, nil, err } // We expect the certificate slice to be ordered downwards the chain. // SRV CRT -> CA. We need to pull the leaf and issuer certs out of it, // which should always be the first two certificates. // If there's no OCSP server listed in the leaf cert, there's nothing to do. // And if we have only one certificate so far, we need to get the issuer cert. issuedCert := certificates[0] if len(issuedCert.OCSPServer) == 0 { return nil, nil, errors.New("no OCSP server specified in cert") } if len(certificates) == 1 { // TODO: build fallback. If this fails, check the remaining array entries. if len(issuedCert.IssuingCertificateURL) == 0 { return nil, nil, errors.New("no issuing certificate URL") } resp, errC := c.core.HTTPClient.Get(issuedCert.IssuingCertificateURL[0]) if errC != nil { return nil, nil, errC } defer resp.Body.Close() issuerBytes, errC := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxBodySize)) if errC != nil { return nil, nil, errC } issuerCert, errC := x509.ParseCertificate(issuerBytes) if errC != nil { return nil, nil, errC } // Insert it into the slice on position 0 // We want it ordered right SRV CRT -> CA certificates = append(certificates, issuerCert) } issuerCert := certificates[1] // Finally kick off the OCSP request. ocspReq, err := ocsp.CreateRequest(issuedCert, issuerCert, nil) if err != nil { return nil, nil, err } resp, err := c.core.HTTPClient.Post(issuedCert.OCSPServer[0], "application/ocsp-request", bytes.NewReader(ocspReq)) if err != nil { return nil, nil, err } defer resp.Body.Close() ocspResBytes, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxBodySize)) if err != nil { return nil, nil, err } ocspRes, err := ocsp.ParseResponse(ocspResBytes, issuerCert) if err != nil { return nil, nil, err } return ocspResBytes, ocspRes, nil } // Get attempts to fetch the certificate at the supplied URL. // The URL is the same as what would normally be supplied at the Resource's CertURL. // // The returned Resource will not have the PrivateKey and CSR fields populated as these will not be available. // // If bundle is true, the Certificate field in the returned Resource includes the issuer certificate. func (c *Certifier) Get(url string, bundle bool) (*Resource, error) { cert, issuer, err := c.core.Certificates.Get(url, bundle) if err != nil { return nil, err } // Parse the returned cert bundle so that we can grab the domain from the common name. x509Certs, err := certcrypto.ParsePEMBundle(cert) if err != nil { return nil, err } domain, err := certcrypto.GetCertificateMainDomain(x509Certs[0]) if err != nil { return nil, err } return &Resource{ Domain: domain, Certificate: cert, IssuerCertificate: issuer, CertURL: url, CertStableURL: url, }, nil } func hasPreferredChain(issuer []byte, preferredChain string) (bool, error) { certs, err := certcrypto.ParsePEMBundle(issuer) if err != nil { return false, err } topCert := certs[len(certs)-1] if topCert.Issuer.CommonName == preferredChain { return true, nil } return false, nil } func checkOrderStatus(order acme.ExtendedOrder) (bool, error) { switch order.Status { case acme.StatusValid: return true, nil case acme.StatusInvalid: return false, fmt.Errorf("invalid order: %w", order.Err()) default: return false, nil } } // https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.4 // The domain name MUST be encoded in the form in which it would appear in a certificate. // That is, it MUST be encoded according to the rules in Section 7 of [RFC5280]. // // https://www.rfc-editor.org/rfc/rfc5280.html#section-7 func sanitizeDomain(domains []string) []string { var sanitizedDomains []string for _, domain := range domains { sanitizedDomain, err := idna.ToASCII(domain) if err != nil { log.Infof("skip domain %q: unable to sanitize (punnycode): %v", domain, err) } else { sanitizedDomains = append(sanitizedDomains, sanitizedDomain) } } return sanitizedDomains } ================================================ FILE: certificate/certificates_test.go ================================================ package certificate import ( "crypto/rand" "crypto/rsa" "fmt" "net/http" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const certResponseNoBundleMock = `-----BEGIN CERTIFICATE----- MIIDEDCCAfigAwIBAgIHPhckqW5fPDANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQD Ex1QZWJibGUgSW50ZXJtZWRpYXRlIENBIDM5NWU2MTAeFw0xODExMDcxNzQ2NTZa Fw0yMzExMDcxNzQ2NTZaMBMxETAPBgNVBAMTCGFjbWUud3RmMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwtLNKvZXD20XPUQCWYSK9rUSKxD9Eb0c9fag bxOxOkLRTgL8LH6yln+bxc3MrHDou4PpDUdeo2CyOQu3CKsTS5mrH3NXYHu0H7p5 y3riOJTHnfkGKLT9LciGz7GkXd62nvNP57bOf5Sk4P2M+Qbxd0hPTSfu52740LSy 144cnxe2P1aDYehrEp6nYCESuyD/CtUHTo0qwJmzIy163Sp3rSs15BuCPyhySnE3 BJ8Ggv+qC6D5I1932DfSqyQJ79iq/HRm0Fn84am3KwvRlUfWxabmsUGARXoqCgnE zcbJVOZKewv0zlQJpfac+b+Imj6Lvt1TGjIz2mVyefYgLx8gwwIDAQABo1QwUjAO BgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwG A1UdEwEB/wQCMAAwEwYDVR0RBAwwCoIIYWNtZS53dGYwDQYJKoZIhvcNAQELBQAD ggEBABB/0iYhmfPSQot5RaeeovQnsqYjI5ryQK2cwzW6qcTJfv8N6+p6XkqF1+W4 jXZjrQP8MvgO9KNWlvx12vhINE6wubk88L+2piAi5uS2QejmZbXpyYB9s+oPqlk9 IDvfdlVYOqvYAhSx7ggGi+j73mjZVtjAavP6dKuu475ZCeq+NIC15RpbbikWKtYE HBJ7BW8XQKx67iHGx8ygHTDLbREL80Bck3oUm7wIYGMoNijD6RBl25p4gYl9dzOd TqGl5hW/1P5hMbgEzHbr4O3BfWqU2g7tV36TASy3jbC3ONFRNNYrpEZ1AL3+cUri OPPkKtAKAbQkKbUIfsHpBZjKZMU= -----END CERTIFICATE----- ` const certResponseMock = `-----BEGIN CERTIFICATE----- MIIDEDCCAfigAwIBAgIHPhckqW5fPDANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQD Ex1QZWJibGUgSW50ZXJtZWRpYXRlIENBIDM5NWU2MTAeFw0xODExMDcxNzQ2NTZa Fw0yMzExMDcxNzQ2NTZaMBMxETAPBgNVBAMTCGFjbWUud3RmMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwtLNKvZXD20XPUQCWYSK9rUSKxD9Eb0c9fag bxOxOkLRTgL8LH6yln+bxc3MrHDou4PpDUdeo2CyOQu3CKsTS5mrH3NXYHu0H7p5 y3riOJTHnfkGKLT9LciGz7GkXd62nvNP57bOf5Sk4P2M+Qbxd0hPTSfu52740LSy 144cnxe2P1aDYehrEp6nYCESuyD/CtUHTo0qwJmzIy163Sp3rSs15BuCPyhySnE3 BJ8Ggv+qC6D5I1932DfSqyQJ79iq/HRm0Fn84am3KwvRlUfWxabmsUGARXoqCgnE zcbJVOZKewv0zlQJpfac+b+Imj6Lvt1TGjIz2mVyefYgLx8gwwIDAQABo1QwUjAO BgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwG A1UdEwEB/wQCMAAwEwYDVR0RBAwwCoIIYWNtZS53dGYwDQYJKoZIhvcNAQELBQAD ggEBABB/0iYhmfPSQot5RaeeovQnsqYjI5ryQK2cwzW6qcTJfv8N6+p6XkqF1+W4 jXZjrQP8MvgO9KNWlvx12vhINE6wubk88L+2piAi5uS2QejmZbXpyYB9s+oPqlk9 IDvfdlVYOqvYAhSx7ggGi+j73mjZVtjAavP6dKuu475ZCeq+NIC15RpbbikWKtYE HBJ7BW8XQKx67iHGx8ygHTDLbREL80Bck3oUm7wIYGMoNijD6RBl25p4gYl9dzOd TqGl5hW/1P5hMbgEzHbr4O3BfWqU2g7tV36TASy3jbC3ONFRNNYrpEZ1AL3+cUri OPPkKtAKAbQkKbUIfsHpBZjKZMU= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDDDCCAfSgAwIBAgIIOV5hkYJx0JwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE AxMVUGViYmxlIFJvb3QgQ0EgNTBmZmJkMB4XDTE4MTEwNzE3NDY0N1oXDTQ4MTEw NzE3NDY0N1owKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAzOTVl NjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCacwXN4LmyRTgYS8TT SZYgz758npHiPTBDKgeN5WVmkkwW0TuN4W2zXhEmcM82uxOEjWS2drvK0+iJKneh 0fQR8ZF35dIYFe8WXTg3kEmqcizSgh4LxlOntsXvatfX/6GU/ADo3xAFoBKCijen SRBIY65yq5m00cWx3RMIcQq1B0X8nJS0O1P7MYE/Vvidz5St/36RXVu1oWLeS5Fx HAezW0lqxEUzvC+uLTFWC6f/CilzmI7SsPAkZBk7dO5Qs0d7m/zWF588vlGS+0pt D1on+lU85Ma2zuAd0qmB6LY66N8pEKKtMk93wF/o4Z5i58ahbwNvTKAzz4JSRWSu mB9LAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcD AQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA upU0DjzvIvoCOYKbq1RRN7rPdYad39mfjxgkeV0iOF5JoIdO6y1C7XAm9lT69Wjm iUPvnCTMDYft40N2SvmXuuMaPOm4zjNwn4K33jw5XBnpwxC7By/Y0oV+Sl10fBsd QqXC6H7LcSGkv+4eJbgY33P4uH5ZAy+2TkHUuZDkpufkAshzBust7nDAjfv3AIuQ wlPoyZfI11eqyiOqRzOq+B5dIBr1JzKnEzSL6n0JLNQiPO7iN03rud/wYD3gbmcv rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2 7R4IbHGnj0BJA2vMYC4hSw== -----END CERTIFICATE----- ` const issuerMock = `-----BEGIN CERTIFICATE----- MIIDDDCCAfSgAwIBAgIIOV5hkYJx0JwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE AxMVUGViYmxlIFJvb3QgQ0EgNTBmZmJkMB4XDTE4MTEwNzE3NDY0N1oXDTQ4MTEw NzE3NDY0N1owKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSAzOTVl NjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCacwXN4LmyRTgYS8TT SZYgz758npHiPTBDKgeN5WVmkkwW0TuN4W2zXhEmcM82uxOEjWS2drvK0+iJKneh 0fQR8ZF35dIYFe8WXTg3kEmqcizSgh4LxlOntsXvatfX/6GU/ADo3xAFoBKCijen SRBIY65yq5m00cWx3RMIcQq1B0X8nJS0O1P7MYE/Vvidz5St/36RXVu1oWLeS5Fx HAezW0lqxEUzvC+uLTFWC6f/CilzmI7SsPAkZBk7dO5Qs0d7m/zWF588vlGS+0pt D1on+lU85Ma2zuAd0qmB6LY66N8pEKKtMk93wF/o4Z5i58ahbwNvTKAzz4JSRWSu mB9LAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcD AQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA upU0DjzvIvoCOYKbq1RRN7rPdYad39mfjxgkeV0iOF5JoIdO6y1C7XAm9lT69Wjm iUPvnCTMDYft40N2SvmXuuMaPOm4zjNwn4K33jw5XBnpwxC7By/Y0oV+Sl10fBsd QqXC6H7LcSGkv+4eJbgY33P4uH5ZAy+2TkHUuZDkpufkAshzBust7nDAjfv3AIuQ wlPoyZfI11eqyiOqRzOq+B5dIBr1JzKnEzSL6n0JLNQiPO7iN03rud/wYD3gbmcv rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2 7R4IbHGnj0BJA2vMYC4hSw== -----END CERTIFICATE----- ` const certResponseMock2 = ` -----BEGIN CERTIFICATE----- MIIFUzCCBDugAwIBAgISA/z9btaZCSo/qlVwmJrHpoyPMA0GCSqGSIb3DQEBCwUA MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0yMDA3MjUwNjUxNDRaFw0y MDEwMjMwNjUxNDRaMBgxFjAUBgNVBAMTDW5hdHVyZS5nbG9iYWwwggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDN/PF8lWub3i+lO3CLl/HJAM86pQH9hWej Whci1PPNzKyEByJq2psNLCO1W1mXK3ClWSyifptCf7+AAFAOoBojPMwjaKMziw1M BxAQiX8MzZLv4Hr4Uk08cQX31QHiEpOv4pMHqB0UpodTYY10dZnDdyJHaGKzxfJh nQPYIVto+UegcVu9iZIDow7ugoT2Gh8nB8jOAc4wtBgmylgeAFmYR6QZ4PYSYFh0 DLZGGB1WuU/4YC5OciwTDv5EiqP3KM3NdkmGhPY0A3jcTrjN+HhcE4pYBtG1wHi8 PEuqqKyCLa3AjHq4WrZyCCkCMXPbIDS1Qt7botDmUZr/26xJZnl5AgMBAAGjggJj MIICXzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF BwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFFm72Cv7LnjVhcLqUujrykUr70lF MB8GA1UdIwQYMBaAFKhKamMEfd265tE5t6ZFZe/zqOyhMG8GCCsGAQUFBwEBBGMw YTAuBggrBgEFBQcwAYYiaHR0cDovL29jc3AuaW50LXgzLmxldHNlbmNyeXB0Lm9y ZzAvBggrBgEFBQcwAoYjaHR0cDovL2NlcnQuaW50LXgzLmxldHNlbmNyeXB0Lm9y Zy8wGAYDVR0RBBEwD4INbmF0dXJlLmdsb2JhbDBMBgNVHSAERTBDMAgGBmeBDAEC ATA3BgsrBgEEAYLfEwEBATAoMCYGCCsGAQUFBwIBFhpodHRwOi8vY3BzLmxldHNl bmNyeXB0Lm9yZzCCAQUGCisGAQQB1nkCBAIEgfYEgfMA8QB3ALIeBcyLos2KIE6H ZvkruYolIGdr2vpw57JJUy3vi5BeAAABc4T006IAAAQDAEgwRgIhAPEEvCEMkekD 8XLDaxHPnJ85UZL72JqGgNK+7I/NdFNuAiEA5D78b4V1YsD8wvWz/sk6Ks8VgjED eKGl/TyXwKEpzEIAdgBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZEwAA AXOE9NPrAAAEAwBHMEUCIAu4YFfGZIN/P+0eRG0krSddHKCSf6rqr6aVqUWkJY3F AiEAz0HkTe0alED1gW9nEAJ1qqK1MLMjRM8SsUv9Is86+CwwDQYJKoZIhvcNAQEL BQADggEBAGriSVi9YuBnm50w84gjlinmeGdvxgugblIoEqKoXd3d5/zx0DvW9Tm6 YGfXsvAJUSCag7dZ/s/PEu23jKNdFoaBmDaUHHKnUwbWWF7/ptYZ+YuDVGOJo8PL CULNfUMon20rPU9smzW4BFDBZ6KmX/r4Q8cQ7FLOqKdcng0yMcqIfq4cBxEvd0uQ pHR3AwCjAIGpV6Q9WHHiHx+SEd/Xc18Z5pXa9m3Rz4i6Mfv+AYLtnsZDxcH81cVM 7rYp80vhXM9tFd4wyrqLuaVZgYD1ylxTYpTI7sijIq4Sl984f3IPA/olN+zK6E8d EbiufIcKeju/aSellDzzBabEo80YT4o= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/ MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT DkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD Ew5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB AN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O rz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq OLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b xiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw 7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD aeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG SIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69 ikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr AvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz R8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5 JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ -----END CERTIFICATE----- ` const issuerMock2 = `-----BEGIN CERTIFICATE----- MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/ MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT DkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD Ew5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB AN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O rz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq OLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b xiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw 7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD aeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG SIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69 ikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr AvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz R8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5 JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ -----END CERTIFICATE----- ` func Test_checkResponse(t *testing.T) { server := tester.MockACMEServer(). Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) order := acme.ExtendedOrder{ Order: acme.Order{ Status: acme.StatusValid, Certificate: server.URL + "/certificate", }, } certRes := &Resource{} valid, err := certifier.checkResponse(order, certRes, true, "") require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) assert.Empty(t, certRes.Domain) assert.Contains(t, certRes.CertStableURL, "/certificate") assert.Contains(t, certRes.CertURL, "/certificate") assert.Nil(t, certRes.CSR) assert.Nil(t, certRes.PrivateKey) assert.Equal(t, certResponseMock, string(certRes.Certificate), "Certificate") assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate") } func Test_checkResponse_issuerRelUp(t *testing.T) { server := tester.MockACMEServer(). Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) order := acme.ExtendedOrder{ Order: acme.Order{ Status: acme.StatusValid, Certificate: server.URL + "/certificate", }, } certRes := &Resource{} valid, err := certifier.checkResponse(order, certRes, true, "") require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) assert.Empty(t, certRes.Domain) assert.Contains(t, certRes.CertStableURL, "/certificate") assert.Contains(t, certRes.CertURL, "/certificate") assert.Nil(t, certRes.CSR) assert.Nil(t, certRes.PrivateKey) assert.Equal(t, certResponseMock, string(certRes.Certificate), "Certificate") assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate") } func Test_checkResponse_no_bundle(t *testing.T) { server := tester.MockACMEServer(). Route("POST /certificate", servermock.RawStringResponse(certResponseMock)). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) order := acme.ExtendedOrder{ Order: acme.Order{ Status: acme.StatusValid, Certificate: server.URL + "/certificate", }, } certRes := &Resource{} valid, err := certifier.checkResponse(order, certRes, false, "") require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) assert.Empty(t, certRes.Domain) assert.Contains(t, certRes.CertStableURL, "/certificate") assert.Contains(t, certRes.CertURL, "/certificate") assert.Nil(t, certRes.CSR) assert.Nil(t, certRes.PrivateKey) assert.Equal(t, certResponseNoBundleMock, string(certRes.Certificate), "Certificate") assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate") } func Test_checkResponse_alternate(t *testing.T) { server := tester.MockACMEServer(). Route("POST /certificate", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { rw.Header().Add("Link", fmt.Sprintf(`;title="foo";rel="alternate"`, req.Context().Value(http.LocalAddrContextKey))) servermock.RawStringResponse(certResponseMock).ServeHTTP(rw, req) })). Route("/certificate/1", servermock.RawStringResponse(certResponseMock2)). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) order := acme.ExtendedOrder{ Order: acme.Order{ Status: acme.StatusValid, Certificate: server.URL + "/certificate", }, } certRes := &Resource{ Domain: "example.com", } valid, err := certifier.checkResponse(order, certRes, true, "DST Root CA X3") require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) assert.Equal(t, "example.com", certRes.Domain) assert.Contains(t, certRes.CertStableURL, "/certificate/1") assert.Contains(t, certRes.CertURL, "/certificate/1") assert.Nil(t, certRes.CSR) assert.Nil(t, certRes.PrivateKey) assert.Equal(t, certResponseMock2, string(certRes.Certificate), "Certificate") assert.Equal(t, issuerMock2, string(certRes.IssuerCertificate), "IssuerCertificate") } func Test_Get(t *testing.T) { server := tester.MockACMEServer(). Route("POST /acme/cert/test-cert", servermock.RawStringResponse(certResponseMock)). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) certRes, err := certifier.Get(server.URL+"/acme/cert/test-cert", true) require.NoError(t, err) assert.NotNil(t, certRes) assert.Equal(t, "acme.wtf", certRes.Domain) assert.Equal(t, server.URL+"/acme/cert/test-cert", certRes.CertStableURL) assert.Equal(t, server.URL+"/acme/cert/test-cert", certRes.CertURL) assert.Nil(t, certRes.CSR) assert.Nil(t, certRes.PrivateKey) assert.Equal(t, certResponseMock, string(certRes.Certificate), "Certificate") assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate") } func Test_checkOrderStatus(t *testing.T) { testCases := []struct { desc string order acme.Order requireErr require.ErrorAssertionFunc expected bool }{ { desc: "status valid", order: acme.Order{Status: acme.StatusValid}, requireErr: require.NoError, expected: true, }, { desc: "status invalid", order: acme.Order{Status: acme.StatusInvalid}, requireErr: require.Error, expected: false, }, { desc: "status invalid with error", order: acme.Order{Status: acme.StatusInvalid, Error: &acme.ProblemDetails{}}, requireErr: require.Error, expected: false, }, { desc: "unknown status", order: acme.Order{Status: "foo"}, requireErr: require.NoError, expected: false, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() status, err := checkOrderStatus(acme.ExtendedOrder{Order: test.order}) test.requireErr(t, err) assert.Equal(t, test.expected, status) }) } } type resolverMock struct { error error } func (r *resolverMock) Solve(_ []acme.Authorization) error { return r.error } ================================================ FILE: certificate/errors.go ================================================ package certificate import ( "errors" "fmt" ) type obtainError struct { data map[string]error } func newObtainError() *obtainError { return &obtainError{data: make(map[string]error)} } func (e *obtainError) Add(domain string, err error) { e.data[domain] = err } func (e *obtainError) Join() error { if e == nil { return nil } if len(e.data) == 0 { return nil } var err error for d, e := range e.data { err = errors.Join(err, fmt.Errorf("%s: %w", d, e)) } return fmt.Errorf("error: one or more domains had a problem:\n%w", err) } type domainError struct { Domain string Error error } ================================================ FILE: certificate/errors_test.go ================================================ package certificate import ( "errors" "testing" "github.com/stretchr/testify/require" ) type TomatoError struct{} func (t TomatoError) Error() string { return "tomato" } type CarrotError struct{} func (t CarrotError) Error() string { return "carrot" } func Test_obtainError_Join(t *testing.T) { failures := newObtainError() failures.Add("example.com", &TomatoError{}) err := failures.Join() to := &TomatoError{} require.ErrorAs(t, err, &to) } func Test_obtainError_Join_multiple_domains(t *testing.T) { failures := newObtainError() failures.Add("example.com", &TomatoError{}) failures.Add("example.org", &CarrotError{}) err := failures.Join() to := &TomatoError{} require.ErrorAs(t, err, &to) ca := &CarrotError{} require.ErrorAs(t, err, &ca) } func Test_obtainError_Join_no_error(t *testing.T) { failures := newObtainError() require.NoError(t, failures.Join()) } func Test_obtainError_Join_same_domain(t *testing.T) { failures := newObtainError() failures.Add("example.com", &TomatoError{}) failures.Add("example.com", &CarrotError{}) err := failures.Join() to := &TomatoError{} if errors.As(err, &to) { require.Fail(t, "TomatoError should be overridden by CarrotError") } ca := &CarrotError{} require.ErrorAs(t, err, &ca) } ================================================ FILE: certificate/renewal.go ================================================ package certificate import ( "crypto/x509" "encoding/asn1" "encoding/base64" "encoding/json" "errors" "fmt" "math/rand" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" ) // RenewalInfoRequest contains the necessary renewal information. type RenewalInfoRequest struct { Cert *x509.Certificate } // RenewalInfoResponse is a wrapper around acme.RenewalInfoResponse that provides a method for determining when to renew a certificate. type RenewalInfoResponse struct { acme.RenewalInfoResponse // RetryAfter header indicating the polling interval that the ACME server recommends. // Conforming clients SHOULD query the renewalInfo URL again after the RetryAfter period has passed, // as the server may provide a different suggestedWindow. // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2 RetryAfter time.Duration } // ShouldRenewAt determines the optimal renewal time based on the current time (UTC),renewal window suggest by ARI, and the client's willingness to sleep. // It returns a pointer to a time.Time value indicating when the renewal should be attempted or nil if deferred until the next normal wake time. // This method implements the RECOMMENDED algorithm described in RFC 9773. // // - (4.1-11. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time { // Explicitly convert all times to UTC. now = now.UTC() start := r.SuggestedWindow.Start.UTC() end := r.SuggestedWindow.End.UTC() // Select a uniform random time within the suggested window. rt := start if window := end.Sub(start); window > 0 { randomDuration := time.Duration(rand.Int63n(int64(window))) rt = rt.Add(randomDuration) } // If the selected time is in the past, attempt renewal immediately. if rt.Before(now) { return &now } // Otherwise, if the client can schedule itself to attempt renewal at exactly the selected time, do so. willingToSleepUntil := now.Add(willingToSleep) if willingToSleepUntil.After(rt) || willingToSleepUntil.Equal(rt) { return &rt } // TODO: Otherwise, if the selected time is before the next time that the client would wake up normally, attempt renewal immediately. // Otherwise, sleep until the next normal wake time, re-check ARI, and return to Step 1. return nil } // GetRenewalInfo sends a request to the ACME server's renewalInfo endpoint to obtain a suggested renewal window. // The caller MUST provide the certificate and issuer certificate for the certificate they wish to renew. // The caller should attempt to renew the certificate at the time indicated by the ShouldRenewAt method of the returned RenewalInfoResponse object. // // Note: this endpoint is part of a draft specification, not all ACME servers will implement it. // This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint. // // https://www.rfc-editor.org/rfc/rfc9773.html func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) { certID, err := MakeARICertID(req.Cert) if err != nil { return nil, fmt.Errorf("error making certID: %w", err) } resp, err := c.core.Certificates.GetRenewalInfo(certID) if err != nil { return nil, err } defer resp.Body.Close() var info RenewalInfoResponse err = json.NewDecoder(resp.Body).Decode(&info) if err != nil { return nil, err } if retry := resp.Header.Get("Retry-After"); retry != "" { info.RetryAfter, err = api.ParseRetryAfter(retry) if err != nil { return nil, fmt.Errorf("failed to parse Retry-After header: %w", err) } } return &info, nil } // MakeARICertID constructs a certificate identifier as described in RFC 9773, section 4.1. func MakeARICertID(leaf *x509.Certificate) (string, error) { if leaf == nil { return "", errors.New("leaf certificate is nil") } // Marshal the Serial Number into DER. der, err := asn1.Marshal(leaf.SerialNumber) if err != nil { return "", err } // Check if the DER encoded bytes are sufficient (at least 3 bytes: tag, // length, and value). if len(der) < 3 { return "", errors.New("invalid DER encoding of serial number") } // Extract only the integer bytes from the DER encoded Serial Number // Skipping the first 2 bytes (tag and length). serial := base64.RawURLEncoding.EncodeToString(der[2:]) // Convert the Authority Key Identifier to base64url encoding without // padding. aki := base64.RawURLEncoding.EncodeToString(leaf.AuthorityKeyId) // Construct the final identifier by concatenating AKI and Serial Number. return fmt.Sprintf("%s.%s", aki, serial), nil } ================================================ FILE: certificate/renewal_test.go ================================================ package certificate import ( "crypto/rand" "crypto/rsa" "net/http" "testing" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( ariLeafPEM = `-----BEGIN CERTIFICATE----- MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu 7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb +FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK -----END CERTIFICATE-----` ariLeafCertID = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE" ) func Test_makeCertID(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) actual, err := MakeARICertID(leaf) require.NoError(t, err) assert.Equal(t, ariLeafCertID, actual) } func TestCertifier_GetRenewalInfo(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) // Test with a fake API. server := tester.MockACMEServer(). Route("GET /renewalInfo/"+ariLeafCertID, servermock.RawStringResponse(`{ "suggestedWindow": { "start": "2020-03-17T17:51:09Z", "end": "2020-03-17T18:21:09Z" }, "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" } }`). WithHeader("Content-Type", "application/json"). WithHeader("Retry-After", "21600")). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf}) require.NoError(t, err) require.NotNil(t, ri) assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339)) assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339)) assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL) assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter) } func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) server := tester.MockACMEServer(). Route("GET /renewalInfo/"+ariLeafCertID, servermock.RawStringResponse(`{ "suggestedWindow": { "start": "2020-03-17T17:51:09Z", "end": "2020-03-17T18:21:09Z" }, "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" } }`). WithHeader("Content-Type", "application/json"). WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf}) require.NoError(t, err) require.NotNil(t, ri) assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339)) assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339)) assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL) assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001) } func TestCertifier_GetRenewalInfo_errors(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") testCases := []struct { desc string timeout time.Duration request RenewalInfoRequest handler http.HandlerFunc }{ { desc: "API timeout", timeout: 500 * time.Millisecond, // HTTP client that times out after 500ms. request: RenewalInfoRequest{leaf}, handler: func(w http.ResponseWriter, r *http.Request) { // API that takes 2ms to respond. time.Sleep(2 * time.Millisecond) }, }, { desc: "API error", request: RenewalInfoRequest{leaf}, handler: func(w http.ResponseWriter, r *http.Request) { // API that responds with error instead of renewal info. http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() server := tester.MockACMEServer(). Route("GET /renewalInfo/"+ariLeafCertID, test.handler). BuildHTTPS(t) client := server.Client() if test.timeout != 0 { client.Timeout = test.timeout } core, err := api.New(client, "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) response, err := certifier.GetRenewalInfo(test.request) require.Error(t, err) assert.Nil(t, response) }) } } func TestRenewalInfoResponse_ShouldRenew(t *testing.T) { now := time.Now().UTC() t.Run("Window is in the past", func(t *testing.T) { ri := RenewalInfoResponse{ RenewalInfoResponse: acme.RenewalInfoResponse{ SuggestedWindow: acme.Window{ Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), }, ExplanationURL: "", }, RetryAfter: 0, } rt := ri.ShouldRenewAt(now, 0) require.NotNil(t, rt) assert.Equal(t, now, *rt) }) t.Run("Window is in the future", func(t *testing.T) { ri := RenewalInfoResponse{ RenewalInfoResponse: acme.RenewalInfoResponse{ SuggestedWindow: acme.Window{ Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), }, ExplanationURL: "", }, RetryAfter: 0, } rt := ri.ShouldRenewAt(now, 0) assert.Nil(t, rt) }) t.Run("Window is in the future, but caller is willing to sleep", func(t *testing.T) { ri := RenewalInfoResponse{ RenewalInfoResponse: acme.RenewalInfoResponse{ SuggestedWindow: acme.Window{ Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), }, ExplanationURL: "", }, RetryAfter: 0, } rt := ri.ShouldRenewAt(now, 2*time.Hour) require.NotNil(t, rt) assert.True(t, rt.Before(now.Add(2*time.Hour))) }) t.Run("Window is in the future, but caller isn't willing to sleep long enough", func(t *testing.T) { ri := RenewalInfoResponse{ RenewalInfoResponse: acme.RenewalInfoResponse{ SuggestedWindow: acme.Window{ Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), }, ExplanationURL: "", }, RetryAfter: 0, } rt := ri.ShouldRenewAt(now, 59*time.Minute) assert.Nil(t, rt) }) } ================================================ FILE: challenge/challenges.go ================================================ package challenge import ( "fmt" "github.com/go-acme/lego/v4/acme" ) // Type is a string that identifies a particular challenge type and version of ACME challenge. type Type string const ( // HTTP01 is the "http-01" ACME challenge https://www.rfc-editor.org/rfc/rfc8555.html#section-8.3 // Note: ChallengePath returns the URL path to fulfill this challenge. HTTP01 = Type("http-01") // DNS01 is the "dns-01" ACME challenge https://www.rfc-editor.org/rfc/rfc8555.html#section-8.4 // Note: GetRecord returns a DNS record which will fulfill this challenge. DNS01 = Type("dns-01") // TLSALPN01 is the "tls-alpn-01" ACME challenge https://www.rfc-editor.org/rfc/rfc8737.html TLSALPN01 = Type("tls-alpn-01") ) func (t Type) String() string { return string(t) } func FindChallenge(chlgType Type, authz acme.Authorization) (acme.Challenge, error) { for _, chlg := range authz.Challenges { if chlg.Type == string(chlgType) { return chlg, nil } } return acme.Challenge{}, fmt.Errorf("[%s] acme: unable to find challenge %s", GetTargetedDomain(authz), chlgType) } func GetTargetedDomain(authz acme.Authorization) string { if authz.Wildcard { return "*." + authz.Identifier.Value } return authz.Identifier.Value } ================================================ FILE: challenge/dns01/cname.go ================================================ package dns01 import ( "strings" "github.com/miekg/dns" ) // Update FQDN with CNAME if any. func updateDomainWithCName(r *dns.Msg, fqdn string) string { for _, rr := range r.Answer { if cn, ok := rr.(*dns.CNAME); ok { if strings.EqualFold(cn.Hdr.Name, fqdn) { return cn.Target } } } return fqdn } ================================================ FILE: challenge/dns01/cname_test.go ================================================ package dns01 import ( "strings" "testing" "github.com/miekg/dns" "github.com/stretchr/testify/assert" ) func Test_updateDomainWithCName_caseInsensitive(t *testing.T) { qname := "_acme-challenge.uppercase-test.example.com." cnameTarget := "_acme-challenge.uppercase-test.cname-target.example.com." msg := &dns.Msg{ MsgHdr: dns.MsgHdr{ Authoritative: true, }, Answer: []dns.RR{ &dns.CNAME{ Hdr: dns.RR_Header{ Name: strings.ToUpper(qname), // CNAME names are case-insensitive Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 3600, }, Target: cnameTarget, }, }, } fqdn := updateDomainWithCName(msg, qname) assert.Equal(t, cnameTarget, fqdn) } ================================================ FILE: challenge/dns01/dns_challenge.go ================================================ package dns01 import ( "crypto/sha256" "encoding/base64" "fmt" "os" "strconv" "strings" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/wait" "github.com/miekg/dns" ) const ( // DefaultPropagationTimeout default propagation timeout. DefaultPropagationTimeout = 60 * time.Second // DefaultPollingInterval default polling interval. DefaultPollingInterval = 2 * time.Second // DefaultTTL default TTL. DefaultTTL = 120 ) type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error type ChallengeOption func(*Challenge) error // CondOption Conditional challenge option. func CondOption(condition bool, opt ChallengeOption) ChallengeOption { if !condition { // NoOp options return func(*Challenge) error { return nil } } return opt } // Challenge implements the dns-01 challenge. type Challenge struct { core *api.Core validate ValidateFunc provider challenge.Provider preCheck preCheck dnsTimeout time.Duration } func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge { chlg := &Challenge{ core: core, validate: validate, provider: provider, preCheck: newPreCheck(), dnsTimeout: 10 * time.Second, } for _, opt := range opts { err := opt(chlg) if err != nil { log.Infof("challenge option error: %v", err) } } return chlg } // PreSolve just submits the txt record to the dns provider. // It does not validate record propagation, or do anything at all with the acme server. func (c *Challenge) PreSolve(authz acme.Authorization) error { domain := challenge.GetTargetedDomain(authz) log.Infof("[%s] acme: Preparing to solve DNS-01", domain) chlng, err := challenge.FindChallenge(challenge.DNS01, authz) if err != nil { return err } if c.provider == nil { return fmt.Errorf("[%s] acme: no DNS Provider configured", domain) } // Generate the Key Authorization for the challenge keyAuth, err := c.core.GetKeyAuthorization(chlng.Token) if err != nil { return err } err = c.provider.Present(authz.Identifier.Value, chlng.Token, keyAuth) if err != nil { return fmt.Errorf("[%s] acme: error presenting token: %w", domain, err) } return nil } func (c *Challenge) Solve(authz acme.Authorization) error { domain := challenge.GetTargetedDomain(authz) log.Infof("[%s] acme: Trying to solve DNS-01", domain) chlng, err := challenge.FindChallenge(challenge.DNS01, authz) if err != nil { return err } // Generate the Key Authorization for the challenge keyAuth, err := c.core.GetKeyAuthorization(chlng.Token) if err != nil { return err } info := GetChallengeInfo(authz.Identifier.Value, keyAuth) var timeout, interval time.Duration switch provider := c.provider.(type) { case challenge.ProviderTimeout: timeout, interval = provider.Timeout() default: timeout, interval = DefaultPropagationTimeout, DefaultPollingInterval } log.Infof("[%s] acme: Checking DNS record propagation. [nameservers=%s]", domain, strings.Join(recursiveNameservers, ",")) time.Sleep(interval) err = wait.For("propagation", timeout, interval, func() (bool, error) { stop, errP := c.preCheck.call(domain, info.EffectiveFQDN, info.Value) if !stop || errP != nil { log.Infof("[%s] acme: Waiting for DNS record propagation.", domain) } return stop, errP }) if err != nil { return err } chlng.KeyAuthorization = keyAuth return c.validate(c.core, domain, chlng) } // CleanUp cleans the challenge. func (c *Challenge) CleanUp(authz acme.Authorization) error { log.Infof("[%s] acme: Cleaning DNS-01 challenge", challenge.GetTargetedDomain(authz)) chlng, err := challenge.FindChallenge(challenge.DNS01, authz) if err != nil { return err } keyAuth, err := c.core.GetKeyAuthorization(chlng.Token) if err != nil { return err } return c.provider.CleanUp(authz.Identifier.Value, chlng.Token, keyAuth) } func (c *Challenge) Sequential() (bool, time.Duration) { if p, ok := c.provider.(sequential); ok { return ok, p.Sequential() } return false, 0 } type sequential interface { Sequential() time.Duration } // GetRecord returns a DNS record which will fulfill the `dns-01` challenge. // // Deprecated: use GetChallengeInfo instead. func GetRecord(domain, keyAuth string) (fqdn, value string) { info := GetChallengeInfo(domain, keyAuth) return info.EffectiveFQDN, info.Value } // ChallengeInfo contains the information use to create the TXT record. type ChallengeInfo struct { // FQDN is the full-qualified challenge domain (i.e. `_acme-challenge.[domain].`) FQDN string // EffectiveFQDN contains the resulting FQDN after the CNAMEs resolutions. EffectiveFQDN string // Value contains the value for the TXT record. Value string } // GetChallengeInfo returns information used to create a DNS record which will fulfill the `dns-01` challenge. func GetChallengeInfo(domain, keyAuth string) ChallengeInfo { keyAuthShaBytes := sha256.Sum256([]byte(keyAuth)) // base64URL encoding without padding value := base64.RawURLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size]) ok, _ := strconv.ParseBool(os.Getenv("LEGO_DISABLE_CNAME_SUPPORT")) return ChallengeInfo{ Value: value, FQDN: getChallengeFQDN(domain, false), EffectiveFQDN: getChallengeFQDN(domain, !ok), } } func getChallengeFQDN(domain string, followCNAME bool) string { fqdn := fmt.Sprintf("_acme-challenge.%s.", domain) if !followCNAME { return fqdn } // recursion counter so it doesn't spin out of control for range 50 { // Keep following CNAMEs r, err := dnsQuery(fqdn, dns.TypeCNAME, recursiveNameservers, true) if err != nil || r.Rcode != dns.RcodeSuccess { // No more CNAME records to follow, exit break } // Check if the domain has CNAME then use that cname := updateDomainWithCName(r, fqdn) if cname == fqdn { break } log.Infof("Found CNAME entry for %q: %q", fqdn, cname) fqdn = cname } return fqdn } ================================================ FILE: challenge/dns01/dns_challenge_manual.go ================================================ package dns01 import ( "bufio" "fmt" "os" "time" ) const ( dnsTemplate = `%s %d IN TXT %q` ) // DNSProviderManual is an implementation of the ChallengeProvider interface. // TODO(ldez): move this to providers/dns/manual // // Deprecated: Use the manual.DNSProvider instead. type DNSProviderManual struct{} // NewDNSProviderManual returns a DNSProviderManual instance. // // Deprecated: Use the manual.NewDNSProvider instead. func NewDNSProviderManual() (*DNSProviderManual, error) { return &DNSProviderManual{}, nil } // Present prints instructions for manually creating the TXT record. func (*DNSProviderManual) Present(domain, token, keyAuth string) error { info := GetChallengeInfo(domain, keyAuth) authZone, err := FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("manual: could not find zone: %w", err) } fmt.Printf("lego: Please create the following TXT record in your %s zone:\n", authZone) fmt.Printf(dnsTemplate+"\n", info.EffectiveFQDN, DefaultTTL, info.Value) fmt.Printf("lego: Press 'Enter' when you are done\n") _, err = bufio.NewReader(os.Stdin).ReadBytes('\n') if err != nil { return fmt.Errorf("manual: %w", err) } return nil } // CleanUp prints instructions for manually removing the TXT record. func (*DNSProviderManual) CleanUp(domain, token, keyAuth string) error { info := GetChallengeInfo(domain, keyAuth) authZone, err := FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("manual: could not find zone: %w", err) } fmt.Printf("lego: You can now remove this TXT record from your %s zone:\n", authZone) fmt.Printf(dnsTemplate+"\n", info.EffectiveFQDN, DefaultTTL, "...") return nil } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProviderManual) Sequential() time.Duration { return DefaultPropagationTimeout } ================================================ FILE: challenge/dns01/dns_challenge_test.go ================================================ package dns01 import ( "crypto/rand" "crypto/rsa" "errors" "testing" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/dnsmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type providerMock struct { present, cleanUp error } func (p *providerMock) Present(domain, token, keyAuth string) error { return p.present } func (p *providerMock) CleanUp(domain, token, keyAuth string) error { return p.cleanUp } type providerTimeoutMock struct { present, cleanUp error timeout, interval time.Duration } func (p *providerTimeoutMock) Present(domain, token, keyAuth string) error { return p.present } func (p *providerTimeoutMock) CleanUp(domain, token, keyAuth string) error { return p.cleanUp } func (p *providerTimeoutMock) Timeout() (time.Duration, time.Duration) { return p.timeout, p.interval } func TestChallenge_PreSolve(t *testing.T) { server := tester.MockACMEServer().BuildHTTPS(t) privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { desc string validate ValidateFunc preCheck WrapPreCheckFunc provider challenge.Provider expectError bool }{ { desc: "success", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{}, }, { desc: "validate fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return errors.New("OOPS") }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ present: nil, cleanUp: nil, }, }, { desc: "preCheck fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return false, errors.New("OOPS") }, provider: &providerTimeoutMock{ timeout: 2 * time.Second, interval: 500 * time.Millisecond, }, }, { desc: "present fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ present: errors.New("OOPS"), }, expectError: true, }, { desc: "cleanUp fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ cleanUp: errors.New("OOPS"), }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { chlg := NewChallenge(core, test.validate, test.provider, WrapPreCheck(test.preCheck)) authz := acme.Authorization{ Identifier: acme.Identifier{ Value: "example.com", }, Challenges: []acme.Challenge{ {Type: challenge.DNS01.String()}, }, } err = chlg.PreSolve(authz) if test.expectError { require.Error(t, err) } else { require.NoError(t, err) } }) } } func TestChallenge_Solve(t *testing.T) { useAsNameserver(t, dnsmock.NewServer(). Query("_acme-challenge.example.com. CNAME", dnsmock.Noop). Build(t)) server := tester.MockACMEServer().BuildHTTPS(t) privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { desc string validate ValidateFunc preCheck WrapPreCheckFunc provider challenge.Provider expectError bool }{ { desc: "success", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{}, }, { desc: "validate fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return errors.New("OOPS") }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ present: nil, cleanUp: nil, }, expectError: true, }, { desc: "preCheck fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return false, errors.New("OOPS") }, provider: &providerTimeoutMock{ timeout: 2 * time.Second, interval: 500 * time.Millisecond, }, expectError: true, }, { desc: "present fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ present: errors.New("OOPS"), }, }, { desc: "cleanUp fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ cleanUp: errors.New("OOPS"), }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { var options []ChallengeOption if test.preCheck != nil { options = append(options, WrapPreCheck(test.preCheck)) } chlg := NewChallenge(core, test.validate, test.provider, options...) authz := acme.Authorization{ Identifier: acme.Identifier{ Value: "example.com", }, Challenges: []acme.Challenge{ {Type: challenge.DNS01.String()}, }, } err = chlg.Solve(authz) if test.expectError { require.Error(t, err) } else { require.NoError(t, err) } }) } } func TestChallenge_CleanUp(t *testing.T) { server := tester.MockACMEServer().BuildHTTPS(t) privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { desc string validate ValidateFunc preCheck WrapPreCheckFunc provider challenge.Provider expectError bool }{ { desc: "success", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{}, }, { desc: "validate fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return errors.New("OOPS") }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ present: nil, cleanUp: nil, }, }, { desc: "preCheck fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return false, errors.New("OOPS") }, provider: &providerTimeoutMock{ timeout: 2 * time.Second, interval: 500 * time.Millisecond, }, }, { desc: "present fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ present: errors.New("OOPS"), }, }, { desc: "cleanUp fail", validate: func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, preCheck: func(_, _, _ string, _ PreCheckFunc) (bool, error) { return true, nil }, provider: &providerMock{ cleanUp: errors.New("OOPS"), }, expectError: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { chlg := NewChallenge(core, test.validate, test.provider, WrapPreCheck(test.preCheck)) authz := acme.Authorization{ Identifier: acme.Identifier{ Value: "example.com", }, Challenges: []acme.Challenge{ {Type: challenge.DNS01.String()}, }, } err = chlg.CleanUp(authz) if test.expectError { require.Error(t, err) } else { require.NoError(t, err) } }) } } func TestGetChallengeInfo(t *testing.T) { useAsNameserver(t, dnsmock.NewServer(). Query("_acme-challenge.example.com. CNAME", dnsmock.Noop). Build(t)) info := GetChallengeInfo("example.com", "123") expected := ChallengeInfo{ FQDN: "_acme-challenge.example.com.", EffectiveFQDN: "_acme-challenge.example.com.", Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM", } assert.Equal(t, expected, info) } func TestGetChallengeInfo_CNAME(t *testing.T) { useAsNameserver(t, dnsmock.NewServer(). Query("_acme-challenge.example.com. CNAME", dnsmock.CNAME("example.org.")). Query("example.org. CNAME", dnsmock.Noop). Build(t)) info := GetChallengeInfo("example.com", "123") expected := ChallengeInfo{ FQDN: "_acme-challenge.example.com.", EffectiveFQDN: "example.org.", Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM", } assert.Equal(t, expected, info) } func TestGetChallengeInfo_CNAME_disabled(t *testing.T) { useAsNameserver(t, dnsmock.NewServer(). // Never called when the env var works. Query("_acme-challenge.example.com. CNAME", dnsmock.CNAME("example.org.")). Build(t)) t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true") info := GetChallengeInfo("example.com", "123") expected := ChallengeInfo{ FQDN: "_acme-challenge.example.com.", EffectiveFQDN: "_acme-challenge.example.com.", Value: "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM", } assert.Equal(t, expected, info) } ================================================ FILE: challenge/dns01/domain.go ================================================ package dns01 import ( "fmt" "strings" "github.com/miekg/dns" ) // ExtractSubDomain extracts the subdomain part from a domain and a zone. func ExtractSubDomain(domain, zone string) (string, error) { canonDomain := dns.Fqdn(domain) canonZone := dns.Fqdn(zone) if canonDomain == canonZone { return "", fmt.Errorf("no subdomain because the domain and the zone are identical: %s", canonDomain) } if !dns.IsSubDomain(canonZone, canonDomain) { return "", fmt.Errorf("%s is not a subdomain of %s", canonDomain, canonZone) } return strings.TrimSuffix(canonDomain, "."+canonZone), nil } ================================================ FILE: challenge/dns01/domain_test.go ================================================ package dns01 import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestExtractSubDomain(t *testing.T) { testCases := []struct { desc string domain string zone string expected string }{ { desc: "no FQDN", domain: "_acme-challenge.example.com", zone: "example.com", expected: "_acme-challenge", }, { desc: "no FQDN zone", domain: "_acme-challenge.example.com.", zone: "example.com", expected: "_acme-challenge", }, { desc: "no FQDN domain", domain: "_acme-challenge.example.com", zone: "example.com.", expected: "_acme-challenge", }, { desc: "FQDN", domain: "_acme-challenge.example.com.", zone: "example.com.", expected: "_acme-challenge", }, { desc: "multi-level subdomain", domain: "_acme-challenge.one.example.com.", zone: "example.com.", expected: "_acme-challenge.one", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() subDomain, err := ExtractSubDomain(test.domain, test.zone) require.NoError(t, err) assert.Equal(t, test.expected, subDomain) }) } } func TestExtractSubDomain_errors(t *testing.T) { testCases := []struct { desc string domain string zone string }{ { desc: "same domain", domain: "example.com", zone: "example.com", }, { desc: "same domain, no FQDN zone", domain: "example.com.", zone: "example.com", }, { desc: "same domain, no FQDN domain", domain: "example.com", zone: "example.com.", }, { desc: "same domain, FQDN", domain: "example.com.", zone: "example.com.", }, { desc: "zone and domain are unrelated", domain: "_acme-challenge.example.com", zone: "example.org", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() _, err := ExtractSubDomain(test.domain, test.zone) require.Error(t, err) }) } } ================================================ FILE: challenge/dns01/fixtures/resolv.conf.1 ================================================ domain example.com nameserver 10.200.3.249 nameserver 10.200.3.250:5353 nameserver 2001:4860:4860::8844 nameserver [10.0.0.1]:5353 ================================================ FILE: challenge/dns01/fqdn.go ================================================ package dns01 import ( "iter" "github.com/miekg/dns" ) // ToFqdn converts the name into a fqdn appending a trailing dot. // // Deprecated: Use [github.com/miekg/dns.Fqdn] directly. func ToFqdn(name string) string { return dns.Fqdn(name) } // UnFqdn converts the fqdn into a name removing the trailing dot. func UnFqdn(name string) string { n := len(name) if n != 0 && name[n-1] == '.' { return name[:n-1] } return name } // UnFqdnDomainsSeq generates a sequence of "unFQDNed" domain names derived from a domain (FQDN or not) in descending order. func UnFqdnDomainsSeq(fqdn string) iter.Seq[string] { return func(yield func(string) bool) { if fqdn == "" { return } for _, index := range dns.Split(fqdn) { if !yield(UnFqdn(fqdn[index:])) { return } } } } // DomainsSeq generates a sequence of domain names derived from a domain (FQDN or not) in descending order. func DomainsSeq(fqdn string) iter.Seq[string] { return func(yield func(string) bool) { if fqdn == "" { return } for _, index := range dns.Split(fqdn) { if !yield(fqdn[index:]) { return } } } } ================================================ FILE: challenge/dns01/fqdn_test.go ================================================ package dns01 import ( "slices" "testing" "github.com/stretchr/testify/assert" ) func TestUnFqdn(t *testing.T) { testCases := []struct { desc string fqdn string expected string }{ { desc: "simple", fqdn: "foo.example.", expected: "foo.example", }, { desc: "already domain", fqdn: "foo.example", expected: "foo.example", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() domain := UnFqdn(test.fqdn) assert.Equal(t, test.expected, domain) }) } } func TestUnFqdnDomainsSeq(t *testing.T) { testCases := []struct { desc string fqdn string expected []string }{ { desc: "empty", fqdn: "", expected: nil, }, { desc: "TLD", fqdn: "com", expected: []string{"com"}, }, { desc: "2 levels", fqdn: "example.com", expected: []string{"example.com", "com"}, }, { desc: "3 levels", fqdn: "foo.example.com", expected: []string{"foo.example.com", "example.com", "com"}, }, } for _, test := range testCases { for name, suffix := range map[string]string{"": "", " FQDN": "."} { //nolint:gocritic t.Run(test.desc+name, func(t *testing.T) { t.Parallel() actual := slices.Collect(UnFqdnDomainsSeq(test.fqdn + suffix)) assert.Equal(t, test.expected, actual) }) } } } func TestDomainsSeq(t *testing.T) { testCases := []struct { desc string fqdn string expected []string }{ { desc: "empty", fqdn: "", expected: nil, }, { desc: "empty FQDN", fqdn: ".", expected: nil, }, { desc: "TLD FQDN", fqdn: "com", expected: []string{"com"}, }, { desc: "TLD", fqdn: "com.", expected: []string{"com."}, }, { desc: "2 levels", fqdn: "example.com", expected: []string{"example.com", "com"}, }, { desc: "2 levels FQDN", fqdn: "example.com.", expected: []string{"example.com.", "com."}, }, { desc: "3 levels", fqdn: "foo.example.com", expected: []string{"foo.example.com", "example.com", "com"}, }, { desc: "3 levels FQDN", fqdn: "foo.example.com.", expected: []string{"foo.example.com.", "example.com.", "com."}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() actual := slices.Collect(DomainsSeq(test.fqdn)) assert.Equal(t, test.expected, actual) }) } } ================================================ FILE: challenge/dns01/mock_test.go ================================================ package dns01 import ( "context" "net" "testing" "time" "github.com/miekg/dns" "github.com/stretchr/testify/require" ) func fakeNS(name, ns string) *dns.NS { return &dns.NS{ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 172800}, Ns: ns, } } func fakeA(name, ip string) *dns.A { return &dns.A{ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 10}, A: net.ParseIP(ip), } } func fakeTXT(name, value string) *dns.TXT { return &dns.TXT{ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 10}, Txt: []string{value}, } } // mockResolver modifies the default DNS resolver to use a custom network address during the test execution. // IMPORTANT: it modifying global variables. func mockResolver(t *testing.T, addr net.Addr) { t.Helper() _, port, err := net.SplitHostPort(addr.String()) require.NoError(t, err) originalDefaultNameserverPort := defaultNameserverPort t.Cleanup(func() { defaultNameserverPort = originalDefaultNameserverPort }) defaultNameserverPort = port originalResolver := net.DefaultResolver t.Cleanup(func() { net.DefaultResolver = originalResolver }) net.DefaultResolver = &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { d := net.Dialer{Timeout: 1 * time.Second} return d.DialContext(ctx, network, addr.String()) }, } } func useAsNameserver(t *testing.T, addr net.Addr) { t.Helper() ClearFqdnCache() t.Cleanup(func() { ClearFqdnCache() }) originalRecursiveNameservers := recursiveNameservers t.Cleanup(func() { recursiveNameservers = originalRecursiveNameservers }) recursiveNameservers = ParseNameservers([]string{addr.String()}) } ================================================ FILE: challenge/dns01/nameserver.go ================================================ package dns01 import ( "errors" "fmt" "net" "os" "slices" "strconv" "strings" "sync" "time" "github.com/miekg/dns" ) const defaultResolvConf = "/etc/resolv.conf" var fqdnSoaCache = &sync.Map{} var defaultNameservers = []string{ "google-public-dns-a.google.com:53", "google-public-dns-b.google.com:53", } // recursiveNameservers are used to pre-check DNS propagation. var recursiveNameservers = getNameservers(defaultResolvConf, defaultNameservers) // soaCacheEntry holds a cached SOA record (only selected fields). type soaCacheEntry struct { zone string // zone apex (a domain name) primaryNs string // primary nameserver for the zone apex expires time.Time // time when this cache entry should be evicted } func newSoaCacheEntry(soa *dns.SOA) *soaCacheEntry { return &soaCacheEntry{ zone: soa.Hdr.Name, primaryNs: soa.Ns, expires: time.Now().Add(time.Duration(soa.Refresh) * time.Second), } } // isExpired checks whether a cache entry should be considered expired. func (cache *soaCacheEntry) isExpired() bool { return time.Now().After(cache.expires) } // ClearFqdnCache clears the cache of fqdn to zone mappings. Primarily used in testing. func ClearFqdnCache() { // TODO(ldez): use `fqdnSoaCache.Clear()` when updating to go1.23 fqdnSoaCache.Range(func(k, v any) bool { fqdnSoaCache.Delete(k) return true }) } func AddDNSTimeout(timeout time.Duration) ChallengeOption { return func(_ *Challenge) error { dnsTimeout = timeout return nil } } func AddRecursiveNameservers(nameservers []string) ChallengeOption { return func(_ *Challenge) error { recursiveNameservers = ParseNameservers(nameservers) return nil } } // getNameservers attempts to get systems nameservers before falling back to the defaults. func getNameservers(path string, defaults []string) []string { config, err := dns.ClientConfigFromFile(path) if err != nil || len(config.Servers) == 0 { return defaults } return ParseNameservers(config.Servers) } func ParseNameservers(servers []string) []string { var resolvers []string for _, resolver := range servers { // ensure all servers have a port number if _, _, err := net.SplitHostPort(resolver); err != nil { resolvers = append(resolvers, net.JoinHostPort(resolver, "53")) } else { resolvers = append(resolvers, resolver) } } return resolvers } // lookupNameservers returns the authoritative nameservers for the given fqdn. func lookupNameservers(fqdn string) ([]string, error) { var authoritativeNss []string zone, err := FindZoneByFqdn(fqdn) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) } r, err := dnsQuery(zone, dns.TypeNS, recursiveNameservers, true) if err != nil { return nil, fmt.Errorf("NS call failed: %w", err) } for _, rr := range r.Answer { if ns, ok := rr.(*dns.NS); ok { authoritativeNss = append(authoritativeNss, strings.ToLower(ns.Ns)) } } if len(authoritativeNss) > 0 { return authoritativeNss, nil } return nil, fmt.Errorf("[zone=%s] could not determine authoritative nameservers", zone) } // FindPrimaryNsByFqdn determines the primary nameserver of the zone apex for the given fqdn // by recursing up the domain labels until the nameserver returns a SOA record in the answer section. func FindPrimaryNsByFqdn(fqdn string) (string, error) { return FindPrimaryNsByFqdnCustom(fqdn, recursiveNameservers) } // FindPrimaryNsByFqdnCustom determines the primary nameserver of the zone apex for the given fqdn // by recursing up the domain labels until the nameserver returns a SOA record in the answer section. func FindPrimaryNsByFqdnCustom(fqdn string, nameservers []string) (string, error) { soa, err := lookupSoaByFqdn(fqdn, nameservers) if err != nil { return "", fmt.Errorf("[fqdn=%s] %w", fqdn, err) } return soa.primaryNs, nil } // FindZoneByFqdn determines the zone apex for the given fqdn // by recursing up the domain labels until the nameserver returns a SOA record in the answer section. func FindZoneByFqdn(fqdn string) (string, error) { return FindZoneByFqdnCustom(fqdn, recursiveNameservers) } // FindZoneByFqdnCustom determines the zone apex for the given fqdn // by recursing up the domain labels until the nameserver returns a SOA record in the answer section. func FindZoneByFqdnCustom(fqdn string, nameservers []string) (string, error) { soa, err := lookupSoaByFqdn(fqdn, nameservers) if err != nil { return "", fmt.Errorf("[fqdn=%s] %w", fqdn, err) } return soa.zone, nil } func lookupSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) { // Do we have it cached and is it still fresh? entAny, ok := fqdnSoaCache.Load(fqdn) if ok && entAny != nil { ent, ok1 := entAny.(*soaCacheEntry) if ok1 && !ent.isExpired() { return ent, nil } } ent, err := fetchSoaByFqdn(fqdn, nameservers) if err != nil { return nil, err } fqdnSoaCache.Store(fqdn, ent) return ent, nil } func fetchSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) { var ( err error r *dns.Msg ) for domain := range DomainsSeq(fqdn) { r, err = dnsQuery(domain, dns.TypeSOA, nameservers, true) if err != nil { continue } if r == nil { continue } switch r.Rcode { case dns.RcodeSuccess: // Check if we got a SOA RR in the answer section if len(r.Answer) == 0 { continue } // CNAME records cannot/should not exist at the root of a zone. // So we skip a domain when a CNAME is found. if dnsMsgContainsCNAME(r) { continue } for _, ans := range r.Answer { if soa, ok := ans.(*dns.SOA); ok { return newSoaCacheEntry(soa), nil } } case dns.RcodeNameError: // NXDOMAIN default: // Any response code other than NOERROR and NXDOMAIN is treated as error return nil, &DNSError{Message: fmt.Sprintf("unexpected response for '%s'", domain), MsgOut: r} } } return nil, &DNSError{Message: fmt.Sprintf("could not find the start of authority for '%s'", fqdn), MsgOut: r, Err: err} } // dnsMsgContainsCNAME checks for a CNAME answer in msg. func dnsMsgContainsCNAME(msg *dns.Msg) bool { return slices.ContainsFunc(msg.Answer, func(rr dns.RR) bool { _, ok := rr.(*dns.CNAME) return ok }) } func dnsQuery(fqdn string, rtype uint16, nameservers []string, recursive bool) (*dns.Msg, error) { m := createDNSMsg(fqdn, rtype, recursive) if len(nameservers) == 0 { return nil, &DNSError{Message: "empty list of nameservers"} } var ( r *dns.Msg err error errAll error ) for _, ns := range nameservers { r, err = sendDNSQuery(m, ns) if err == nil && len(r.Answer) > 0 { break } errAll = errors.Join(errAll, err) } if err != nil { return r, errAll } return r, nil } func createDNSMsg(fqdn string, rtype uint16, recursive bool) *dns.Msg { m := new(dns.Msg) m.SetQuestion(fqdn, rtype) m.SetEdns0(4096, false) if !recursive { m.RecursionDesired = false } return m } func sendDNSQuery(m *dns.Msg, ns string) (*dns.Msg, error) { if ok, _ := strconv.ParseBool(os.Getenv("LEGO_EXPERIMENTAL_DNS_TCP_ONLY")); ok { tcp := &dns.Client{Net: "tcp", Timeout: dnsTimeout} r, _, err := tcp.Exchange(m, ns) if err != nil { return r, &DNSError{Message: "DNS call error", MsgIn: m, NS: ns, Err: err} } return r, nil } udp := &dns.Client{Net: "udp", Timeout: dnsTimeout} r, _, err := udp.Exchange(m, ns) if r != nil && r.Truncated { tcp := &dns.Client{Net: "tcp", Timeout: dnsTimeout} // If the TCP request succeeds, the "err" will reset to nil r, _, err = tcp.Exchange(m, ns) } if err != nil { return r, &DNSError{Message: "DNS call error", MsgIn: m, NS: ns, Err: err} } return r, nil } // DNSError error related to DNS calls. type DNSError struct { Message string NS string MsgIn *dns.Msg MsgOut *dns.Msg Err error } func (d *DNSError) Error() string { var details []string if d.NS != "" { details = append(details, "ns="+d.NS) } if d.MsgIn != nil && len(d.MsgIn.Question) > 0 { details = append(details, fmt.Sprintf("question='%s'", formatQuestions(d.MsgIn.Question))) } if d.MsgOut != nil { if d.MsgIn == nil || len(d.MsgIn.Question) == 0 { details = append(details, fmt.Sprintf("question='%s'", formatQuestions(d.MsgOut.Question))) } details = append(details, "code="+dns.RcodeToString[d.MsgOut.Rcode]) } msg := "DNS error" if d.Message != "" { msg = d.Message } if d.Err != nil { msg += ": " + d.Err.Error() } if len(details) > 0 { msg += " [" + strings.Join(details, ", ") + "]" } return msg } func (d *DNSError) Unwrap() error { return d.Err } func formatQuestions(questions []dns.Question) string { var parts []string for _, question := range questions { parts = append(parts, strings.ReplaceAll(strings.TrimPrefix(question.String(), ";"), "\t", " ")) } return strings.Join(parts, ";") } ================================================ FILE: challenge/dns01/nameserver_test.go ================================================ package dns01 import ( "errors" "sort" "testing" "github.com/go-acme/lego/v4/platform/tester/dnsmock" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_lookupNameserversOK(t *testing.T) { testCases := []struct { desc string fakeDNSServer *dnsmock.Builder fqdn string expected []string }{ { fqdn: "en.wikipedia.org.localhost.", fakeDNSServer: dnsmock.NewServer(). Query("en.wikipedia.org.localhost SOA", dnsmock.CNAME("dyna.wikimedia.org.localhost")). Query("wikipedia.org.localhost SOA", dnsmock.SOA("")). Query("wikipedia.org.localhost NS", dnsmock.Answer( fakeNS("wikipedia.org.localhost.", "ns0.wikimedia.org.localhost."), fakeNS("wikipedia.org.localhost.", "ns1.wikimedia.org.localhost."), fakeNS("wikipedia.org.localhost.", "ns2.wikimedia.org.localhost."), ), ), expected: []string{"ns0.wikimedia.org.localhost.", "ns1.wikimedia.org.localhost.", "ns2.wikimedia.org.localhost."}, }, { fqdn: "www.google.com.localhost.", fakeDNSServer: dnsmock.NewServer(). Query("www.google.com.localhost. SOA", dnsmock.Noop). Query("google.com.localhost. SOA", dnsmock.SOA("")). Query("google.com.localhost. NS", dnsmock.Answer( fakeNS("google.com.localhost.", "ns1.google.com.localhost."), fakeNS("google.com.localhost.", "ns2.google.com.localhost."), fakeNS("google.com.localhost.", "ns3.google.com.localhost."), fakeNS("google.com.localhost.", "ns4.google.com.localhost."), ), ), expected: []string{"ns1.google.com.localhost.", "ns2.google.com.localhost.", "ns3.google.com.localhost.", "ns4.google.com.localhost."}, }, { fqdn: "mail.proton.me.localhost.", fakeDNSServer: dnsmock.NewServer(). Query("mail.proton.me.localhost. SOA", dnsmock.Noop). Query("proton.me.localhost. SOA", dnsmock.SOA("")). Query("proton.me.localhost. NS", dnsmock.Answer( fakeNS("proton.me.localhost.", "ns1.proton.me.localhost."), fakeNS("proton.me.localhost.", "ns2.proton.me.localhost."), fakeNS("proton.me.localhost.", "ns3.proton.me.localhost."), ), ), expected: []string{"ns1.proton.me.localhost.", "ns2.proton.me.localhost.", "ns3.proton.me.localhost."}, }, } for _, test := range testCases { t.Run(test.fqdn, func(t *testing.T) { useAsNameserver(t, test.fakeDNSServer.Build(t)) nss, err := lookupNameservers(test.fqdn) require.NoError(t, err) sort.Strings(nss) sort.Strings(test.expected) assert.Equal(t, test.expected, nss) }) } } func Test_lookupNameserversErr(t *testing.T) { testCases := []struct { desc string fqdn string fakeDNSServer *dnsmock.Builder error string }{ { desc: "NXDOMAIN", fqdn: "example.invalid.", fakeDNSServer: dnsmock.NewServer(). Query(". SOA", dnsmock.Error(dns.RcodeNameError)), error: "could not find zone: [fqdn=example.invalid.] could not find the start of authority for 'example.invalid.' [question='invalid. IN SOA', code=NXDOMAIN]", }, { desc: "NS error", fqdn: "example.com.", fakeDNSServer: dnsmock.NewServer(). Query("example.com. SOA", dnsmock.SOA("")). Query("example.com. NS", dnsmock.Error(dns.RcodeServerFailure)), error: "[zone=example.com.] could not determine authoritative nameservers", }, { desc: "empty NS", fqdn: "example.com.", fakeDNSServer: dnsmock.NewServer(). Query("example.com. SOA", dnsmock.SOA("")). Query("example.me NS", dnsmock.Noop), error: "[zone=example.com.] could not determine authoritative nameservers", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { useAsNameserver(t, test.fakeDNSServer.Build(t)) _, err := lookupNameservers(test.fqdn) require.Error(t, err) assert.EqualError(t, err, test.error) }) } } type lookupSoaByFqdnTestCase struct { desc string fqdn string zone string primaryNs string nameservers []string expectedError string } func lookupSoaByFqdnTestCases(t *testing.T) []lookupSoaByFqdnTestCase { t.Helper() return []lookupSoaByFqdnTestCase{ { desc: "domain is a CNAME", fqdn: "mail.example.com.", zone: "example.com.", primaryNs: "ns1.example.com.", nameservers: []string{ dnsmock.NewServer(). Query("mail.example.com. SOA", dnsmock.CNAME("example.com.")). Query("example.com. SOA", dnsmock.SOA("")). Build(t). String(), }, }, { desc: "domain is a non-existent subdomain", fqdn: "foo.example.com.", zone: "example.com.", primaryNs: "ns1.example.com.", nameservers: []string{ dnsmock.NewServer(). Query("foo.example.com. SOA", dnsmock.Error(dns.RcodeNameError)). Query("example.com. SOA", dnsmock.SOA("")). Build(t). String(), }, }, { desc: "domain is a eTLD", fqdn: "example.com.ac.", zone: "ac.", primaryNs: "ns1.nic.ac.", nameservers: []string{ dnsmock.NewServer(). Query("example.com.ac. SOA", dnsmock.Error(dns.RcodeNameError)). Query("com.ac. SOA", dnsmock.Error(dns.RcodeNameError)). Query("ac. SOA", dnsmock.SOA("")). Build(t). String(), }, }, { desc: "domain is a cross-zone CNAME", fqdn: "cross-zone-example.example.com.", zone: "example.com.", primaryNs: "ns1.example.com.", nameservers: []string{ dnsmock.NewServer(). Query("cross-zone-example.example.com. SOA", dnsmock.CNAME("example.org.")). Query("example.com. SOA", dnsmock.SOA("")). Build(t). String(), }, }, { desc: "NXDOMAIN", fqdn: "test.lego.invalid.", zone: "lego.invalid.", nameservers: []string{ dnsmock.NewServer(). Query("test.lego.invalid. SOA", dnsmock.Error(dns.RcodeNameError)). Query("lego.invalid. SOA", dnsmock.Error(dns.RcodeNameError)). Query("invalid. SOA", dnsmock.Error(dns.RcodeNameError)). Build(t). String(), }, expectedError: `[fqdn=test.lego.invalid.] could not find the start of authority for 'test.lego.invalid.' [question='invalid. IN SOA', code=NXDOMAIN]`, }, { desc: "several non existent nameservers", fqdn: "mail.example.com.", zone: "example.com.", primaryNs: "ns1.example.com.", nameservers: []string{ ":7053", ":8053", dnsmock.NewServer(). Query("mail.example.com. SOA", dnsmock.CNAME("example.com.")). Query("example.com. SOA", dnsmock.SOA("")). Build(t). String(), }, }, { desc: "only non-existent nameservers", fqdn: "mail.example.com.", zone: "example.com.", nameservers: []string{":7053", ":8053", ":9053"}, // use only the start of the message because the port changes with each call: 127.0.0.1:XXXXX->127.0.0.1:7053. expectedError: "[fqdn=mail.example.com.] could not find the start of authority for 'mail.example.com.': DNS call error: read udp ", }, { desc: "no nameservers", fqdn: "test.example.com.", zone: "example.com.", nameservers: []string{}, expectedError: "[fqdn=test.example.com.] could not find the start of authority for 'test.example.com.': empty list of nameservers", }, } } func TestFindZoneByFqdnCustom(t *testing.T) { for _, test := range lookupSoaByFqdnTestCases(t) { t.Run(test.desc, func(t *testing.T) { ClearFqdnCache() zone, err := FindZoneByFqdnCustom(test.fqdn, test.nameservers) if test.expectedError != "" { require.Error(t, err) assert.ErrorContains(t, err, test.expectedError) } else { require.NoError(t, err) assert.Equal(t, test.zone, zone) } }) } } func TestFindPrimaryNsByFqdnCustom(t *testing.T) { for _, test := range lookupSoaByFqdnTestCases(t) { t.Run(test.desc, func(t *testing.T) { ClearFqdnCache() ns, err := FindPrimaryNsByFqdnCustom(test.fqdn, test.nameservers) if test.expectedError != "" { require.Error(t, err) assert.ErrorContains(t, err, test.expectedError) } else { require.NoError(t, err) assert.Equal(t, test.primaryNs, ns) } }) } } func Test_getNameservers_ResolveConfServers(t *testing.T) { testCases := []struct { fixture string expected []string defaults []string }{ { fixture: "fixtures/resolv.conf.1", defaults: []string{"127.0.0.1:53"}, expected: []string{"10.200.3.249:53", "10.200.3.250:5353", "[2001:4860:4860::8844]:53", "[10.0.0.1]:5353"}, }, { fixture: "fixtures/resolv.conf.nonexistant", defaults: []string{"127.0.0.1:53"}, expected: []string{"127.0.0.1:53"}, }, } for _, test := range testCases { t.Run(test.fixture, func(t *testing.T) { result := getNameservers(test.fixture, test.defaults) sort.Strings(result) sort.Strings(test.expected) assert.Equal(t, test.expected, result) }) } } func TestDNSError_Error(t *testing.T) { msgIn := createDNSMsg("example.com.", dns.TypeTXT, true) msgOut := createDNSMsg("example.org.", dns.TypeSOA, true) msgOut.Rcode = dns.RcodeNameError testCases := []struct { desc string err *DNSError expected string }{ { desc: "empty error", err: &DNSError{}, expected: "DNS error", }, { desc: "all fields", err: &DNSError{ Message: "Oops", NS: "example.com.", MsgIn: msgIn, MsgOut: msgOut, Err: errors.New("I did it again"), }, expected: "Oops: I did it again [ns=example.com., question='example.com. IN TXT', code=NXDOMAIN]", }, { desc: "only NS", err: &DNSError{ NS: "example.com.", }, expected: "DNS error [ns=example.com.]", }, { desc: "only MsgIn", err: &DNSError{ MsgIn: msgIn, }, expected: "DNS error [question='example.com. IN TXT']", }, { desc: "only MsgOut", err: &DNSError{ MsgOut: msgOut, }, expected: "DNS error [question='example.org. IN SOA', code=NXDOMAIN]", }, { desc: "only Err", err: &DNSError{ Err: errors.New("I did it again"), }, expected: "DNS error: I did it again", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() assert.EqualError(t, test.err, test.expected) }) } } ================================================ FILE: challenge/dns01/nameserver_unix.go ================================================ //go:build !windows package dns01 import "time" // dnsTimeout is used to override the default DNS timeout of 10 seconds. var dnsTimeout = 10 * time.Second ================================================ FILE: challenge/dns01/nameserver_windows.go ================================================ //go:build windows package dns01 import "time" // dnsTimeout is used to override the default DNS timeout of 20 seconds. var dnsTimeout = 20 * time.Second ================================================ FILE: challenge/dns01/precheck.go ================================================ package dns01 import ( "fmt" "net" "strings" "time" "github.com/miekg/dns" ) // defaultNameserverPort used by authoritative NS. // This is for tests only. var defaultNameserverPort = "53" // PreCheckFunc checks DNS propagation before notifying ACME that the DNS challenge is ready. type PreCheckFunc func(fqdn, value string) (bool, error) // WrapPreCheckFunc wraps a PreCheckFunc in order to do extra operations before or after // the main check, put it in a loop, etc. type WrapPreCheckFunc func(domain, fqdn, value string, check PreCheckFunc) (bool, error) // WrapPreCheck Allow to define checks before notifying ACME that the DNS challenge is ready. func WrapPreCheck(wrap WrapPreCheckFunc) ChallengeOption { return func(chlg *Challenge) error { chlg.preCheck.checkFunc = wrap return nil } } // DisableCompletePropagationRequirement obsolete. // // Deprecated: use DisableAuthoritativeNssPropagationRequirement instead. func DisableCompletePropagationRequirement() ChallengeOption { return DisableAuthoritativeNssPropagationRequirement() } func DisableAuthoritativeNssPropagationRequirement() ChallengeOption { return func(chlg *Challenge) error { chlg.preCheck.requireAuthoritativeNssPropagation = false return nil } } func RecursiveNSsPropagationRequirement() ChallengeOption { return func(chlg *Challenge) error { chlg.preCheck.requireRecursiveNssPropagation = true return nil } } func PropagationWait(wait time.Duration, skipCheck bool) ChallengeOption { return WrapPreCheck(func(domain, fqdn, value string, check PreCheckFunc) (bool, error) { time.Sleep(wait) if skipCheck { return true, nil } return check(fqdn, value) }) } type preCheck struct { // checks DNS propagation before notifying ACME that the DNS challenge is ready. checkFunc WrapPreCheckFunc // require the TXT record to be propagated to all authoritative name servers requireAuthoritativeNssPropagation bool // require the TXT record to be propagated to all recursive name servers requireRecursiveNssPropagation bool } func newPreCheck() preCheck { return preCheck{ requireAuthoritativeNssPropagation: true, } } func (p preCheck) call(domain, fqdn, value string) (bool, error) { if p.checkFunc == nil { return p.checkDNSPropagation(fqdn, value) } return p.checkFunc(domain, fqdn, value, p.checkDNSPropagation) } // checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers. func (p preCheck) checkDNSPropagation(fqdn, value string) (bool, error) { // Initial attempt to resolve at the recursive NS (require to get CNAME) r, err := dnsQuery(fqdn, dns.TypeTXT, recursiveNameservers, true) if err != nil { return false, fmt.Errorf("initial recursive nameserver: %w", err) } if r.Rcode == dns.RcodeSuccess { fqdn = updateDomainWithCName(r, fqdn) } if p.requireRecursiveNssPropagation { _, err = checkNameserversPropagation(fqdn, value, recursiveNameservers, false) if err != nil { return false, fmt.Errorf("recursive nameservers: %w", err) } } if !p.requireAuthoritativeNssPropagation { return true, nil } authoritativeNss, err := lookupNameservers(fqdn) if err != nil { return false, err } found, err := checkNameserversPropagation(fqdn, value, authoritativeNss, true) if err != nil { return found, fmt.Errorf("authoritative nameservers: %w", err) } return found, nil } // checkNameserversPropagation queries each of the given nameservers for the expected TXT record. func checkNameserversPropagation(fqdn, value string, nameservers []string, addPort bool) (bool, error) { for _, ns := range nameservers { if addPort { ns = net.JoinHostPort(ns, defaultNameserverPort) } r, err := dnsQuery(fqdn, dns.TypeTXT, []string{ns}, false) if err != nil { return false, err } if r.Rcode != dns.RcodeSuccess { return false, fmt.Errorf("NS %s returned %s for %s", ns, dns.RcodeToString[r.Rcode], fqdn) } var records []string var found bool for _, rr := range r.Answer { if txt, ok := rr.(*dns.TXT); ok { record := strings.Join(txt.Txt, "") records = append(records, record) if record == value { found = true break } } } if !found { return false, fmt.Errorf("NS %s did not return the expected TXT record [fqdn: %s, value: %s]: %s", ns, fqdn, value, strings.Join(records, " ,")) } } return true, nil } ================================================ FILE: challenge/dns01/precheck_test.go ================================================ package dns01 import ( "testing" "github.com/go-acme/lego/v4/platform/tester/dnsmock" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_preCheck_checkDNSPropagation(t *testing.T) { mockResolver(t, dnsmock.NewServer(). Query("ns0.lego.localhost. A", dnsmock.Answer(fakeA("ns0.lego.localhost.", "127.0.0.1"))). Query("ns1.lego.localhost. A", dnsmock.Answer(fakeA("ns1.lego.localhost.", "127.0.0.1"))). Query("example.com. TXT", dnsmock.Answer( fakeTXT("example.com.", "one"), fakeTXT("example.com.", "two"), fakeTXT("example.com.", "three"), fakeTXT("example.com.", "four"), fakeTXT("example.com.", "five"), ), ). Build(t), ) useAsNameserver(t, dnsmock.NewServer(). Query("acme-staging.api.example.com. SOA", dnsmock.Error(dns.RcodeNameError)). Query("api.example.com. SOA", dnsmock.Error(dns.RcodeNameError)). Query("example.com. SOA", dnsmock.SOA("")). Query("example.com. NS", dnsmock.Answer( fakeNS("example.com.", "ns0.lego.localhost."), fakeNS("example.com.", "ns1.lego.localhost."), ), ). Build(t), ) testCases := []struct { desc string fqdn string value string expectedError string }{ { desc: "success", fqdn: "example.com.", value: "four", }, { desc: "no matching TXT record", fqdn: "acme-staging.api.example.com.", value: "fe01=", expectedError: "did not return the expected TXT record [fqdn: acme-staging.api.example.com., value: fe01=]: one ,two ,three ,four ,five", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { ClearFqdnCache() check := newPreCheck() ok, err := check.checkDNSPropagation(test.fqdn, test.value) if test.expectedError != "" { assert.ErrorContainsf(t, err, test.expectedError, "PreCheckDNS must fail for %s", test.fqdn) assert.False(t, ok, "PreCheckDNS must fail for %s", test.fqdn) } else { assert.NoErrorf(t, err, "PreCheckDNS failed for %s", test.fqdn) assert.True(t, ok, "PreCheckDNS failed for %s", test.fqdn) } }) } } func Test_checkNameserversPropagation_authoritativeNss(t *testing.T) { testCases := []struct { desc string fqdn, value string fakeDNSServer *dnsmock.Builder expectedError string }{ { desc: "TXT RR w/ expected value", // NS: asnums.routeviews.org. fqdn: "8.8.8.8.asn.routeviews.org.", value: "151698.8.8.024", fakeDNSServer: dnsmock.NewServer(). Query("8.8.8.8.asn.routeviews.org. TXT", dnsmock.Answer( fakeTXT("8.8.8.8.asn.routeviews.org.", "151698.8.8.024"), ), ), }, { desc: "TXT RR w/ unexpected value", // NS: asnums.routeviews.org. fqdn: "8.8.8.8.asn.routeviews.org.", value: "fe01=", fakeDNSServer: dnsmock.NewServer(). Query("8.8.8.8.asn.routeviews.org. TXT", dnsmock.Answer( fakeTXT("8.8.8.8.asn.routeviews.org.", "15169"), fakeTXT("8.8.8.8.asn.routeviews.org.", "8.8.8.0"), fakeTXT("8.8.8.8.asn.routeviews.org.", "24"), ), ), expectedError: "did not return the expected TXT record [fqdn: 8.8.8.8.asn.routeviews.org., value: fe01=]: 15169 ,8.8.8.0 ,24", }, { desc: "No TXT RR", // NS: ns2.google.com. fqdn: "ns1.google.com.", value: "fe01=", fakeDNSServer: dnsmock.NewServer(). Query("ns1.google.com.", dnsmock.Noop), expectedError: "did not return the expected TXT record [fqdn: ns1.google.com., value: fe01=]: ", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { ClearFqdnCache() addr := test.fakeDNSServer.Build(t) ok, err := checkNameserversPropagation(test.fqdn, test.value, []string{addr.String()}, false) if test.expectedError == "" { require.NoError(t, err) assert.True(t, ok) } else { require.Error(t, err) require.ErrorContains(t, err, test.expectedError) assert.False(t, ok) } }) } } ================================================ FILE: challenge/http01/domain_matcher.go ================================================ package http01 import ( "fmt" "net/http" "net/netip" "strings" ) // A domainMatcher tries to match a domain (the one we're requesting a certificate for) // in the HTTP request coming from the ACME validation servers. // This step is part of DNS rebind attack prevention, // where the webserver matches incoming requests to a list of domain the server acts authoritative for. // // The most simple check involves finding the domain in the HTTP Host header; // this is what hostMatcher does. // Use it, when the http01.ProviderServer is directly reachable from the internet, // or when it operates behind a transparent proxy. // // In many (reverse) proxy setups, Apache and NGINX traditionally move the Host header to a new header named X-Forwarded-Host. // Use arbitraryMatcher("X-Forwarded-Host") in this case, // or the appropriate header name for other proxy servers. // // RFC7239 has standardized the different forwarding headers into a single header named Forwarded. // The header value has a different format, so you should use forwardedMatcher // when the http01.ProviderServer operates behind a RFC7239 compatible proxy. // https://www.rfc-editor.org/rfc/rfc7239.html // // Note: RFC7239 also reminds us, "that an HTTP list [...] may be split over multiple header fields" (section 7.1), // meaning that // // X-Header: a // X-Header: b // // is equal to // // X-Header: a, b // // All matcher implementations (explicitly not excluding arbitraryMatcher!) // have in common that they only match against the first value in such lists. type domainMatcher interface { // matches checks whether the request is valid for the given domain. matches(request *http.Request, domain string) bool // name returns the header name used in the check. // This is primarily used to create meaningful error messages. name() string } // hostMatcher checks whether (*net/http).Request.Host starts with a domain name. type hostMatcher struct{} func (m *hostMatcher) name() string { return "Host" } func (m *hostMatcher) matches(r *http.Request, domain string) bool { return matchDomain(r.Host, domain) } // arbitraryMatcher checks whether the specified (*net/http.Request).Header value starts with a domain name. type arbitraryMatcher string func (m arbitraryMatcher) name() string { return string(m) } func (m arbitraryMatcher) matches(r *http.Request, domain string) bool { return matchDomain(r.Header.Get(m.name()), domain) } // forwardedMatcher checks whether the Forwarded header contains a "host" element starting with a domain name. // See https://www.rfc-editor.org/rfc/rfc7239.html for details. type forwardedMatcher struct{} func (m *forwardedMatcher) name() string { return "Forwarded" } func (m *forwardedMatcher) matches(r *http.Request, domain string) bool { fwds, err := parseForwardedHeader(r.Header.Get(m.name())) if err != nil { return false } if len(fwds) == 0 { return false } host := fwds[0]["host"] return matchDomain(host, domain) } // parsing requires some form of state machine. func parseForwardedHeader(s string) (elements []map[string]string, err error) { cur := make(map[string]string) key := "" val := "" inquote := false pos := 0 l := len(s) for i := 0; i < l; i++ { r := rune(s[i]) if inquote { if r == '"' { cur[key] = s[pos:i] key = "" pos = i inquote = false } continue } switch { case r == '"': // start of quoted-string if key == "" { return nil, fmt.Errorf("unexpected quoted string as pos %d", i) } inquote = true pos = i + 1 case r == ';': // end of forwarded-pair cur[key] = s[pos:i] key = "" i = skipWS(s, i) pos = i + 1 case r == '=': // end of token key = strings.ToLower(strings.TrimFunc(s[pos:i], isWS)) i = skipWS(s, i) pos = i + 1 case r == ',': // end of forwarded-element if key != "" { val = s[pos:i] cur[key] = val } elements = append(elements, cur) cur = make(map[string]string) key = "" val = "" i = skipWS(s, i) pos = i + 1 case tchar(r) || isWS(r): // valid token character or whitespace continue default: return nil, fmt.Errorf("invalid token character at pos %d: %c", i, r) } } if inquote { return nil, fmt.Errorf("unterminated quoted-string at pos %d", len(s)) } if key != "" { if pos < len(s) { val = s[pos:] } cur[key] = val } if len(cur) > 0 { elements = append(elements, cur) } return elements, nil } func tchar(r rune) bool { return strings.ContainsRune("!#$%&'*+-.^_`|~", r) || '0' <= r && r <= '9' || 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' } func skipWS(s string, i int) int { for isWS(rune(s[i+1])) { i++ } return i } func isWS(r rune) bool { return strings.ContainsRune(" \t\v\r\n", r) } func matchDomain(src, domain string) bool { addr, err := netip.ParseAddr(domain) if err == nil && addr.Is6() { domain = "[" + domain + "]" } return strings.HasPrefix(src, domain) } ================================================ FILE: challenge/http01/domain_matcher_test.go ================================================ package http01 import ( "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_parseForwardedHeader(t *testing.T) { testCases := []struct { name string input string want []map[string]string err string }{ { name: "empty input", input: "", want: nil, }, { name: "simple case", input: `for=1.2.3.4;host=example.com; by=127.0.0.1`, want: []map[string]string{ {"for": "1.2.3.4", "host": "example.com", "by": "127.0.0.1"}, }, }, { name: "quoted-string", input: `foo="bar"`, want: []map[string]string{ {"foo": "bar"}, }, }, { name: "multiple entries", input: `a=1, b=2; c=3, d=4`, want: []map[string]string{ {"a": "1"}, {"b": "2", "c": "3"}, {"d": "4"}, }, }, { name: "whitespace", input: " a = 1,\tb\n=\r\n2,c=\" untrimmed \"", want: []map[string]string{ {"a": "1"}, {"b": "2"}, {"c": " untrimmed "}, }, }, { name: "unterminated quote", input: `x="y`, err: "unterminated quoted-string", }, { name: "unexpected quote", input: `"x=y"`, err: "unexpected quote", }, { name: "invalid token", input: `a=b, ipv6=[fe80::1], x=y`, err: "invalid token character at pos 10: [", }, } for _, test := range testCases { t.Run(test.name, func(t *testing.T) { t.Parallel() actual, err := parseForwardedHeader(test.input) if test.err == "" { require.NoError(t, err) assert.Equal(t, test.want, actual) } else { require.Error(t, err) assert.Contains(t, err.Error(), test.err) } }) } } func Test_hostMatcher_matches(t *testing.T) { hm := &hostMatcher{} testCases := []struct { desc string domain string req *http.Request expected assert.BoolAssertionFunc }{ { desc: "exact domain", domain: "example.com", req: httptest.NewRequest(http.MethodGet, "http://example.com", nil), expected: assert.True, }, { desc: "request with path", domain: "example.com", req: httptest.NewRequest(http.MethodGet, "http://example.com/foo/bar", nil), expected: assert.True, }, { desc: "ipv4", domain: "127.0.0.1", req: httptest.NewRequest(http.MethodGet, "http://127.0.0.1", nil), expected: assert.True, }, { desc: "ipv6", domain: "2001:db8::1", req: httptest.NewRequest(http.MethodGet, "http://[2001:db8::1]", nil), expected: assert.True, }, { desc: "ipv6 with brackets", domain: "[2001:db8::1]", req: httptest.NewRequest(http.MethodGet, "http://[2001:db8::1]", nil), expected: assert.True, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() hm.matches(test.req, test.domain) test.expected(t, hm.matches(test.req, test.domain)) }) } } ================================================ FILE: challenge/http01/http_challenge.go ================================================ package http01 import ( "fmt" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/log" ) type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error type ChallengeOption func(*Challenge) error // SetDelay sets a delay between the start of the HTTP server and the challenge validation. func SetDelay(delay time.Duration) ChallengeOption { return func(chlg *Challenge) error { chlg.delay = delay return nil } } // ChallengePath returns the URL path for the `http-01` challenge. func ChallengePath(token string) string { return "/.well-known/acme-challenge/" + token } type Challenge struct { core *api.Core validate ValidateFunc provider challenge.Provider delay time.Duration } func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge { chlg := &Challenge{ core: core, validate: validate, provider: provider, } for _, opt := range opts { err := opt(chlg) if err != nil { log.Infof("challenge option error: %v", err) } } return chlg } func (c *Challenge) SetProvider(provider challenge.Provider) { c.provider = provider } func (c *Challenge) Solve(authz acme.Authorization) error { domain := challenge.GetTargetedDomain(authz) log.Infof("[%s] acme: Trying to solve HTTP-01", domain) chlng, err := challenge.FindChallenge(challenge.HTTP01, authz) if err != nil { return err } // Generate the Key Authorization for the challenge keyAuth, err := c.core.GetKeyAuthorization(chlng.Token) if err != nil { return err } err = c.provider.Present(authz.Identifier.Value, chlng.Token, keyAuth) if err != nil { return fmt.Errorf("[%s] acme: error presenting token: %w", domain, err) } defer func() { err := c.provider.CleanUp(authz.Identifier.Value, chlng.Token, keyAuth) if err != nil { log.Warnf("[%s] acme: cleaning up failed: %v", domain, err) } }() if c.delay > 0 { time.Sleep(c.delay) } chlng.KeyAuthorization = keyAuth return c.validate(c.core, domain, chlng) } ================================================ FILE: challenge/http01/http_challenge_server.go ================================================ package http01 import ( "fmt" "io/fs" "net" "net/http" "net/textproto" "os" "strings" "github.com/go-acme/lego/v4/log" ) // ProviderServer implements ChallengeProvider for `http-01` challenge. // It may be instantiated without using the NewProviderServer function if // you want only to use the default values. type ProviderServer struct { address string network string // must be valid argument to net.Listen socketMode fs.FileMode matcher domainMatcher done chan bool listener net.Listener } // NewProviderServer creates a new ProviderServer on the selected interface and port. // Setting iface and / or port to an empty string will make the server fall back to // the "any" interface and port 80 respectively. func NewProviderServer(iface, port string) *ProviderServer { if port == "" { port = "80" } return &ProviderServer{network: "tcp", address: net.JoinHostPort(iface, port), matcher: &hostMatcher{}} } func NewUnixProviderServer(socketPath string, mode fs.FileMode) *ProviderServer { return &ProviderServer{network: "unix", address: socketPath, socketMode: mode, matcher: &hostMatcher{}} } // Present starts a web server and makes the token available at `ChallengePath(token)` for web requests. func (s *ProviderServer) Present(domain, token, keyAuth string) error { var err error s.listener, err = net.Listen(s.network, s.GetAddress()) if err != nil { return fmt.Errorf("could not start HTTP server for challenge: %w", err) } if s.network == "unix" { if err = os.Chmod(s.address, s.socketMode); err != nil { return fmt.Errorf("chmod %s: %w", s.address, err) } } s.done = make(chan bool) go s.serve(domain, token, keyAuth) return nil } func (s *ProviderServer) GetAddress() string { return s.address } // CleanUp closes the HTTP server and removes the token from `ChallengePath(token)`. func (s *ProviderServer) CleanUp(domain, token, keyAuth string) error { if s.listener == nil { return nil } s.listener.Close() <-s.done return nil } // SetProxyHeader changes the validation of incoming requests. // By default, s matches the "Host" header value to the domain name. // // When the server runs behind a proxy server, this is not the correct place to look at; // Apache and NGINX have traditionally moved the original Host header into a new header named "X-Forwarded-Host". // Other webservers might use different names; // and RFC7239 has standardized a new header named "Forwarded" (with slightly different semantics). // // The exact behavior depends on the value of headerName: // - "" (the empty string) and "Host" will restore the default and only check the Host header // - "Forwarded" will look for a Forwarded header, and inspect it according to https://www.rfc-editor.org/rfc/rfc7239.html // - any other value will check the header value with the same name. func (s *ProviderServer) SetProxyHeader(headerName string) { switch h := textproto.CanonicalMIMEHeaderKey(headerName); h { case "", "Host": s.matcher = &hostMatcher{} case "Forwarded": s.matcher = &forwardedMatcher{} default: s.matcher = arbitraryMatcher(h) } } func (s *ProviderServer) serve(domain, token, keyAuth string) { path := ChallengePath(token) // The incoming request will be validated to prevent DNS rebind attacks. // We only respond with the keyAuth, when we're receiving a GET requests with // the "Host" header matching the domain (the latter is configurable though SetProxyHeader). mux := http.NewServeMux() mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && s.matcher.matches(r, domain) { w.Header().Set("Content-Type", "text/plain") _, err := w.Write([]byte(keyAuth)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } log.Infof("[%s] Served key authentication", domain) return } log.Warnf("Received request for domain %s with method %s but the domain did not match any challenge. Please ensure you are passing the %s header properly.", r.Host, r.Method, s.matcher.name()) _, err := w.Write([]byte("TEST")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } }) httpServer := &http.Server{Handler: mux} // Once httpServer is shut down // we don't want any lingering connections, so disable KeepAlives. httpServer.SetKeepAlivesEnabled(false) err := httpServer.Serve(s.listener) if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { log.Println(err) } s.done <- true } ================================================ FILE: challenge/http01/http_challenge_test.go ================================================ package http01 import ( "context" "crypto/rand" "crypto/rsa" "fmt" "io" "io/fs" "net" "net/http" "net/textproto" "os" "path/filepath" "runtime" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProviderServer_GetAddress(t *testing.T) { dir := t.TempDir() t.Cleanup(func() { _ = os.RemoveAll(dir) }) sock := filepath.Join(dir, "var", "run", "test") testCases := []struct { desc string server *ProviderServer expected string }{ { desc: "TCP default address", server: NewProviderServer("", ""), expected: ":80", }, { desc: "TCP with explicit port", server: NewProviderServer("", "8080"), expected: ":8080", }, { desc: "TCP with host and port", server: NewProviderServer("localhost", "8080"), expected: "localhost:8080", }, { desc: "UDS socket", server: NewUnixProviderServer(sock, fs.ModeSocket|0o666), expected: sock, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() address := test.server.GetAddress() assert.Equal(t, test.expected, address) }) } } func TestChallenge(t *testing.T) { server := tester.MockACMEServer().BuildHTTPS(t) providerServer := NewProviderServer("", "23457") validate := func(_ *api.Core, _ string, chlng acme.Challenge) error { uri := "http://localhost" + providerServer.GetAddress() + ChallengePath(chlng.Token) resp, err := http.DefaultClient.Get(uri) if err != nil { return err } defer resp.Body.Close() if want := "text/plain"; resp.Header.Get("Content-Type") != want { t.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want) } body, err := io.ReadAll(resp.Body) if err != nil { return err } bodyStr := string(body) if bodyStr != chlng.KeyAuthorization { t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization) } return nil } privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge(core, validate, providerServer) authz := acme.Authorization{ Identifier: acme.Identifier{ Value: "localhost:23457", }, Challenges: []acme.Challenge{ {Type: challenge.HTTP01.String(), Token: "http1"}, }, } err = solver.Solve(authz) require.NoError(t, err) } func TestChallengeUnix(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("only for UNIX systems") } server := tester.MockACMEServer().BuildHTTPS(t) dir := t.TempDir() t.Cleanup(func() { _ = os.RemoveAll(dir) }) socket := filepath.Join(dir, "lego-challenge-test.sock") providerServer := NewUnixProviderServer(socket, fs.ModeSocket|0o666) validate := func(_ *api.Core, _ string, chlng acme.Challenge) error { // any uri will do, as we hijack the dial uri := "http://localhost" + ChallengePath(chlng.Token) client := &http.Client{Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return net.Dial("unix", socket) }, }} resp, err := client.Get(uri) if err != nil { return err } defer resp.Body.Close() if want := "text/plain"; resp.Header.Get("Content-Type") != want { t.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want) } body, err := io.ReadAll(resp.Body) if err != nil { return err } bodyStr := string(body) if bodyStr != chlng.KeyAuthorization { t.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization) } return nil } privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge(core, validate, providerServer) authz := acme.Authorization{ Identifier: acme.Identifier{ Value: "localhost", }, Challenges: []acme.Challenge{ {Type: challenge.HTTP01.String(), Token: "http1"}, }, } err = solver.Solve(authz) require.NoError(t, err) } func TestChallengeInvalidPort(t *testing.T) { server := tester.MockACMEServer().BuildHTTPS(t) privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) validate := func(_ *api.Core, _ string, _ acme.Challenge) error { return nil } solver := NewChallenge(core, validate, NewProviderServer("", "123456")) authz := acme.Authorization{ Identifier: acme.Identifier{ Value: "localhost:123456", }, Challenges: []acme.Challenge{ {Type: challenge.HTTP01.String(), Token: "http2"}, }, } err = solver.Solve(authz) require.Error(t, err) assert.Contains(t, err.Error(), "invalid port") assert.Contains(t, err.Error(), "123456") } type testProxyHeader struct { name string values []string } func (h *testProxyHeader) update(r *http.Request) { if h == nil || len(h.values) == 0 { return } if h.name == "Host" { r.Host = h.values[0] } else if h.name != "" { r.Header[h.name] = h.values } } func TestChallengeWithProxy(t *testing.T) { h := func(name string, values ...string) *testProxyHeader { name = textproto.CanonicalMIMEHeaderKey(name) return &testProxyHeader{name, values} } const ( ok = "localhost:23457" nook = "example.com" ) testCases := []struct { name string header *testProxyHeader extra *testProxyHeader isErr bool }{ // tests for hostMatcher { name: "no proxy", }, { name: "empty string", header: h(""), }, { name: "empty Host", header: h("host"), }, { name: "matching Host", header: h("host", ok), }, { name: "Host mismatch", header: h("host", nook), isErr: true, }, { name: "Host mismatch (ignoring forwarding header)", header: h("host", nook), extra: h("X-Forwarded-Host", ok), isErr: true, }, // test for arbitraryMatcher { name: "matching X-Forwarded-Host", header: h("X-Forwarded-Host", ok), }, { name: "matching X-Forwarded-Host (multiple fields)", header: h("X-Forwarded-Host", ok, nook), }, { name: "matching X-Forwarded-Host (chain value)", header: h("X-Forwarded-Host", ok+", "+nook), }, { name: "X-Forwarded-Host mismatch", header: h("X-Forwarded-Host", nook), extra: h("host", ok), isErr: true, }, { name: "X-Forwarded-Host mismatch (multiple fields)", header: h("X-Forwarded-Host", nook, ok), isErr: true, }, { name: "matching X-Something-Else", header: h("X-Something-Else", ok), }, { name: "matching X-Something-Else (multiple fields)", header: h("X-Something-Else", ok, nook), }, { name: "matching X-Something-Else (chain value)", header: h("X-Something-Else", ok+", "+nook), }, { name: "X-Something-Else mismatch", header: h("X-Something-Else", nook), isErr: true, }, { name: "X-Something-Else mismatch (multiple fields)", header: h("X-Something-Else", nook, ok), isErr: true, }, { name: "X-Something-Else mismatch (chain value)", header: h("X-Something-Else", nook+", "+ok), isErr: true, }, // tests for forwardedHeader { name: "matching Forwarded", header: h("Forwarded", fmt.Sprintf("host=%q;foo=bar", ok)), }, { name: "matching Forwarded (multiple fields)", header: h("Forwarded", fmt.Sprintf("host=%q", ok), "host="+nook), }, { name: "matching Forwarded (chain value)", header: h("Forwarded", fmt.Sprintf("host=%q, host=%s", ok, nook)), }, { name: "Forwarded mismatch", header: h("Forwarded", "host="+nook), isErr: true, }, { name: "Forwarded mismatch (missing information)", header: h("Forwarded", "for=127.0.0.1"), isErr: true, }, { name: "Forwarded mismatch (multiple fields)", header: h("Forwarded", "host="+nook, fmt.Sprintf("host=%q", ok)), isErr: true, }, { name: "Forwarded mismatch (chain value)", header: h("Forwarded", fmt.Sprintf("host=%s, host=%q", nook, ok)), isErr: true, }, } for _, test := range testCases { t.Run(test.name, func(t *testing.T) { testServeWithProxy(t, test.header, test.extra, test.isErr) }) } } func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectError bool) { t.Helper() server := tester.MockACMEServer().BuildHTTPS(t) providerServer := NewProviderServer("localhost", "23457") if header != nil { providerServer.SetProxyHeader(header.name) } validate := func(_ *api.Core, _ string, chlng acme.Challenge) error { uri := "http://" + providerServer.GetAddress() + ChallengePath(chlng.Token) req, err := http.NewRequest(http.MethodGet, uri, nil) if err != nil { return err } header.update(req) extra.update(req) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if want := "text/plain"; resp.Header.Get("Content-Type") != want { return fmt.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want) } body, err := io.ReadAll(resp.Body) if err != nil { return err } bodyStr := string(body) if bodyStr != chlng.KeyAuthorization { return fmt.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization) } return nil } privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge(core, validate, providerServer) authz := acme.Authorization{ Identifier: acme.Identifier{ Value: "localhost:23457", }, Challenges: []acme.Challenge{ {Type: challenge.HTTP01.String(), Token: "http1"}, }, } err = solver.Solve(authz) if expectError { require.Error(t, err) } else { require.NoError(t, err) } } ================================================ FILE: challenge/provider.go ================================================ package challenge import "time" // Provider enables implementing a custom challenge // provider. Present presents the solution to a challenge available to // be solved. CleanUp will be called by the challenge if Present ends // in a non-error state. type Provider interface { Present(domain, token, keyAuth string) error CleanUp(domain, token, keyAuth string) error } // ProviderTimeout allows for implementing a // Provider where an unusually long timeout is required when // waiting for an ACME challenge to be satisfied, such as when // checking for DNS record propagation. If an implementor of a // Provider provides a Timeout method, then the return values // of the Timeout method will be used when appropriate by the acme // package. The interval value is the time between checks. // // The default values used for timeout and interval are 60 seconds and // 2 seconds respectively. These are used when no Timeout method is // defined for the Provider. type ProviderTimeout interface { Provider Timeout() (timeout, interval time.Duration) } ================================================ FILE: challenge/resolver/errors.go ================================================ package resolver import ( "bytes" "fmt" "maps" "slices" "sort" ) // obtainError is returned when there are specific errors available per domain. type obtainError map[string]error func (e obtainError) Error() string { buffer := bytes.NewBufferString("error: one or more domains had a problem:\n") var domains []string for domain := range e { domains = append(domains, domain) } sort.Strings(domains) for _, domain := range domains { _, _ = fmt.Fprintf(buffer, "[%s] %s\n", domain, e[domain]) } return buffer.String() } func (e obtainError) Unwrap() []error { return slices.AppendSeq(make([]error, 0, len(e)), maps.Values(e)) } ================================================ FILE: challenge/resolver/errors_test.go ================================================ package resolver import ( "errors" "testing" "github.com/go-acme/lego/v4/acme" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_obtainError_Error(t *testing.T) { err := obtainError{ "a": &acme.ProblemDetails{Type: "001"}, "b": errors.New("oops"), "c": errors.New("I did it again"), } require.EqualError(t, err, `error: one or more domains had a problem: [a] acme: error: 0 :: 001 :: [b] oops [c] I did it again `) } func Test_obtainError_Unwrap(t *testing.T) { testCases := []struct { desc string err obtainError assert assert.BoolAssertionFunc }{ { desc: "one ok", err: obtainError{ "a": &acme.ProblemDetails{}, "b": errors.New("oops"), "c": errors.New("I did it again"), }, assert: assert.True, }, { desc: "all ok", err: obtainError{ "a": &acme.ProblemDetails{Type: "001"}, "b": &acme.ProblemDetails{Type: "002"}, "c": &acme.ProblemDetails{Type: "002"}, }, assert: assert.True, }, { desc: "nope", err: obtainError{ "a": errors.New("hello"), "b": errors.New("oops"), "c": errors.New("I did it again"), }, assert: assert.False, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() var pd *acme.ProblemDetails test.assert(t, errors.As(test.err, &pd)) }) } } ================================================ FILE: challenge/resolver/prober.go ================================================ package resolver import ( "fmt" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/log" ) // Interface for all challenge solvers to implement. type solver interface { Solve(authorization acme.Authorization) error } // Interface for challenges like dns, where we can set a record in advance for ALL challenges. // This saves quite a bit of time vs creating the records and solving them serially. type preSolver interface { PreSolve(authorization acme.Authorization) error } // Interface for challenges like dns, where we can solve all the challenges before to delete them. type cleanup interface { CleanUp(authorization acme.Authorization) error } type sequential interface { Sequential() (bool, time.Duration) } // an authz with the solver we have chosen and the index of the challenge associated with it. type selectedAuthSolver struct { authz acme.Authorization solver solver } type Prober struct { solverManager *SolverManager } func NewProber(solverManager *SolverManager) *Prober { return &Prober{ solverManager: solverManager, } } // Solve Looks through the challenge combinations to find a solvable match. // Then solves the challenges in series and returns. func (p *Prober) Solve(authorizations []acme.Authorization) error { failures := make(obtainError) var ( authSolvers []*selectedAuthSolver authSolversSequential []*selectedAuthSolver ) // Loop through the resources, basically through the domains. // First pass just selects a solver for each authz. for _, authz := range authorizations { domain := challenge.GetTargetedDomain(authz) if authz.Status == acme.StatusValid { // Boulder might recycle recent validated authz (see issue #267) log.Infof("[%s] acme: authorization already valid; skipping challenge", domain) continue } if solvr := p.solverManager.chooseSolver(authz); solvr != nil { authSolver := &selectedAuthSolver{authz: authz, solver: solvr} switch s := solvr.(type) { case sequential: if ok, _ := s.Sequential(); ok { authSolversSequential = append(authSolversSequential, authSolver) } else { authSolvers = append(authSolvers, authSolver) } default: authSolvers = append(authSolvers, authSolver) } } else { failures[domain] = fmt.Errorf("[%s] acme: could not determine solvers", domain) } } parallelSolve(authSolvers, failures) sequentialSolve(authSolversSequential, failures) // Be careful not to return an empty failures map, // for even an empty obtainError is a non-nil error value if len(failures) > 0 { return failures } return nil } func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) { // Some CA are using the same token, // this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records. // In the sequential mode, this is not a problem because we can solve the challenges in order. // But it can reduce the number of call the DNS provider APIs. uniq := make(map[string]struct{}) for i, authSolver := range authSolvers { // Submit the challenge domain := challenge.GetTargetedDomain(authSolver.authz) chlg, _ := challenge.FindChallenge(challenge.DNS01, authSolver.authz) if solvr, ok := authSolver.solver.(preSolver); ok { if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok && chlg.Token != "" { log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value) continue } err := solvr.PreSolve(authSolver.authz) if err != nil { failures[domain] = err cleanUp(authSolver.solver, authSolver.authz) continue } uniq[authSolver.authz.Identifier.Value+chlg.Token] = struct{}{} } // Solve challenge err := authSolver.solver.Solve(authSolver.authz) if err != nil { failures[domain] = err cleanUp(authSolver.solver, authSolver.authz) continue } if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok || chlg.Token == "" { // Clean challenge cleanUp(authSolver.solver, authSolver.authz) if len(authSolvers)-1 > i { solvr := authSolver.solver.(sequential) _, interval := solvr.Sequential() log.Infof("sequence: wait for %s", interval) time.Sleep(interval) } delete(uniq, authSolver.authz.Identifier.Value+chlg.Token) } else { log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value) } } } func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) { // Some CA are using the same token, // this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records. uniq := make(map[string]struct{}) // For all valid preSolvers, first submit the challenges, so they have max time to propagate for _, authSolver := range authSolvers { authz := authSolver.authz chlg, err := challenge.FindChallenge(challenge.DNS01, authz) if err == nil { if _, ok := uniq[authz.Identifier.Value+chlg.Token]; ok { log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value) continue } uniq[authz.Identifier.Value+chlg.Token] = struct{}{} } if solvr, ok := authSolver.solver.(preSolver); ok { err := solvr.PreSolve(authz) if err != nil { failures[challenge.GetTargetedDomain(authz)] = err } } } defer func() { // Clean all created TXT records for _, authSolver := range authSolvers { chlg, err := challenge.FindChallenge(challenge.DNS01, authSolver.authz) if err == nil { if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok { delete(uniq, authSolver.authz.Identifier.Value+chlg.Token) } else { log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value) continue } } cleanUp(authSolver.solver, authSolver.authz) } }() // Finally solve all challenges for real for _, authSolver := range authSolvers { authz := authSolver.authz domain := challenge.GetTargetedDomain(authz) if failures[domain] != nil { // already failed in previous loop continue } err := authSolver.solver.Solve(authz) if err != nil { failures[domain] = err } } } func cleanUp(solvr solver, authz acme.Authorization) { if solvr, ok := solvr.(cleanup); ok { domain := challenge.GetTargetedDomain(authz) err := solvr.CleanUp(authz) if err != nil { log.Warnf("[%s] acme: cleaning up failed: %v ", domain, err) } } } ================================================ FILE: challenge/resolver/prober_mock_test.go ================================================ package resolver import ( "fmt" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/challenge" ) type preSolverMock struct { preSolve map[string]error solve map[string]error cleanUp map[string]error preSolveCounter int solveCounter int cleanUpCounter int } func (s *preSolverMock) PreSolve(authorization acme.Authorization) error { s.preSolveCounter++ return s.preSolve[authorization.Identifier.Value] } func (s *preSolverMock) Solve(authorization acme.Authorization) error { s.solveCounter++ return s.solve[authorization.Identifier.Value] } func (s *preSolverMock) CleanUp(authorization acme.Authorization) error { s.cleanUpCounter++ return s.cleanUp[authorization.Identifier.Value] } func (s *preSolverMock) String() string { return fmt.Sprintf("PreSolve: %d, Solve: %d, CleanUp: %d", s.preSolveCounter, s.solveCounter, s.cleanUpCounter) } func createStubAuthorizationHTTP01(domain, status string) acme.Authorization { return createStubAuthorization(domain, status, false, acme.Challenge{ Type: challenge.HTTP01.String(), Validated: time.Now(), }) } func createStubAuthorizationDNS01(domain string, wildcard bool) acme.Authorization { var chlgs []acme.Challenge if wildcard { chlgs = append(chlgs, acme.Challenge{ Type: challenge.HTTP01.String(), Validated: time.Now(), }) } chlgs = append(chlgs, acme.Challenge{ Type: challenge.DNS01.String(), Validated: time.Now(), }) return createStubAuthorization(domain, acme.StatusProcessing, wildcard, chlgs...) } func createStubAuthorization(domain, status string, wildcard bool, chlgs ...acme.Challenge) acme.Authorization { return acme.Authorization{ Wildcard: wildcard, Status: status, Expires: time.Now(), Identifier: acme.Identifier{ Type: "dns", Value: domain, }, Challenges: chlgs, } } ================================================ FILE: challenge/resolver/prober_test.go ================================================ package resolver import ( "errors" "fmt" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/challenge" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProber_Solve(t *testing.T) { testCases := []struct { desc string solvers map[challenge.Type]solver authz []acme.Authorization expectedError string expectedCounters map[challenge.Type]string }{ { desc: "success", solvers: map[challenge.Type]solver{ challenge.HTTP01: &preSolverMock{ preSolve: map[string]error{}, solve: map[string]error{}, cleanUp: map[string]error{}, }, }, authz: []acme.Authorization{ createStubAuthorizationHTTP01("example.com", acme.StatusProcessing), createStubAuthorizationHTTP01("example.org", acme.StatusProcessing), createStubAuthorizationHTTP01("example.net", acme.StatusProcessing), }, expectedCounters: map[challenge.Type]string{ challenge.HTTP01: "PreSolve: 3, Solve: 3, CleanUp: 3", }, }, { desc: "DNS-01 deduplicate", solvers: map[challenge.Type]solver{ challenge.DNS01: &preSolverMock{ preSolve: map[string]error{}, solve: map[string]error{}, cleanUp: map[string]error{}, }, }, authz: []acme.Authorization{ createStubAuthorizationDNS01("a.example", false), createStubAuthorizationDNS01("a.example", true), createStubAuthorizationDNS01("b.example", false), createStubAuthorizationDNS01("b.example", true), createStubAuthorizationDNS01("c.example", true), createStubAuthorizationDNS01("d.example", false), }, expectedCounters: map[challenge.Type]string{ challenge.DNS01: "PreSolve: 4, Solve: 6, CleanUp: 4", }, }, { desc: "already valid", solvers: map[challenge.Type]solver{ challenge.HTTP01: &preSolverMock{ preSolve: map[string]error{}, solve: map[string]error{}, cleanUp: map[string]error{}, }, }, authz: []acme.Authorization{ createStubAuthorizationHTTP01("example.com", acme.StatusValid), createStubAuthorizationHTTP01("example.org", acme.StatusValid), createStubAuthorizationHTTP01("example.net", acme.StatusValid), }, expectedCounters: map[challenge.Type]string{ challenge.HTTP01: "PreSolve: 0, Solve: 0, CleanUp: 0", }, }, { desc: "when preSolve fail, auth is flagged as error and skipped", solvers: map[challenge.Type]solver{ challenge.HTTP01: &preSolverMock{ preSolve: map[string]error{ "example.com": errors.New("preSolve error example.com"), }, solve: map[string]error{ "example.com": errors.New("solve error example.com"), }, cleanUp: map[string]error{ "example.com": errors.New("clean error example.com"), }, }, }, authz: []acme.Authorization{ createStubAuthorizationHTTP01("example.com", acme.StatusProcessing), createStubAuthorizationHTTP01("example.org", acme.StatusProcessing), createStubAuthorizationHTTP01("example.net", acme.StatusProcessing), }, expectedError: `error: one or more domains had a problem: [example.com] preSolve error example.com `, expectedCounters: map[challenge.Type]string{ challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3", }, }, { desc: "errors at different stages", solvers: map[challenge.Type]solver{ challenge.HTTP01: &preSolverMock{ preSolve: map[string]error{ "example.com": errors.New("preSolve error example.com"), }, solve: map[string]error{ "example.com": errors.New("solve error example.com"), "example.org": errors.New("solve error example.org"), }, cleanUp: map[string]error{ "example.net": errors.New("clean error example.net"), }, }, }, authz: []acme.Authorization{ createStubAuthorizationHTTP01("example.com", acme.StatusProcessing), createStubAuthorizationHTTP01("example.org", acme.StatusProcessing), createStubAuthorizationHTTP01("example.net", acme.StatusProcessing), }, expectedError: `error: one or more domains had a problem: [example.com] preSolve error example.com [example.org] solve error example.org `, expectedCounters: map[challenge.Type]string{ challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() prober := &Prober{ solverManager: &SolverManager{solvers: test.solvers}, } err := prober.Solve(test.authz) if test.expectedError != "" { require.EqualError(t, err, test.expectedError) } else { require.NoError(t, err) } for n, s := range test.solvers { assert.Equal(t, test.expectedCounters[n], fmt.Sprintf("%s", s)) } }) } } ================================================ FILE: challenge/resolver/solver_manager.go ================================================ package resolver import ( "context" "errors" "fmt" "sort" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/challenge/http01" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/wait" ) type byType []acme.Challenge func (a byType) Len() int { return len(a) } func (a byType) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a byType) Less(i, j int) bool { return a[i].Type > a[j].Type } type SolverManager struct { core *api.Core solvers map[challenge.Type]solver } func NewSolversManager(core *api.Core) *SolverManager { return &SolverManager{ solvers: map[challenge.Type]solver{}, core: core, } } // SetHTTP01Provider specifies a custom provider p that can solve the given HTTP-01 challenge. func (c *SolverManager) SetHTTP01Provider(p challenge.Provider, opts ...http01.ChallengeOption) error { c.solvers[challenge.HTTP01] = http01.NewChallenge(c.core, validate, p, opts...) return nil } // SetTLSALPN01Provider specifies a custom provider p that can solve the given TLS-ALPN-01 challenge. func (c *SolverManager) SetTLSALPN01Provider(p challenge.Provider, opts ...tlsalpn01.ChallengeOption) error { c.solvers[challenge.TLSALPN01] = tlsalpn01.NewChallenge(c.core, validate, p, opts...) return nil } // SetDNS01Provider specifies a custom provider p that can solve the given DNS-01 challenge. func (c *SolverManager) SetDNS01Provider(p challenge.Provider, opts ...dns01.ChallengeOption) error { c.solvers[challenge.DNS01] = dns01.NewChallenge(c.core, validate, p, opts...) return nil } // Remove removes a challenge type from the available solvers. func (c *SolverManager) Remove(chlgType challenge.Type) { delete(c.solvers, chlgType) } // Checks all challenges from the server in order and returns the first matching solver. func (c *SolverManager) chooseSolver(authz acme.Authorization) solver { // Allow to have a deterministic challenge order sort.Sort(byType(authz.Challenges)) domain := challenge.GetTargetedDomain(authz) for _, chlg := range authz.Challenges { if solvr, ok := c.solvers[challenge.Type(chlg.Type)]; ok { log.Infof("[%s] acme: use %s solver", domain, chlg.Type) return solvr } log.Infof("[%s] acme: Could not find solver for: %s", domain, chlg.Type) } return nil } func validate(core *api.Core, domain string, chlg acme.Challenge) error { chlng, err := core.Challenges.New(chlg.URL) if err != nil { return fmt.Errorf("failed to initiate challenge: %w", err) } valid, err := checkChallengeStatus(chlng) if err != nil { return err } if valid { log.Infof("[%s] The server validated our request", domain) return nil } retryAfter, err := api.ParseRetryAfter(chlng.RetryAfter) if err != nil || retryAfter == 0 { // The ACME server MUST return a Retry-After. // If it doesn't, or if it's invalid, we'll just poll hard. // Boulder does not implement the ability to retry challenges or the Retry-After header. // https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md#section-82 retryAfter = 5 * time.Second } ctx := context.Background() bo := backoff.NewExponentialBackOff() bo.InitialInterval = retryAfter bo.MaxInterval = 10 * retryAfter // After the path is sent, the ACME server will access our server. // Repeatedly check the server for an updated status on our request. operation := func() error { authz, err := core.Authorizations.Get(chlng.AuthorizationURL) if err != nil { return backoff.Permanent(err) } valid, err := checkAuthorizationStatus(authz) if err != nil { return backoff.Permanent(err) } if valid { log.Infof("[%s] The server validated our request", domain) return nil } return fmt.Errorf("the server didn't respond to our request (status=%s)", authz.Status) } return wait.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithMaxElapsedTime(100*retryAfter)) } func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) { switch chlng.Status { case acme.StatusValid: return true, nil case acme.StatusPending, acme.StatusProcessing: return false, nil case acme.StatusInvalid: return false, fmt.Errorf("invalid challenge: %w", chlng.Err()) default: return false, fmt.Errorf("the server returned an unexpected challenge status: %s", chlng.Status) } } func checkAuthorizationStatus(authz acme.Authorization) (bool, error) { switch authz.Status { case acme.StatusValid: return true, nil case acme.StatusPending, acme.StatusProcessing: return false, nil case acme.StatusDeactivated, acme.StatusExpired, acme.StatusRevoked: return false, fmt.Errorf("the authorization state %s", authz.Status) case acme.StatusInvalid: for _, chlg := range authz.Challenges { if chlg.Status == acme.StatusInvalid && chlg.Error != nil { return false, fmt.Errorf("invalid authorization: %w", chlg.Err()) } } return false, errors.New("invalid authorization") default: return false, fmt.Errorf("the server returned an unexpected authorization status: %s", authz.Status) } } ================================================ FILE: challenge/resolver/solver_manager_test.go ================================================ package resolver import ( "crypto/rand" "crypto/rsa" "fmt" "io" "net/http" "sort" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestByType(t *testing.T) { challenges := []acme.Challenge{ {Type: "dns-01"}, {Type: "tlsalpn-01"}, {Type: "http-01"}, } sort.Sort(byType(challenges)) expected := []acme.Challenge{ {Type: "tlsalpn-01"}, {Type: "http-01"}, {Type: "dns-01"}, } assert.Equal(t, expected, challenges) } func TestValidate(t *testing.T) { var statuses []string privateKey, _ := rsa.GenerateKey(rand.Reader, 1024) server := tester.MockACMEServer(). Route("POST /chlg", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { if err := validateNoBody(privateKey, req); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } rw.Header().Set("Link", fmt.Sprintf(`; rel="up"`, req.Context().Value(http.LocalAddrContextKey))) st := statuses[0] statuses = statuses[1:] chlg := &acme.Challenge{Type: "http-01", Status: st, URL: "http://example.com/", Token: "token"} servermock.JSONEncode(chlg).ServeHTTP(rw, req) })). Route("POST /my-authz", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { st := statuses[0] statuses = statuses[1:] authorization := acme.Authorization{ Status: st, Challenges: []acme.Challenge{}, } if st == acme.StatusInvalid { chlg := acme.Challenge{ Status: acme.StatusInvalid, } authorization.Challenges = append(authorization.Challenges, chlg) } servermock.JSONEncode(authorization).ServeHTTP(rw, req) })). BuildHTTPS(t) core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) testCases := []struct { name string statuses []string want string }{ { name: "POST-unexpected", statuses: []string{"weird"}, want: "the server returned an unexpected challenge status: weird", }, { name: "POST-valid", statuses: []string{acme.StatusValid}, }, { name: "POST-invalid", statuses: []string{acme.StatusInvalid}, want: "invalid challenge:", }, { name: "POST-pending-unexpected", statuses: []string{acme.StatusPending, "weird"}, want: "the server returned an unexpected authorization status: weird", }, { name: "POST-pending-valid", statuses: []string{acme.StatusPending, acme.StatusValid}, }, { name: "POST-pending-invalid", statuses: []string{acme.StatusPending, acme.StatusInvalid}, want: "invalid authorization", }, } for _, test := range testCases { t.Run(test.name, func(t *testing.T) { statuses = test.statuses err := validate(core, "example.com", acme.Challenge{Type: "http-01", Token: "token", URL: server.URL + "/chlg"}) if test.want == "" { require.NoError(t, err) } else { require.Error(t, err) assert.Contains(t, err.Error(), test.want) } }) } } func Test_checkChallengeStatus(t *testing.T) { testCases := []struct { desc string challenge acme.Challenge requireErr require.ErrorAssertionFunc expected bool }{ { desc: "status valid", challenge: acme.Challenge{Status: acme.StatusValid}, requireErr: require.NoError, expected: true, }, { desc: "status invalid", challenge: acme.Challenge{Status: acme.StatusInvalid}, requireErr: require.Error, expected: false, }, { desc: "status invalid with error", challenge: acme.Challenge{Status: acme.StatusInvalid, Error: &acme.ProblemDetails{}}, requireErr: require.Error, expected: false, }, { desc: "status pending", challenge: acme.Challenge{Status: acme.StatusPending}, requireErr: require.NoError, expected: false, }, { desc: "status processing", challenge: acme.Challenge{Status: acme.StatusProcessing}, requireErr: require.NoError, expected: false, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() status, err := checkChallengeStatus(acme.ExtendedChallenge{Challenge: test.challenge}) test.requireErr(t, err) assert.Equal(t, test.expected, status) }) } } func Test_checkAuthorizationStatus(t *testing.T) { testCases := []struct { desc string authorization acme.Authorization requireErr require.ErrorAssertionFunc expected bool }{ { desc: "status valid", authorization: acme.Authorization{Status: acme.StatusValid}, requireErr: require.NoError, expected: true, }, { desc: "status invalid", authorization: acme.Authorization{Status: acme.StatusInvalid}, requireErr: require.Error, expected: false, }, { desc: "status invalid with error", authorization: acme.Authorization{Status: acme.StatusInvalid, Challenges: []acme.Challenge{{Error: &acme.ProblemDetails{}}}}, requireErr: require.Error, expected: false, }, { desc: "status pending", authorization: acme.Authorization{Status: acme.StatusPending}, requireErr: require.NoError, expected: false, }, { desc: "status processing", authorization: acme.Authorization{Status: acme.StatusProcessing}, requireErr: require.NoError, expected: false, }, { desc: "status deactivated", authorization: acme.Authorization{Status: acme.StatusDeactivated}, requireErr: require.Error, expected: false, }, { desc: "status expired", authorization: acme.Authorization{Status: acme.StatusExpired}, requireErr: require.Error, expected: false, }, { desc: "status revoked", authorization: acme.Authorization{Status: acme.StatusRevoked}, requireErr: require.Error, expected: false, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() status, err := checkAuthorizationStatus(test.authorization) test.requireErr(t, err) assert.Equal(t, test.expected, status) }) } } // validateNoBody reads the http.Request POST body, parses the JWS and validates it to read the body. // If there is an error doing this, // or if the JWS body is not the empty JSON payload "{}" or a POST-as-GET payload "" an error is returned. // We use this to verify challenge POSTs to the ts below do not send a JWS body. func validateNoBody(privateKey *rsa.PrivateKey, r *http.Request) error { reqBody, err := io.ReadAll(r.Body) if err != nil { return err } sigAlgs := []jose.SignatureAlgorithm{jose.RS256} jws, err := jose.ParseSigned(string(reqBody), sigAlgs) if err != nil { return err } body, err := jws.Verify(&jose.JSONWebKey{ Key: privateKey.Public(), Algorithm: "RSA", }) if err != nil { return err } if bodyStr := string(body); bodyStr != "{}" && bodyStr != "" { return fmt.Errorf(`expected JWS POST body "{}" or "", got %q`, bodyStr) } return nil } ================================================ FILE: challenge/tlsalpn01/tls_alpn_challenge.go ================================================ package tlsalpn01 import ( "crypto/rsa" "crypto/sha256" "crypto/tls" "crypto/x509/pkix" "encoding/asn1" "fmt" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/log" ) // idPeAcmeIdentifierV1 is the SMI Security for PKIX Certification Extension OID referencing the ACME extension. // Reference: https://www.rfc-editor.org/rfc/rfc8737.html#section-6.1 var idPeAcmeIdentifierV1 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31} type ValidateFunc func(core *api.Core, domain string, chlng acme.Challenge) error type ChallengeOption func(*Challenge) error // SetDelay sets a delay between the start of the TLS listener and the challenge validation. func SetDelay(delay time.Duration) ChallengeOption { return func(chlg *Challenge) error { chlg.delay = delay return nil } } type Challenge struct { core *api.Core validate ValidateFunc provider challenge.Provider delay time.Duration } func NewChallenge(core *api.Core, validate ValidateFunc, provider challenge.Provider, opts ...ChallengeOption) *Challenge { chlg := &Challenge{ core: core, validate: validate, provider: provider, } for _, opt := range opts { err := opt(chlg) if err != nil { log.Infof("challenge option error: %v", err) } } return chlg } func (c *Challenge) SetProvider(provider challenge.Provider) { c.provider = provider } // Solve manages the provider to validate and solve the challenge. func (c *Challenge) Solve(authz acme.Authorization) error { domain := authz.Identifier.Value log.Infof("[%s] acme: Trying to solve TLS-ALPN-01", challenge.GetTargetedDomain(authz)) chlng, err := challenge.FindChallenge(challenge.TLSALPN01, authz) if err != nil { return err } // Generate the Key Authorization for the challenge keyAuth, err := c.core.GetKeyAuthorization(chlng.Token) if err != nil { return err } err = c.provider.Present(domain, chlng.Token, keyAuth) if err != nil { return fmt.Errorf("[%s] acme: error presenting token: %w", challenge.GetTargetedDomain(authz), err) } defer func() { err := c.provider.CleanUp(domain, chlng.Token, keyAuth) if err != nil { log.Warnf("[%s] acme: cleaning up failed: %v", challenge.GetTargetedDomain(authz), err) } }() if c.delay > 0 { time.Sleep(c.delay) } chlng.KeyAuthorization = keyAuth return c.validate(c.core, domain, chlng) } // ChallengeBlocks returns PEM blocks (certPEMBlock, keyPEMBlock) with the acmeValidation-v1 extension // and domain name for the `tls-alpn-01` challenge. func ChallengeBlocks(domain, keyAuth string) ([]byte, []byte, error) { // Compute the SHA-256 digest of the key authorization. zBytes := sha256.Sum256([]byte(keyAuth)) value, err := asn1.Marshal(zBytes[:sha256.Size]) if err != nil { return nil, nil, err } // Add the keyAuth digest as the acmeValidation-v1 extension // (marked as critical such that it won't be used by non-ACME software). // Reference: https://www.rfc-editor.org/rfc/rfc8737.html#section-3 extensions := []pkix.Extension{ { Id: idPeAcmeIdentifierV1, Critical: true, Value: value, }, } // Generate a new RSA key for the certificates. tempPrivateKey, err := certcrypto.GeneratePrivateKey(certcrypto.RSA2048) if err != nil { return nil, nil, err } rsaPrivateKey := tempPrivateKey.(*rsa.PrivateKey) // Generate the PEM certificate using the provided private key, domain, and extra extensions. tempCertPEM, err := certcrypto.GeneratePemCert(rsaPrivateKey, domain, extensions) if err != nil { return nil, nil, err } // Encode the private key into a PEM format. We'll need to use it to generate the x509 keypair. rsaPrivatePEM := certcrypto.PEMEncode(rsaPrivateKey) return tempCertPEM, rsaPrivatePEM, nil } // ChallengeCert returns a certificate with the acmeValidation-v1 extension // and domain name for the `tls-alpn-01` challenge. func ChallengeCert(domain, keyAuth string) (*tls.Certificate, error) { tempCertPEM, rsaPrivatePEM, err := ChallengeBlocks(domain, keyAuth) if err != nil { return nil, err } cert, err := tls.X509KeyPair(tempCertPEM, rsaPrivatePEM) if err != nil { return nil, err } return &cert, nil } ================================================ FILE: challenge/tlsalpn01/tls_alpn_challenge_server.go ================================================ package tlsalpn01 import ( "crypto/tls" "errors" "fmt" "net" "net/http" "strings" "github.com/go-acme/lego/v4/log" ) const ( // ACMETLS1Protocol is the ALPN Protocol ID for the ACME-TLS/1 Protocol. ACMETLS1Protocol = "acme-tls/1" // defaultTLSPort is the port that the ProviderServer will default to // when no other port is provided. defaultTLSPort = "443" ) // ProviderServer implements ChallengeProvider for `TLS-ALPN-01` challenge. // It may be instantiated without using the NewProviderServer // if you want only to use the default values. type ProviderServer struct { iface string port string listener net.Listener } // NewProviderServer creates a new ProviderServer on the selected interface and port. // Setting iface and / or port to an empty string will make the server fall back to // the "any" interface and port 443 respectively. func NewProviderServer(iface, port string) *ProviderServer { return &ProviderServer{iface: iface, port: port} } func (s *ProviderServer) GetAddress() string { return net.JoinHostPort(s.iface, s.port) } // Present generates a certificate with an SHA-256 digest of the keyAuth provided // as the acmeValidation-v1 extension value to conform to the ACME-TLS-ALPN spec. func (s *ProviderServer) Present(domain, token, keyAuth string) error { if s.port == "" { // Fallback to port 443 if the port was not provided. s.port = defaultTLSPort } // Generate the challenge certificate using the provided keyAuth and domain. cert, err := ChallengeCert(domain, keyAuth) if err != nil { return err } // Place the generated certificate with the extension into the TLS config // so that it can serve the correct details. tlsConf := new(tls.Config) tlsConf.Certificates = []tls.Certificate{*cert} // We must set that the `acme-tls/1` application level protocol is supported // so that the protocol negotiation can succeed. Reference: // https://www.rfc-editor.org/rfc/rfc8737.html#section-6.2 tlsConf.NextProtos = []string{ACMETLS1Protocol} // Create the listener with the created tls.Config. s.listener, err = tls.Listen("tcp", s.GetAddress(), tlsConf) if err != nil { return fmt.Errorf("could not start HTTPS server for challenge: %w", err) } // Shut the server down when we're finished. go func() { err := http.Serve(s.listener, nil) if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { log.Println(err) } }() return nil } // CleanUp closes the HTTPS server. func (s *ProviderServer) CleanUp(domain, token, keyAuth string) error { if s.listener == nil { return nil } // Server was created, close it. if err := s.listener.Close(); err != nil && errors.Is(err, http.ErrServerClosed) { return err } return nil } ================================================ FILE: challenge/tlsalpn01/tls_alpn_challenge_test.go ================================================ package tlsalpn01 import ( "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/subtle" "crypto/tls" "encoding/asn1" "net" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/tester" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestChallenge(t *testing.T) { server := tester.MockACMEServer().BuildHTTPS(t) domain := "localhost" port := "24457" mockValidate := func(_ *api.Core, _ string, chlng acme.Challenge) error { conn, err := tls.Dial("tcp", net.JoinHostPort(domain, port), &tls.Config{ ServerName: domain, InsecureSkipVerify: true, }) require.NoError(t, err, "Expected to connect to challenge server without an error") // Expect the server to only return one certificate connState := conn.ConnectionState() assert.Len(t, connState.PeerCertificates, 1, "Expected the challenge server to return exactly one certificate") remoteCert := connState.PeerCertificates[0] assert.Len(t, remoteCert.DNSNames, 1, "Expected the challenge certificate to have exactly one DNSNames entry") assert.Equal(t, domain, remoteCert.DNSNames[0], "challenge certificate DNSName ") assert.NotEmpty(t, remoteCert.Extensions, "Expected the challenge certificate to contain extensions") idx := -1 for i, ext := range remoteCert.Extensions { if idPeAcmeIdentifierV1.Equal(ext.Id) { idx = i break } } require.NotEqual(t, -1, idx, "Expected the challenge certificate to contain an extension with the id-pe-acmeIdentifier id,") ext := remoteCert.Extensions[idx] assert.True(t, ext.Critical, "Expected the challenge certificate id-pe-acmeIdentifier extension to be marked as critical") zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization)) value, err := asn1.Marshal(zBytes[:sha256.Size]) require.NoError(t, err, "Expected marshaling of the keyAuth to return no error") if subtle.ConstantTimeCompare(value, ext.Value) != 1 { t.Errorf("Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth, %v, but was %v", zBytes[:], ext.Value) } return nil } privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge( core, mockValidate, &ProviderServer{port: port}, ) authz := acme.Authorization{ Identifier: acme.Identifier{ Type: "dns", Value: domain, }, Challenges: []acme.Challenge{ {Type: challenge.TLSALPN01.String(), Token: "tlsalpn1"}, }, } err = solver.Solve(authz) require.NoError(t, err) } func TestChallengeInvalidPort(t *testing.T) { server := tester.MockACMEServer().BuildHTTPS(t) privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge( core, func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }, &ProviderServer{port: "123456"}, ) authz := acme.Authorization{ Identifier: acme.Identifier{ Value: "localhost:123456", }, Challenges: []acme.Challenge{ {Type: challenge.TLSALPN01.String(), Token: "tlsalpn1"}, }, } err = solver.Solve(authz) require.Error(t, err) assert.Contains(t, err.Error(), "invalid port") assert.Contains(t, err.Error(), "123456") } func TestChallengeIPaddress(t *testing.T) { server := tester.MockACMEServer().BuildHTTPS(t) domain := "127.0.0.1" port := "24457" rd, _ := dns.ReverseAddr(domain) mockValidate := func(_ *api.Core, _ string, chlng acme.Challenge) error { conn, err := tls.Dial("tcp", net.JoinHostPort(domain, port), &tls.Config{ ServerName: rd, InsecureSkipVerify: true, }) require.NoError(t, err, "Expected to connect to challenge server without an error") // Expect the server to only return one certificate connState := conn.ConnectionState() assert.Len(t, connState.PeerCertificates, 1, "Expected the challenge server to return exactly one certificate") remoteCert := connState.PeerCertificates[0] assert.Empty(t, remoteCert.DNSNames, "Expected the challenge certificate to have no DNSNames entry in context of challenge for IP") assert.Len(t, remoteCert.IPAddresses, 1, "Expected the challenge certificate to have exactly one IPAddresses entry") assert.True(t, net.ParseIP("127.0.0.1").Equal(remoteCert.IPAddresses[0]), "challenge certificate IPAddress ") assert.NotEmpty(t, remoteCert.Extensions, "Expected the challenge certificate to contain extensions") var ( foundAcmeIdentifier bool extValue []byte ) for _, ext := range remoteCert.Extensions { if idPeAcmeIdentifierV1.Equal(ext.Id) { assert.True(t, ext.Critical, "Expected the challenge certificate id-pe-acmeIdentifier extension to be marked as critical") foundAcmeIdentifier = true extValue = ext.Value break } } require.True(t, foundAcmeIdentifier, "Expected the challenge certificate to contain an extension with the id-pe-acmeIdentifier id,") zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization)) value, err := asn1.Marshal(zBytes[:sha256.Size]) require.NoError(t, err, "Expected marshaling of the keyAuth to return no error") require.Equal(t, value, extValue, "Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth") return nil } privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) require.NoError(t, err) solver := NewChallenge( core, mockValidate, &ProviderServer{port: port}, ) authz := acme.Authorization{ Identifier: acme.Identifier{ Type: "ip", Value: domain, }, Challenges: []acme.Challenge{ {Type: challenge.TLSALPN01.String(), Token: "tlsalpn1"}, }, } require.NoError(t, solver.Solve(authz)) } ================================================ FILE: cmd/account.go ================================================ package cmd import ( "crypto" "github.com/go-acme/lego/v4/registration" ) // Account represents a users local saved credentials. type Account struct { Email string `json:"email"` Registration *registration.Resource `json:"registration"` key crypto.PrivateKey } /** Implementation of the registration.User interface **/ // GetEmail returns the email address for the account. func (a *Account) GetEmail() string { return a.Email } // GetPrivateKey returns the private RSA account key. func (a *Account) GetPrivateKey() crypto.PrivateKey { return a.key } // GetRegistration returns the server registration. func (a *Account) GetRegistration() *registration.Resource { return a.Registration } ================================================ FILE: cmd/accounts_storage.go ================================================ package cmd import ( "crypto" "encoding/json" "encoding/pem" "net/url" "os" "path/filepath" "strings" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/registration" "github.com/urfave/cli/v2" ) const userIDPlaceholder = "noemail@example.com" const ( baseAccountsRootFolderName = "accounts" baseKeysFolderName = "keys" accountFileName = "account.json" ) // AccountsStorage A storage for account data. // // rootPath: // // ./.lego/accounts/ // │ └── root accounts directory // └── "path" option // // rootUserPath: // // ./.lego/accounts/localhost_14000/foo@example.com/ // │ │ │ └── userID ("email" option) // │ │ └── CA server ("server" option) // │ └── root accounts directory // └── "path" option // // keysPath: // // ./.lego/accounts/localhost_14000/foo@example.com/keys/ // │ │ │ │ └── root keys directory // │ │ │ └── userID ("email" option) // │ │ └── CA server ("server" option) // │ └── root accounts directory // └── "path" option // // accountFilePath: // // ./.lego/accounts/localhost_14000/foo@example.com/account.json // │ │ │ │ └── account file // │ │ │ └── userID ("email" option) // │ │ └── CA server ("server" option) // │ └── root accounts directory // └── "path" option type AccountsStorage struct { userID string email string rootPath string rootUserPath string keysPath string accountFilePath string ctx *cli.Context } // NewAccountsStorage Creates a new AccountsStorage. func NewAccountsStorage(ctx *cli.Context) *AccountsStorage { // TODO: move to account struct? email := ctx.String(flgEmail) userID := email if userID == "" { userID = userIDPlaceholder } serverURL, err := url.Parse(ctx.String(flgServer)) if err != nil { log.Fatal(err) } rootPath := filepath.Join(ctx.String(flgPath), baseAccountsRootFolderName) serverPath := strings.NewReplacer(":", "_", "/", string(os.PathSeparator)).Replace(serverURL.Host) accountsPath := filepath.Join(rootPath, serverPath) rootUserPath := filepath.Join(accountsPath, userID) return &AccountsStorage{ userID: userID, email: email, rootPath: rootPath, rootUserPath: rootUserPath, keysPath: filepath.Join(rootUserPath, baseKeysFolderName), accountFilePath: filepath.Join(rootUserPath, accountFileName), ctx: ctx, } } func (s *AccountsStorage) ExistsAccountFilePath() bool { accountFile := filepath.Join(s.rootUserPath, accountFileName) if _, err := os.Stat(accountFile); os.IsNotExist(err) { return false } else if err != nil { log.Fatal(err) } return true } func (s *AccountsStorage) GetRootPath() string { return s.rootPath } func (s *AccountsStorage) GetRootUserPath() string { return s.rootUserPath } func (s *AccountsStorage) GetUserID() string { return s.userID } func (s *AccountsStorage) GetEmail() string { return s.email } func (s *AccountsStorage) Save(account *Account) error { jsonBytes, err := json.MarshalIndent(account, "", "\t") if err != nil { return err } return os.WriteFile(s.accountFilePath, jsonBytes, filePerm) } func (s *AccountsStorage) LoadAccount(privateKey crypto.PrivateKey) *Account { fileBytes, err := os.ReadFile(s.accountFilePath) if err != nil { log.Fatalf("Could not load file for account %s: %v", s.GetUserID(), err) } var account Account err = json.Unmarshal(fileBytes, &account) if err != nil { log.Fatalf("Could not parse file for account %s: %v", s.GetUserID(), err) } account.key = privateKey if account.Registration == nil || account.Registration.Body.Status == "" { reg, err := tryRecoverRegistration(s.ctx, privateKey) if err != nil { log.Fatalf("Could not load account for %s. Registration is nil: %#v", s.GetUserID(), err) } account.Registration = reg err = s.Save(&account) if err != nil { log.Fatalf("Could not save account for %s. Registration is nil: %#v", s.GetUserID(), err) } } return &account } func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.PrivateKey { accKeyPath := filepath.Join(s.keysPath, s.GetUserID()+".key") if _, err := os.Stat(accKeyPath); os.IsNotExist(err) { log.Printf("No key found for account %s. Generating a %s key.", s.GetUserID(), keyType) s.createKeysFolder() privateKey, err := generatePrivateKey(accKeyPath, keyType) if err != nil { log.Fatalf("Could not generate RSA private account key for account %s: %v", s.GetUserID(), err) } log.Printf("Saved key to %s", accKeyPath) return privateKey } privateKey, err := loadPrivateKey(accKeyPath) if err != nil { log.Fatalf("Could not load RSA private key from file %s: %v", accKeyPath, err) } return privateKey } func (s *AccountsStorage) createKeysFolder() { if err := createNonExistingFolder(s.keysPath); err != nil { log.Fatalf("Could not check/create directory for account %s: %v", s.GetUserID(), err) } } func generatePrivateKey(file string, keyType certcrypto.KeyType) (crypto.PrivateKey, error) { privateKey, err := certcrypto.GeneratePrivateKey(keyType) if err != nil { return nil, err } certOut, err := os.Create(file) if err != nil { return nil, err } defer certOut.Close() pemKey := certcrypto.PEMBlock(privateKey) err = pem.Encode(certOut, pemKey) if err != nil { return nil, err } return privateKey, nil } func loadPrivateKey(file string) (crypto.PrivateKey, error) { keyBytes, err := os.ReadFile(file) if err != nil { return nil, err } privateKey, err := certcrypto.ParsePEMPrivateKey(keyBytes) if err != nil { return nil, err } return privateKey, nil } func tryRecoverRegistration(ctx *cli.Context, privateKey crypto.PrivateKey) (*registration.Resource, error) { // couldn't load account but got a key. Try to look the account up. config := lego.NewConfig(&Account{key: privateKey}) config.CADirURL = ctx.String(flgServer) config.UserAgent = getUserAgent(ctx) client, err := lego.NewClient(config) if err != nil { return nil, err } reg, err := client.Registration.ResolveAccountByKey() if err != nil { return nil, err } return reg, nil } ================================================ FILE: cmd/certs_storage.go ================================================ package cmd import ( "bytes" "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/log" "github.com/urfave/cli/v2" "golang.org/x/net/idna" "software.sslmate.com/src/go-pkcs12" ) const ( baseCertificatesFolderName = "certificates" baseArchivesFolderName = "archives" ) const ( issuerExt = ".issuer.crt" certExt = ".crt" keyExt = ".key" pemExt = ".pem" pfxExt = ".pfx" resourceExt = ".json" ) // CertificatesStorage a certificates' storage. // // rootPath: // // ./.lego/certificates/ // │ └── root certificates directory // └── "path" option // // archivePath: // // ./.lego/archives/ // │ └── archived certificates directory // └── "path" option type CertificatesStorage struct { rootPath string archivePath string pem bool pfx bool pfxPassword string pfxFormat string filename string // Deprecated } // NewCertificatesStorage create a new certificates storage. func NewCertificatesStorage(ctx *cli.Context) *CertificatesStorage { pfxFormat := ctx.String(flgPFXFormat) switch pfxFormat { case "DES", "RC2", "SHA256": default: log.Fatalf("Invalid PFX format: %s", pfxFormat) } return &CertificatesStorage{ rootPath: filepath.Join(ctx.String(flgPath), baseCertificatesFolderName), archivePath: filepath.Join(ctx.String(flgPath), baseArchivesFolderName), pem: ctx.Bool(flgPEM), pfx: ctx.Bool(flgPFX), pfxPassword: ctx.String(flgPFXPass), pfxFormat: pfxFormat, filename: ctx.String(flgFilename), } } func (s *CertificatesStorage) CreateRootFolder() { err := createNonExistingFolder(s.rootPath) if err != nil { log.Fatalf("Could not check/create path: %v", err) } } func (s *CertificatesStorage) CreateArchiveFolder() { err := createNonExistingFolder(s.archivePath) if err != nil { log.Fatalf("Could not check/create path: %v", err) } } func (s *CertificatesStorage) GetRootPath() string { return s.rootPath } func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) { domain := certRes.Domain // We store the certificate, private key and metadata in different files // as web servers would not be able to work with a combined file. err := s.WriteFile(domain, certExt, certRes.Certificate) if err != nil { log.Fatalf("Unable to save Certificate for domain %s\n\t%v", domain, err) } if certRes.IssuerCertificate != nil { err = s.WriteFile(domain, issuerExt, certRes.IssuerCertificate) if err != nil { log.Fatalf("Unable to save IssuerCertificate for domain %s\n\t%v", domain, err) } } // if we were given a CSR, we don't know the private key if certRes.PrivateKey != nil { err = s.WriteCertificateFiles(domain, certRes) if err != nil { log.Fatalf("Unable to save PrivateKey for domain %s\n\t%v", domain, err) } } else if s.pem || s.pfx { // we don't have the private key; can't write the .pem or .pfx file log.Fatalf("Unable to save PEM or PFX without private key for domain %s. Are you using a CSR?", domain) } jsonBytes, err := json.MarshalIndent(certRes, "", "\t") if err != nil { log.Fatalf("Unable to marshal CertResource for domain %s\n\t%v", domain, err) } err = s.WriteFile(domain, resourceExt, jsonBytes) if err != nil { log.Fatalf("Unable to save CertResource for domain %s\n\t%v", domain, err) } } func (s *CertificatesStorage) ReadResource(domain string) certificate.Resource { raw, err := s.ReadFile(domain, resourceExt) if err != nil { log.Fatalf("Error while loading the meta data for domain %s\n\t%v", domain, err) } var resource certificate.Resource if err = json.Unmarshal(raw, &resource); err != nil { log.Fatalf("Error while marshaling the meta data for domain %s\n\t%v", domain, err) } return resource } func (s *CertificatesStorage) ExistsFile(domain, extension string) bool { filePath := s.GetFileName(domain, extension) if _, err := os.Stat(filePath); os.IsNotExist(err) { return false } else if err != nil { log.Fatal(err) } return true } func (s *CertificatesStorage) ReadFile(domain, extension string) ([]byte, error) { return os.ReadFile(s.GetFileName(domain, extension)) } func (s *CertificatesStorage) GetFileName(domain, extension string) string { filename := sanitizedDomain(domain) + extension return filepath.Join(s.rootPath, filename) } func (s *CertificatesStorage) ReadCertificate(domain, extension string) ([]*x509.Certificate, error) { content, err := s.ReadFile(domain, extension) if err != nil { return nil, err } // The input may be a bundle or a single certificate. return certcrypto.ParsePEMBundle(content) } func (s *CertificatesStorage) WriteFile(domain, extension string, data []byte) error { var baseFileName string if s.filename != "" { baseFileName = s.filename } else { baseFileName = sanitizedDomain(domain) } filePath := filepath.Join(s.rootPath, baseFileName+extension) return os.WriteFile(filePath, data, filePerm) } func (s *CertificatesStorage) WriteCertificateFiles(domain string, certRes *certificate.Resource) error { err := s.WriteFile(domain, keyExt, certRes.PrivateKey) if err != nil { return fmt.Errorf("unable to save key file: %w", err) } if s.pem { err = s.WriteFile(domain, pemExt, bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil)) if err != nil { return fmt.Errorf("unable to save PEM file: %w", err) } } if s.pfx { err = s.WritePFXFile(domain, certRes) if err != nil { return fmt.Errorf("unable to save PFX file: %w", err) } } return nil } func (s *CertificatesStorage) WritePFXFile(domain string, certRes *certificate.Resource) error { certPemBlock, _ := pem.Decode(certRes.Certificate) if certPemBlock == nil { return fmt.Errorf("unable to parse Certificate for domain %s", domain) } cert, err := x509.ParseCertificate(certPemBlock.Bytes) if err != nil { return fmt.Errorf("unable to load Certificate for domain %s: %w", domain, err) } certChain, err := getCertificateChain(certRes) if err != nil { return fmt.Errorf("unable to get certificate chain for domain %s: %w", domain, err) } privateKey, err := certcrypto.ParsePEMPrivateKey(certRes.PrivateKey) if err != nil { return fmt.Errorf("unable to parse PrivateKey for domain %s: %w", domain, err) } encoder, err := getPFXEncoder(s.pfxFormat) if err != nil { return fmt.Errorf("PFX encoder: %w", err) } pfxBytes, err := encoder.Encode(privateKey, cert, certChain, s.pfxPassword) if err != nil { return fmt.Errorf("unable to encode PFX data for domain %s: %w", domain, err) } return s.WriteFile(domain, pfxExt, pfxBytes) } func (s *CertificatesStorage) MoveToArchive(domain string) error { baseFilename := filepath.Join(s.rootPath, sanitizedDomain(domain)) matches, err := filepath.Glob(baseFilename + ".*") if err != nil { return err } for _, oldFile := range matches { if strings.TrimSuffix(oldFile, filepath.Ext(oldFile)) != baseFilename && oldFile != baseFilename+issuerExt { continue } date := strconv.FormatInt(time.Now().Unix(), 10) filename := date + "." + filepath.Base(oldFile) newFile := filepath.Join(s.archivePath, filename) err = os.Rename(oldFile, newFile) if err != nil { return err } } return nil } func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, error) { chainCertPemBlock, rest := pem.Decode(certRes.IssuerCertificate) if chainCertPemBlock == nil { return nil, errors.New("unable to parse Issuer Certificate") } var certChain []*x509.Certificate for chainCertPemBlock != nil { chainCert, err := x509.ParseCertificate(chainCertPemBlock.Bytes) if err != nil { return nil, fmt.Errorf("unable to parse Chain Certificate: %w", err) } certChain = append(certChain, chainCert) chainCertPemBlock, rest = pem.Decode(rest) // Try decoding the next pem block } return certChain, nil } func getPFXEncoder(pfxFormat string) (*pkcs12.Encoder, error) { var encoder *pkcs12.Encoder switch pfxFormat { case "SHA256": encoder = pkcs12.Modern2023 case "DES": encoder = pkcs12.LegacyDES case "RC2": encoder = pkcs12.LegacyRC2 default: return nil, fmt.Errorf("invalid PFX format: %s", pfxFormat) } return encoder, nil } // sanitizedDomain Make sure no funny chars are in the cert names (like wildcards ;)). func sanitizedDomain(domain string) string { safe, err := idna.ToASCII(strings.NewReplacer(":", "-", "*", "_").Replace(domain)) if err != nil { log.Fatal(err) } return safe } ================================================ FILE: cmd/certs_storage_test.go ================================================ package cmd import ( "os" "path/filepath" "regexp" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCertificatesStorage_MoveToArchive(t *testing.T) { domain := "example.com" storage := CertificatesStorage{ rootPath: t.TempDir(), archivePath: t.TempDir(), } domainFiles := generateTestFiles(t, storage.rootPath, domain) err := storage.MoveToArchive(domain) require.NoError(t, err) for _, file := range domainFiles { assert.NoFileExists(t, file) } root, err := os.ReadDir(storage.rootPath) require.NoError(t, err) require.Empty(t, root) archive, err := os.ReadDir(storage.archivePath) require.NoError(t, err) require.Len(t, archive, len(domainFiles)) assert.Regexp(t, `\d+\.`+regexp.QuoteMeta(domain), archive[0].Name()) } func TestCertificatesStorage_MoveToArchive_noFileRelatedToDomain(t *testing.T) { domain := "example.com" storage := CertificatesStorage{ rootPath: t.TempDir(), archivePath: t.TempDir(), } domainFiles := generateTestFiles(t, storage.rootPath, "example.org") err := storage.MoveToArchive(domain) require.NoError(t, err) for _, file := range domainFiles { assert.FileExists(t, file) } root, err := os.ReadDir(storage.rootPath) require.NoError(t, err) assert.Len(t, root, len(domainFiles)) archive, err := os.ReadDir(storage.archivePath) require.NoError(t, err) assert.Empty(t, archive) } func TestCertificatesStorage_MoveToArchive_ambiguousDomain(t *testing.T) { domain := "example.com" storage := CertificatesStorage{ rootPath: t.TempDir(), archivePath: t.TempDir(), } domainFiles := generateTestFiles(t, storage.rootPath, domain) otherDomainFiles := generateTestFiles(t, storage.rootPath, domain+".example.org") err := storage.MoveToArchive(domain) require.NoError(t, err) for _, file := range domainFiles { assert.NoFileExists(t, file) } for _, file := range otherDomainFiles { assert.FileExists(t, file) } root, err := os.ReadDir(storage.rootPath) require.NoError(t, err) require.Len(t, root, len(otherDomainFiles)) archive, err := os.ReadDir(storage.archivePath) require.NoError(t, err) require.Len(t, archive, len(domainFiles)) assert.Regexp(t, `\d+\.`+regexp.QuoteMeta(domain), archive[0].Name()) } func generateTestFiles(t *testing.T, dir, domain string) []string { t.Helper() var filenames []string for _, ext := range []string{issuerExt, certExt, keyExt, pemExt, pfxExt, resourceExt} { filename := filepath.Join(dir, domain+ext) err := os.WriteFile(filename, []byte("test"), 0o666) require.NoError(t, err) filenames = append(filenames, filename) } return filenames } ================================================ FILE: cmd/cmd.go ================================================ package cmd import "github.com/urfave/cli/v2" // CreateCommands Creates all CLI commands. func CreateCommands() []*cli.Command { return []*cli.Command{ createRun(), createRevoke(), createRenew(), createDNSHelp(), createList(), } } ================================================ FILE: cmd/cmd_before.go ================================================ package cmd import ( "github.com/go-acme/lego/v4/log" "github.com/urfave/cli/v2" ) func Before(ctx *cli.Context) error { if ctx.String(flgPath) == "" { log.Fatalf("Could not determine current working directory. Please pass --%s.", flgPath) } err := createNonExistingFolder(ctx.String(flgPath)) if err != nil { log.Fatalf("Could not check/create path: %v", err) } if ctx.String(flgServer) == "" { log.Fatalf("Could not determine current working server. Please pass --%s.", flgServer) } return nil } ================================================ FILE: cmd/cmd_dnshelp.go ================================================ package cmd import ( "fmt" "io" "strings" "text/tabwriter" "github.com/urfave/cli/v2" ) const flgCode = "code" func createDNSHelp() *cli.Command { return &cli.Command{ Name: "dnshelp", Usage: "Shows additional help for the '--dns' global option", Action: dnsHelp, Flags: []cli.Flag{ &cli.StringFlag{ Name: flgCode, Aliases: []string{"c"}, Usage: fmt.Sprintf("DNS code: %s", allDNSCodes()), }, }, } } func dnsHelp(ctx *cli.Context) error { code := ctx.String(flgCode) if code == "" { w := tabwriter.NewWriter(ctx.App.Writer, 0, 0, 2, ' ', 0) ew := &errWriter{w: w} ew.writeln(`Credentials for DNS providers must be passed through environment variables.`) ew.writeln() ew.writeln(`To display the documentation for a specific DNS provider, run:`) ew.writeln() ew.writeln("\t$ lego dnshelp -c code") ew.writeln() ew.writeln("Supported DNS providers:") ew.writef("\t%s\n", allDNSCodes()) ew.writeln() ew.writeln("More information: https://go-acme.github.io/lego/dns") if ew.err != nil { return ew.err } return w.Flush() } return displayDNSHelp(ctx.App.Writer, strings.ToLower(code)) } type errWriter struct { w io.Writer err error } func (ew *errWriter) writeln(a ...any) { if ew.err != nil { return } _, ew.err = fmt.Fprintln(ew.w, a...) } func (ew *errWriter) writef(format string, a ...any) { if ew.err != nil { return } _, ew.err = fmt.Fprintf(ew.w, format, a...) } ================================================ FILE: cmd/cmd_list.go ================================================ package cmd import ( "encoding/json" "fmt" "net" "net/url" "os" "path/filepath" "strings" "github.com/go-acme/lego/v4/certcrypto" "github.com/urfave/cli/v2" ) const ( flgAccounts = "accounts" flgNames = "names" ) func createList() *cli.Command { return &cli.Command{ Name: "list", Usage: "Display certificates and accounts information.", Action: list, Flags: []cli.Flag{ &cli.BoolFlag{ Name: flgAccounts, Aliases: []string{"a"}, Usage: "Display accounts.", }, &cli.BoolFlag{ Name: flgNames, Aliases: []string{"n"}, Usage: "Display certificate common names only.", }, // fake email, needed by NewAccountsStorage &cli.StringFlag{ Name: flgEmail, Value: "", Hidden: true, }, }, } } func list(ctx *cli.Context) error { if ctx.Bool(flgAccounts) && !ctx.Bool(flgNames) { if err := listAccount(ctx); err != nil { return err } } return listCertificates(ctx) } func listCertificates(ctx *cli.Context) error { certsStorage := NewCertificatesStorage(ctx) matches, err := filepath.Glob(filepath.Join(certsStorage.GetRootPath(), "*.crt")) if err != nil { return err } names := ctx.Bool(flgNames) if len(matches) == 0 { if !names { fmt.Println("No certificates found.") } return nil } if !names { fmt.Println("Found the following certs:") } for _, filename := range matches { if strings.HasSuffix(filename, issuerExt) { continue } data, err := os.ReadFile(filename) if err != nil { return err } pCert, err := certcrypto.ParsePEMCertificate(data) if err != nil { return err } name, err := certcrypto.GetCertificateMainDomain(pCert) if err != nil { return err } if names { fmt.Println(name) } else { fmt.Println(" Certificate Name:", name) fmt.Println(" Domains:", strings.Join(pCert.DNSNames, ", ")) if len(pCert.IPAddresses) > 0 { fmt.Println(" IPs:", formatIPAddresses(pCert.IPAddresses)) } fmt.Println(" Expiry Date:", pCert.NotAfter) fmt.Println(" Certificate Path:", filename) fmt.Println() } } return nil } func listAccount(ctx *cli.Context) error { accountsStorage := NewAccountsStorage(ctx) matches, err := filepath.Glob(filepath.Join(accountsStorage.GetRootPath(), "*", "*", "*.json")) if err != nil { return err } if len(matches) == 0 { fmt.Println("No accounts found.") return nil } fmt.Println("Found the following accounts:") for _, filename := range matches { data, err := os.ReadFile(filename) if err != nil { return err } var account Account err = json.Unmarshal(data, &account) if err != nil { return err } uri, err := url.Parse(account.Registration.URI) if err != nil { return err } fmt.Println(" Email:", account.Email) fmt.Println(" Server:", uri.Host) fmt.Println(" Path:", filepath.Dir(filename)) fmt.Println() } return nil } func formatIPAddresses(ipAddresses []net.IP) string { var ips []string for _, ip := range ipAddresses { ips = append(ips, ip.String()) } return strings.Join(ips, ", ") } ================================================ FILE: cmd/cmd_renew.go ================================================ package cmd import ( "crypto" "crypto/x509" "errors" "math/rand" "os" "slices" "time" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/log" "github.com/mattn/go-isatty" "github.com/urfave/cli/v2" ) // Flag names. const ( flgRenewDays = "days" flgRenewDynamic = "dynamic" flgARIDisable = "ari-disable" flgARIWaitToRenewDuration = "ari-wait-to-renew-duration" flgReuseKey = "reuse-key" flgRenewHook = "renew-hook" flgRenewHookTimeout = "renew-hook-timeout" flgNoRandomSleep = "no-random-sleep" flgForceCertDomains = "force-cert-domains" ) func createRenew() *cli.Command { return &cli.Command{ Name: "renew", Usage: "Renew a certificate", Action: renew, Before: func(ctx *cli.Context) error { // we require either domains or csr, but not both hasDomains := len(ctx.StringSlice(flgDomains)) > 0 hasCsr := ctx.String(flgCSR) != "" if hasDomains && hasCsr { log.Fatalf("Please specify either --%s/-d or --%s/-c, but not both", flgDomains, flgCSR) } if !hasDomains && !hasCsr { log.Fatalf("Please specify --%s/-d (or --%s/-c if you already have a CSR)", flgDomains, flgCSR) } if ctx.Bool(flgForceCertDomains) && hasCsr { log.Fatalf("--%s only works with --%s/-d, --%s/-c doesn't support this option.", flgForceCertDomains, flgDomains, flgCSR) } return nil }, Flags: []cli.Flag{ &cli.IntFlag{ Name: flgRenewDays, Value: 30, Usage: "The number of days left on a certificate to renew it.", }, // TODO(ldez): in v5, remove this flag, use this behavior as default. &cli.BoolFlag{ Name: flgRenewDynamic, Value: false, Usage: "Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5.", }, &cli.BoolFlag{ Name: flgARIDisable, Usage: "Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed.", }, &cli.DurationFlag{ Name: flgARIWaitToRenewDuration, Usage: "The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint.", }, &cli.BoolFlag{ Name: flgReuseKey, Usage: "Used to indicate you want to reuse your current private key for the new certificate.", }, &cli.BoolFlag{ Name: flgNoBundle, Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", }, &cli.BoolFlag{ Name: flgMustStaple, Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate." + " Only works if the CSR is generated by lego.", }, &cli.TimestampFlag{ Name: flgNotBefore, Usage: "Set the notBefore field in the certificate (RFC3339 format)", Layout: time.RFC3339, }, &cli.TimestampFlag{ Name: flgNotAfter, Usage: "Set the notAfter field in the certificate (RFC3339 format)", Layout: time.RFC3339, }, &cli.StringFlag{ Name: flgPreferredChain, Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." + " If no match, the default offered chain will be used.", }, &cli.StringFlag{ Name: flgProfile, Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.", }, &cli.StringFlag{ Name: flgAlwaysDeactivateAuthorizations, Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", }, &cli.StringFlag{ Name: flgRenewHook, Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.", }, &cli.DurationFlag{ Name: flgRenewHookTimeout, Usage: "Define the timeout for the hook execution.", Value: 2 * time.Minute, }, &cli.BoolFlag{ Name: flgNoRandomSleep, Usage: "Do not add a random sleep before the renewal." + " We do not recommend using this flag if you are doing your renewals in an automated way.", }, &cli.BoolFlag{ Name: flgForceCertDomains, Usage: "Check and ensure that the cert's domain list matches those passed in the domains argument.", }, }, } } func renew(ctx *cli.Context) error { account, keyType := setupAccount(ctx, NewAccountsStorage(ctx)) if account.Registration == nil { log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email) } certsStorage := NewCertificatesStorage(ctx) bundle := !ctx.Bool(flgNoBundle) meta := map[string]string{ hookEnvAccountEmail: account.Email, } // CSR if ctx.IsSet(flgCSR) { return renewForCSR(ctx, account, keyType, certsStorage, bundle, meta) } // Domains return renewForDomains(ctx, account, keyType, certsStorage, bundle, meta) } func renewForDomains(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { domains := ctx.StringSlice(flgDomains) domain := domains[0] // load the cert resource from files. // We store the certificate, private key and metadata in different files // as web servers would not be able to work with a combined file. certificates, err := certsStorage.ReadCertificate(domain, certExt) if err != nil { log.Fatalf("Error while loading the certificate for domain %s\n\t%v", domain, err) } cert := certificates[0] var ( ariRenewalTime *time.Time replacesCertID string ) var client *lego.Client if !ctx.Bool(flgARIDisable) { client = setupClient(ctx, account, keyType) ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client) if ariRenewalTime != nil { now := time.Now().UTC() // Figure out if we need to sleep before renewing. if ariRenewalTime.After(now) { log.Infof("[%s] Sleeping %s until renewal time %s", domain, ariRenewalTime.Sub(now), ariRenewalTime) time.Sleep(ariRenewalTime.Sub(now)) } } replacesCertID, err = certificate.MakeARICertID(cert) if err != nil { log.Fatalf("Error while construction the ARI CertID for domain %s\n\t%v", domain, err) } } forceDomains := ctx.Bool(flgForceCertDomains) certDomains := certcrypto.ExtractDomains(cert) if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) && (!forceDomains || slices.Equal(certDomains, domains)) { return nil } if client == nil { client = setupClient(ctx, account, keyType) } // This is just meant to be informal for the user. timeLeft := cert.NotAfter.Sub(time.Now().UTC()) log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours())) var privateKey crypto.PrivateKey if ctx.Bool(flgReuseKey) { keyBytes, errR := certsStorage.ReadFile(domain, keyExt) if errR != nil { log.Fatalf("Error while loading the private key for domain %s\n\t%v", domain, errR) } privateKey, errR = certcrypto.ParsePEMPrivateKey(keyBytes) if errR != nil { return errR } } // https://github.com/go-acme/lego/issues/1656 // https://github.com/certbot/certbot/blob/284023a1b7672be2bd4018dd7623b3b92197d4b0/certbot/certbot/_internal/renewal.py#L435-L440 if !isatty.IsTerminal(os.Stdout.Fd()) && !ctx.Bool(flgNoRandomSleep) { // https://github.com/certbot/certbot/blob/284023a1b7672be2bd4018dd7623b3b92197d4b0/certbot/certbot/_internal/renewal.py#L472 const jitter = 8 * time.Minute rnd := rand.New(rand.NewSource(time.Now().UnixNano())) sleepTime := time.Duration(rnd.Int63n(int64(jitter))) log.Infof("renewal: random delay of %s", sleepTime) time.Sleep(sleepTime) } renewalDomains := slices.Clone(domains) if !forceDomains { renewalDomains = merge(certDomains, domains) } request := certificate.ObtainRequest{ Domains: renewalDomains, PrivateKey: privateKey, MustStaple: ctx.Bool(flgMustStaple), NotBefore: getTime(ctx, flgNotBefore), NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } if replacesCertID != "" { request.ReplacesCertID = replacesCertID } certRes, err := client.Certificate.Obtain(request) if err != nil { log.Fatal(err) } certRes.Domain = domain certsStorage.SaveResource(certRes) addPathToMetadata(meta, domain, certRes, certsStorage) return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta) } func renewForCSR(ctx *cli.Context, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { csr, err := readCSRFile(ctx.String(flgCSR)) if err != nil { log.Fatal(err) } domain, err := certcrypto.GetCSRMainDomain(csr) if err != nil { log.Fatalf("Error: %v", err) } // load the cert resource from files. // We store the certificate, private key and metadata in different files // as web servers would not be able to work with a combined file. certificates, err := certsStorage.ReadCertificate(domain, certExt) if err != nil { log.Fatalf("Error while loading the certificate for domain %s\n\t%v", domain, err) } cert := certificates[0] var ( ariRenewalTime *time.Time replacesCertID string ) var client *lego.Client if !ctx.Bool(flgARIDisable) { client = setupClient(ctx, account, keyType) ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client) if ariRenewalTime != nil { now := time.Now().UTC() // Figure out if we need to sleep before renewing. if ariRenewalTime.After(now) { log.Infof("[%s] Sleeping %s until renewal time %s", domain, ariRenewalTime.Sub(now), ariRenewalTime) time.Sleep(ariRenewalTime.Sub(now)) } } replacesCertID, err = certificate.MakeARICertID(cert) if err != nil { log.Fatalf("Error while construction the ARI CertID for domain %s\n\t%v", domain, err) } } if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) { return nil } if client == nil { client = setupClient(ctx, account, keyType) } // This is just meant to be informal for the user. timeLeft := cert.NotAfter.Sub(time.Now().UTC()) log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours())) request := certificate.ObtainForCSRRequest{ CSR: csr, NotBefore: getTime(ctx, flgNotBefore), NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } if replacesCertID != "" { request.ReplacesCertID = replacesCertID } certRes, err := client.Certificate.ObtainForCSR(request) if err != nil { log.Fatal(err) } certsStorage.SaveResource(certRes) addPathToMetadata(meta, domain, certRes, certsStorage) return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta) } func needRenewal(x509Cert *x509.Certificate, domain string, days int, dynamic bool) bool { if x509Cert.IsCA { log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain) } if dynamic { return needRenewalDynamic(x509Cert, domain, time.Now()) } if days < 0 { return true } notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0) if notAfter <= days { return true } log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.", domain, notAfter, days) return false } func needRenewalDynamic(x509Cert *x509.Certificate, domain string, now time.Time) bool { lifetime := x509Cert.NotAfter.Sub(x509Cert.NotBefore) var divisor int64 = 3 if lifetime.Round(24*time.Hour).Hours()/24.0 <= 10 { divisor = 2 } dueDate := x509Cert.NotAfter.Add(-1 * time.Duration(lifetime.Nanoseconds()/divisor)) if dueDate.Before(now) { return true } log.Infof("[%s] The certificate expires at %s, the renewal can be performed in %s: no renewal.", domain, x509Cert.NotAfter.Format(time.RFC3339), dueDate.Sub(now)) return false } // getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint. func getARIRenewalTime(ctx *cli.Context, cert *x509.Certificate, domain string, client *lego.Client) *time.Time { if cert.IsCA { log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain) } renewalInfo, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{Cert: cert}) if err != nil { if errors.Is(err, api.ErrNoARI) { // The server does not advertise a renewal info endpoint. log.Warnf("[%s] acme: %v", domain, err) return nil } log.Warnf("[%s] acme: calling renewal info endpoint: %v", domain, err) return nil } now := time.Now().UTC() renewalTime := renewalInfo.ShouldRenewAt(now, ctx.Duration(flgARIWaitToRenewDuration)) if renewalTime == nil { log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is not needed", domain) return nil } log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is needed", domain) if renewalInfo.ExplanationURL != "" { log.Infof("[%s] acme: renewalInfo endpoint provided an explanation: %s", domain, renewalInfo.ExplanationURL) } return renewalTime } func merge(prevDomains, nextDomains []string) []string { for _, next := range nextDomains { if slices.Contains(prevDomains, next) { continue } prevDomains = append(prevDomains, next) } return prevDomains } ================================================ FILE: cmd/cmd_renew_test.go ================================================ package cmd import ( "crypto/x509" "testing" "time" "github.com/stretchr/testify/assert" ) func Test_merge(t *testing.T) { testCases := []struct { desc string prevDomains []string nextDomains []string expected []string }{ { desc: "all empty", prevDomains: []string{}, nextDomains: []string{}, expected: []string{}, }, { desc: "next empty", prevDomains: []string{"a", "b", "c"}, nextDomains: []string{}, expected: []string{"a", "b", "c"}, }, { desc: "prev empty", prevDomains: []string{}, nextDomains: []string{"a", "b", "c"}, expected: []string{"a", "b", "c"}, }, { desc: "merge append", prevDomains: []string{"a", "b", "c"}, nextDomains: []string{"a", "c", "d"}, expected: []string{"a", "b", "c", "d"}, }, { desc: "merge same", prevDomains: []string{"a", "b", "c"}, nextDomains: []string{"a", "b", "c"}, expected: []string{"a", "b", "c"}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() actual := merge(test.prevDomains, test.nextDomains) assert.Equal(t, test.expected, actual) }) } } func Test_needRenewal(t *testing.T) { testCases := []struct { desc string x509Cert *x509.Certificate days int expected bool }{ { desc: "30 days, NotAfter now", x509Cert: &x509.Certificate{ NotAfter: time.Now(), }, days: 30, expected: true, }, { desc: "30 days, NotAfter 31 days", x509Cert: &x509.Certificate{ NotAfter: time.Now().Add(31*24*time.Hour + 1*time.Second), }, days: 30, expected: false, }, { desc: "30 days, NotAfter 30 days", x509Cert: &x509.Certificate{ NotAfter: time.Now().Add(30 * 24 * time.Hour), }, days: 30, expected: true, }, { desc: "0 days, NotAfter 30 days: only the day of the expiration", x509Cert: &x509.Certificate{ NotAfter: time.Now().Add(30 * 24 * time.Hour), }, days: 0, expected: false, }, { desc: "-1 days, NotAfter 30 days: always renew", x509Cert: &x509.Certificate{ NotAfter: time.Now().Add(30 * 24 * time.Hour), }, days: -1, expected: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { actual := needRenewal(test.x509Cert, "foo.com", test.days, false) assert.Equal(t, test.expected, actual) }) } } func Test_needRenewalDynamic(t *testing.T) { testCases := []struct { desc string now time.Time notBefore, notAfter time.Time expected assert.BoolAssertionFunc }{ { desc: "higher than 1/3 of the certificate lifetime left (lifetime > 10 days)", now: time.Date(2025, 1, 19, 1, 1, 1, 1, time.UTC), notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC), notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC), expected: assert.False, }, { desc: "lower than 1/3 of the certificate lifetime left(lifetime > 10 days)", now: time.Date(2025, 1, 21, 1, 1, 1, 1, time.UTC), notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC), notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC), expected: assert.True, }, { desc: "higher than 1/2 of the certificate lifetime left (lifetime < 10 days)", now: time.Date(2025, 1, 4, 1, 1, 1, 1, time.UTC), notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC), notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC), expected: assert.False, }, { desc: "lower than 1/2 of the certificate lifetime left (lifetime < 10 days)", now: time.Date(2025, 1, 6, 1, 1, 1, 1, time.UTC), notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC), notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC), expected: assert.True, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() x509Cert := &x509.Certificate{ NotBefore: test.notBefore, NotAfter: test.notAfter, } ok := needRenewalDynamic(x509Cert, "example.com", test.now) test.expected(t, ok) }) } } ================================================ FILE: cmd/cmd_revoke.go ================================================ package cmd import ( "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/log" "github.com/urfave/cli/v2" ) // Flag names. const ( flgKeep = "keep" flgReason = "reason" ) func createRevoke() *cli.Command { return &cli.Command{ Name: "revoke", Usage: "Revoke a certificate", Action: revoke, Flags: []cli.Flag{ &cli.BoolFlag{ Name: flgKeep, Aliases: []string{"k"}, Usage: "Keep the certificates after the revocation instead of archiving them.", }, &cli.UintFlag{ Name: flgReason, Usage: "Identifies the reason for the certificate revocation." + " See https://www.rfc-editor.org/rfc/rfc5280.html#section-5.3.1." + " Valid values are:" + " 0 (unspecified), 1 (keyCompromise), 2 (cACompromise), 3 (affiliationChanged)," + " 4 (superseded), 5 (cessationOfOperation), 6 (certificateHold), 8 (removeFromCRL)," + " 9 (privilegeWithdrawn), or 10 (aACompromise).", Value: acme.CRLReasonUnspecified, }, }, } } func revoke(ctx *cli.Context) error { account, keyType := setupAccount(ctx, NewAccountsStorage(ctx)) if account.Registration == nil { log.Fatalf("Account %s is not registered. Use 'run' to register a new account.\n", account.Email) } client := newClient(ctx, account, keyType) certsStorage := NewCertificatesStorage(ctx) certsStorage.CreateRootFolder() for _, domain := range ctx.StringSlice(flgDomains) { log.Printf("Trying to revoke certificate for domain %s", domain) certBytes, err := certsStorage.ReadFile(domain, certExt) if err != nil { log.Fatalf("Error while revoking the certificate for domain %s\n\t%v", domain, err) } reason := ctx.Uint(flgReason) err = client.Certificate.RevokeWithReason(certBytes, &reason) if err != nil { log.Fatalf("Error while revoking the certificate for domain %s\n\t%v", domain, err) } log.Println("Certificate was revoked.") if ctx.Bool(flgKeep) { return nil } certsStorage.CreateArchiveFolder() err = certsStorage.MoveToArchive(domain) if err != nil { return err } log.Println("Certificate was archived for domain:", domain) } return nil } ================================================ FILE: cmd/cmd_run.go ================================================ package cmd import ( "bufio" "fmt" "os" "strings" "time" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/registration" "github.com/urfave/cli/v2" ) // Flag names. const ( flgNoBundle = "no-bundle" flgMustStaple = "must-staple" flgNotBefore = "not-before" flgNotAfter = "not-after" flgPrivateKey = "private-key" flgPreferredChain = "preferred-chain" flgProfile = "profile" flgAlwaysDeactivateAuthorizations = "always-deactivate-authorizations" flgRunHook = "run-hook" flgRunHookTimeout = "run-hook-timeout" ) func createRun() *cli.Command { return &cli.Command{ Name: "run", Usage: "Register an account, then create and install a certificate", Before: func(ctx *cli.Context) error { // we require either domains or csr, but not both hasDomains := len(ctx.StringSlice(flgDomains)) > 0 hasCsr := ctx.String(flgCSR) != "" if hasDomains && hasCsr { log.Fatal("Please specify either --domains/-d or --csr/-c, but not both") } if !hasDomains && !hasCsr { log.Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)") } return nil }, Action: run, Flags: []cli.Flag{ &cli.BoolFlag{ Name: flgNoBundle, Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", }, &cli.BoolFlag{ Name: flgMustStaple, Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate." + " Only works if the CSR is generated by lego.", }, &cli.TimestampFlag{ Name: flgNotBefore, Usage: "Set the notBefore field in the certificate (RFC3339 format)", Layout: time.RFC3339, }, &cli.TimestampFlag{ Name: flgNotAfter, Usage: "Set the notAfter field in the certificate (RFC3339 format)", Layout: time.RFC3339, }, &cli.StringFlag{ Name: flgPrivateKey, Usage: "Path to private key (in PEM encoding) for the certificate. By default, the private key is generated.", }, &cli.StringFlag{ Name: flgPreferredChain, Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." + " If no match, the default offered chain will be used.", }, &cli.StringFlag{ Name: flgProfile, Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.", }, &cli.StringFlag{ Name: flgAlwaysDeactivateAuthorizations, Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", }, &cli.StringFlag{ Name: flgRunHook, Usage: "Define a hook. The hook is executed when the certificates are effectively created.", }, &cli.DurationFlag{ Name: flgRunHookTimeout, Usage: "Define the timeout for the hook execution.", Value: 2 * time.Minute, }, }, } } const rootPathWarningMessage = `!!!! HEADS UP !!!! Your account credentials have been saved in your configuration directory at "%s". You should make a secure backup of this folder now. This configuration directory will also contain private keys generated by lego and certificates obtained from the ACME server. Making regular backups of this folder is ideal. ` func run(ctx *cli.Context) error { accountsStorage := NewAccountsStorage(ctx) account, keyType := setupAccount(ctx, accountsStorage) client := setupClient(ctx, account, keyType) if account.Registration == nil { reg, err := register(ctx, client) if err != nil { log.Fatalf("Could not complete registration\n\t%v", err) } account.Registration = reg if err = accountsStorage.Save(account); err != nil { log.Fatal(err) } fmt.Printf(rootPathWarningMessage, accountsStorage.GetRootPath()) } certsStorage := NewCertificatesStorage(ctx) certsStorage.CreateRootFolder() cert, err := obtainCertificate(ctx, client) if err != nil { // Make sure to return a non-zero exit code if ObtainSANCertificate returned at least one error. // Due to us not returning partial certificate we can just exit here instead of at the end. log.Fatalf("Could not obtain certificates:\n\t%v", err) } certsStorage.SaveResource(cert) meta := map[string]string{ hookEnvAccountEmail: account.Email, } addPathToMetadata(meta, cert.Domain, cert, certsStorage) return launchHook(ctx.String(flgRunHook), ctx.Duration(flgRunHookTimeout), meta) } func handleTOS(ctx *cli.Context, client *lego.Client) bool { // Check for a global accept override if ctx.Bool(flgAcceptTOS) { return true } reader := bufio.NewReader(os.Stdin) log.Printf("Please review the TOS at %s", client.GetToSURL()) for { fmt.Println("Do you accept the TOS? Y/n") text, err := reader.ReadString('\n') if err != nil { log.Fatalf("Could not read from console: %v", err) } text = strings.Trim(text, "\r\n") switch text { case "", "y", "Y": return true case "n", "N": return false default: fmt.Println("Your input was invalid. Please answer with one of Y/y, n/N or by pressing enter.") } } } func register(ctx *cli.Context, client *lego.Client) (*registration.Resource, error) { accepted := handleTOS(ctx, client) if !accepted { log.Fatal("You did not accept the TOS. Unable to proceed.") } if ctx.Bool(flgEAB) { kid := ctx.String(flgKID) hmacEncoded := ctx.String(flgHMAC) if kid == "" || hmacEncoded == "" { log.Fatalf("Requires arguments --%s and --%s.", flgKID, flgHMAC) } return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ TermsOfServiceAgreed: accepted, Kid: kid, HmacEncoded: hmacEncoded, }) } return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) } func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Resource, error) { bundle := !ctx.Bool(flgNoBundle) domains := ctx.StringSlice(flgDomains) if len(domains) > 0 { // obtain a certificate, generating a new private key request := certificate.ObtainRequest{ Domains: domains, MustStaple: ctx.Bool(flgMustStaple), NotBefore: getTime(ctx, flgNotBefore), NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } if ctx.IsSet(flgPrivateKey) { var err error request.PrivateKey, err = loadPrivateKey(ctx.String(flgPrivateKey)) if err != nil { return nil, fmt.Errorf("load private key: %w", err) } } return client.Certificate.Obtain(request) } // read the CSR csr, err := readCSRFile(ctx.String(flgCSR)) if err != nil { return nil, err } // obtain a certificate for this CSR request := certificate.ObtainForCSRRequest{ CSR: csr, NotBefore: getTime(ctx, flgNotBefore), NotAfter: getTime(ctx, flgNotAfter), Bundle: bundle, PreferredChain: ctx.String(flgPreferredChain), Profile: ctx.String(flgProfile), AlwaysDeactivateAuthorizations: ctx.Bool(flgAlwaysDeactivateAuthorizations), } if ctx.IsSet(flgPrivateKey) { var err error request.PrivateKey, err = loadPrivateKey(ctx.String(flgPrivateKey)) if err != nil { return nil, fmt.Errorf("load private key: %w", err) } } return client.Certificate.ObtainForCSR(request) } ================================================ FILE: cmd/flags.go ================================================ package cmd import ( "fmt" "time" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/lego" "github.com/urfave/cli/v2" "software.sslmate.com/src/go-pkcs12" ) // Flag names. const ( flgDomains = "domains" flgServer = "server" flgAcceptTOS = "accept-tos" flgEmail = "email" flgDisableCommonName = "disable-cn" flgCSR = "csr" flgEAB = "eab" flgKID = "kid" flgHMAC = "hmac" flgKeyType = "key-type" flgFilename = "filename" flgPath = "path" flgHTTP = "http" flgHTTPPort = "http.port" flgHTTPDelay = "http.delay" flgHTTPProxyHeader = "http.proxy-header" flgHTTPWebroot = "http.webroot" flgHTTPMemcachedHost = "http.memcached-host" flgHTTPS3Bucket = "http.s3-bucket" flgTLS = "tls" flgTLSPort = "tls.port" flgTLSDelay = "tls.delay" flgDNS = "dns" flgDNSDisableCP = "dns.disable-cp" flgDNSPropagationWait = "dns.propagation-wait" flgDNSPropagationDisableANS = "dns.propagation-disable-ans" flgDNSPropagationRNS = "dns.propagation-rns" flgDNSResolvers = "dns.resolvers" flgHTTPTimeout = "http-timeout" flgTLSSkipVerify = "tls-skip-verify" flgDNSTimeout = "dns-timeout" flgPEM = "pem" flgPFX = "pfx" flgPFXPass = "pfx.pass" flgPFXFormat = "pfx.format" flgCertTimeout = "cert.timeout" flgOverallRequestLimit = "overall-request-limit" flgUserAgent = "user-agent" ) const ( envEAB = "LEGO_EAB" envEABHMAC = "LEGO_EAB_HMAC" envEABKID = "LEGO_EAB_KID" envEmail = "LEGO_EMAIL" envPath = "LEGO_PATH" envPFX = "LEGO_PFX" envPFXFormat = "LEGO_PFX_FORMAT" envPFXPassword = "LEGO_PFX_PASSWORD" envServer = "LEGO_SERVER" ) func CreateFlags(defaultPath string) []cli.Flag { return []cli.Flag{ &cli.StringSliceFlag{ Name: flgDomains, Aliases: []string{"d"}, Usage: "Add a domain to the process. Can be specified multiple times.", }, &cli.StringFlag{ Name: flgServer, Aliases: []string{"s"}, EnvVars: []string{envServer}, Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.", Value: lego.LEDirectoryProduction, }, &cli.BoolFlag{ Name: flgAcceptTOS, Aliases: []string{"a"}, Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.", }, &cli.StringFlag{ Name: flgEmail, Aliases: []string{"m"}, EnvVars: []string{envEmail}, Usage: "Email used for registration and recovery contact.", }, &cli.BoolFlag{ Name: flgDisableCommonName, Usage: "Disable the use of the common name in the CSR.", }, &cli.StringFlag{ Name: flgCSR, Aliases: []string{"c"}, Usage: "Certificate signing request filename, if an external CSR is to be used.", }, &cli.BoolFlag{ Name: flgEAB, EnvVars: []string{envEAB}, Usage: "Use External Account Binding for account registration. Requires --kid and --hmac.", }, &cli.StringFlag{ Name: flgKID, EnvVars: []string{envEABKID}, Usage: "Key identifier from External CA. Used for External Account Binding.", }, &cli.StringFlag{ Name: flgHMAC, EnvVars: []string{envEABHMAC}, Usage: "MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.", }, &cli.StringFlag{ Name: flgKeyType, Aliases: []string{"k"}, Value: "ec256", Usage: "Key type to use for private keys. Supported: rsa2048, rsa3072, rsa4096, rsa8192, ec256, ec384.", }, &cli.StringFlag{ Name: flgFilename, Usage: "(deprecated) Filename of the generated certificate.", }, &cli.StringFlag{ Name: flgPath, EnvVars: []string{envPath}, Usage: "Directory to use for storing the data.", Value: defaultPath, }, &cli.BoolFlag{ Name: flgHTTP, Usage: "Use the HTTP-01 challenge to solve challenges. Can be mixed with other types of challenges.", }, &cli.StringFlag{ Name: flgHTTPPort, Usage: "Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port.", Value: ":80", }, &cli.DurationFlag{ Name: flgHTTPDelay, Usage: "Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge.", Value: 0, }, &cli.StringFlag{ Name: flgHTTPProxyHeader, Usage: "Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy.", Value: "Host", }, &cli.StringFlag{ Name: flgHTTPWebroot, Usage: "Set the webroot folder to use for HTTP-01 based challenges to write directly to the .well-known/acme-challenge file." + " This disables the built-in server and expects the given directory to be publicly served with access to .well-known/acme-challenge", }, &cli.StringSliceFlag{ Name: flgHTTPMemcachedHost, Usage: "Set the memcached host(s) to use for HTTP-01 based challenges. Challenges will be written to all specified hosts.", }, &cli.StringFlag{ Name: flgHTTPS3Bucket, Usage: "Set the S3 bucket name to use for HTTP-01 based challenges. Challenges will be written to the S3 bucket.", }, &cli.BoolFlag{ Name: flgTLS, Usage: "Use the TLS-ALPN-01 challenge to solve challenges. Can be mixed with other types of challenges.", }, &cli.StringFlag{ Name: flgTLSPort, Usage: "Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port.", Value: ":443", }, &cli.DurationFlag{ Name: flgTLSDelay, Usage: "Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge.", Value: 0, }, &cli.StringFlag{ Name: flgDNS, Usage: "Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage.", }, &cli.BoolFlag{ Name: flgDNSDisableCP, Usage: fmt.Sprintf("(deprecated) use %s instead.", flgDNSPropagationDisableANS), }, &cli.BoolFlag{ Name: flgDNSPropagationDisableANS, Usage: "By setting this flag to true, disables the need to await propagation of the TXT record to all authoritative name servers.", }, &cli.BoolFlag{ Name: flgDNSPropagationRNS, Usage: "By setting this flag to true, use all the recursive nameservers to check the propagation of the TXT record.", }, &cli.DurationFlag{ Name: flgDNSPropagationWait, Usage: "By setting this flag, disables all the propagation checks of the TXT record and uses a wait duration instead.", }, &cli.StringSliceFlag{ Name: flgDNSResolvers, Usage: "Set the resolvers to use for performing (recursive) CNAME resolving and apex domain determination." + " For DNS-01 challenge verification, the authoritative DNS server is queried directly." + " Supported: host:port." + " The default is to use the system resolvers, or Google's DNS resolvers if the system's cannot be determined.", }, &cli.IntFlag{ Name: flgHTTPTimeout, Usage: "Set the HTTP timeout value to a specific value in seconds.", }, &cli.BoolFlag{ Name: flgTLSSkipVerify, Usage: "Skip the TLS verification of the ACME server.", }, &cli.IntFlag{ Name: flgDNSTimeout, Usage: "Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name server queries.", Value: 10, }, &cli.BoolFlag{ Name: flgPEM, Usage: "Generate an additional .pem (base64) file by concatenating the .key and .crt files together.", }, &cli.BoolFlag{ Name: flgPFX, Usage: "Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together.", EnvVars: []string{envPFX}, }, &cli.StringFlag{ Name: flgPFXPass, Usage: "The password used to encrypt the .pfx (PCKS#12) file.", Value: pkcs12.DefaultPassword, EnvVars: []string{envPFXPassword}, }, &cli.StringFlag{ Name: flgPFXFormat, Usage: "The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256.", Value: "RC2", EnvVars: []string{envPFXFormat}, }, &cli.IntFlag{ Name: flgCertTimeout, Usage: "Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates.", Value: 30, }, &cli.IntFlag{ Name: flgOverallRequestLimit, Usage: "ACME overall requests limit.", Value: certificate.DefaultOverallRequestLimit, }, &cli.StringFlag{ Name: flgUserAgent, Usage: "Add to the user-agent sent to the CA to identify an application embedding lego-cli", }, } } func getTime(ctx *cli.Context, name string) time.Time { value := ctx.Timestamp(name) if value == nil { return time.Time{} } return *value } ================================================ FILE: cmd/hook.go ================================================ package cmd import ( "bufio" "context" "errors" "fmt" "os" "os/exec" "strings" "time" "github.com/go-acme/lego/v4/certificate" ) const ( hookEnvAccountEmail = "LEGO_ACCOUNT_EMAIL" hookEnvCertDomain = "LEGO_CERT_DOMAIN" hookEnvCertPath = "LEGO_CERT_PATH" hookEnvCertKeyPath = "LEGO_CERT_KEY_PATH" hookEnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH" hookEnvCertPEMPath = "LEGO_CERT_PEM_PATH" hookEnvCertPFXPath = "LEGO_CERT_PFX_PATH" ) func launchHook(hook string, timeout time.Duration, meta map[string]string) error { if hook == "" { return nil } ctxCmd, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() parts := strings.Fields(hook) cmd := exec.CommandContext(ctxCmd, parts[0], parts[1:]...) cmd.Env = append(os.Environ(), metaToEnv(meta)...) stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("create pipe: %w", err) } cmd.Stderr = cmd.Stdout err = cmd.Start() if err != nil { return fmt.Errorf("start command: %w", err) } go func() { <-ctxCmd.Done() if ctxCmd.Err() != nil { _ = cmd.Process.Kill() _ = stdout.Close() } }() scanner := bufio.NewScanner(stdout) for scanner.Scan() { fmt.Println(scanner.Text()) } err = cmd.Wait() if err != nil { if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) { return errors.New("hook timed out") } return fmt.Errorf("wait command: %w", err) } return nil } func metaToEnv(meta map[string]string) []string { var envs []string for k, v := range meta { envs = append(envs, k+"="+v) } return envs } func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) { meta[hookEnvCertDomain] = domain meta[hookEnvCertPath] = certsStorage.GetFileName(domain, certExt) meta[hookEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt) if certRes.IssuerCertificate != nil { meta[hookEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt) } if certsStorage.pem { meta[hookEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt) } if certsStorage.pfx { meta[hookEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt) } } ================================================ FILE: cmd/hook_test.go ================================================ package cmd import ( "runtime" "testing" "time" "github.com/stretchr/testify/require" ) func Test_launchHook(t *testing.T) { err := launchHook("echo foo", 1*time.Second, map[string]string{}) require.NoError(t, err) } func Test_launchHook_errors(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("skipping test on Windows") } testCases := []struct { desc string hook string timeout time.Duration expected string }{ { desc: "kill the hook", hook: "sleep 5", timeout: 1 * time.Second, expected: "hook timed out", }, { desc: "context timeout on Start", hook: "echo foo", timeout: 1 * time.Nanosecond, expected: "start command: context deadline exceeded", }, { desc: "multiple short sleeps", hook: "./testdata/sleepy.sh", timeout: 1 * time.Second, expected: "hook timed out", }, { desc: "long sleep", hook: "./testdata/sleeping_beauty.sh", timeout: 1 * time.Second, expected: "hook timed out", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() err := launchHook(test.hook, test.timeout, map[string]string{}) require.EqualError(t, err, test.expected) }) } } ================================================ FILE: cmd/lego/main.go ================================================ // Let's Encrypt client to go! // CLI application for generating Let's Encrypt certificates using the ACME package. package main import ( "fmt" "os" "path/filepath" "runtime" "github.com/go-acme/lego/v4/cmd" "github.com/go-acme/lego/v4/log" "github.com/urfave/cli/v2" ) func main() { app := cli.NewApp() app.Name = "lego" app.HelpName = "lego" app.Usage = "Let's Encrypt client written in Go" app.EnableBashCompletion = true app.Version = getVersion() cli.VersionPrinter = func(c *cli.Context) { fmt.Printf("lego version %s %s/%s\n", c.App.Version, runtime.GOOS, runtime.GOARCH) } var defaultPath string cwd, err := os.Getwd() if err == nil { defaultPath = filepath.Join(cwd, ".lego") } app.Flags = cmd.CreateFlags(defaultPath) app.Before = cmd.Before app.Commands = cmd.CreateCommands() err = app.Run(os.Args) if err != nil { log.Fatal(err) } } ================================================ FILE: cmd/lego/zz_gen_version.go ================================================ // Code generated by 'internal/releaser'; DO NOT EDIT. package main const defaultVersion = "v4.33.0+dev-detach" var version = "" func getVersion() string { if version == "" { return defaultVersion } return version } ================================================ FILE: cmd/setup.go ================================================ package cmd import ( "context" "crypto/x509" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "os" "strings" "time" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/registration" "github.com/hashicorp/go-retryablehttp" "github.com/urfave/cli/v2" ) const filePerm os.FileMode = 0o600 // setupClient creates a new client with challenge settings. func setupClient(ctx *cli.Context, account *Account, keyType certcrypto.KeyType) *lego.Client { client := newClient(ctx, account, keyType) setupChallenges(ctx, client) return client } func setupAccount(ctx *cli.Context, accountsStorage *AccountsStorage) (*Account, certcrypto.KeyType) { keyType := getKeyType(ctx) privateKey := accountsStorage.GetPrivateKey(keyType) var account *Account if accountsStorage.ExistsAccountFilePath() { account = accountsStorage.LoadAccount(privateKey) } else { account = &Account{Email: accountsStorage.GetEmail(), key: privateKey} } return account, keyType } func newClient(ctx *cli.Context, acc registration.User, keyType certcrypto.KeyType) *lego.Client { config := lego.NewConfig(acc) config.CADirURL = ctx.String(flgServer) config.Certificate = lego.CertificateConfig{ KeyType: keyType, Timeout: time.Duration(ctx.Int(flgCertTimeout)) * time.Second, OverallRequestLimit: ctx.Int(flgOverallRequestLimit), DisableCommonName: ctx.Bool(flgDisableCommonName), } config.UserAgent = getUserAgent(ctx) if ctx.IsSet(flgHTTPTimeout) { config.HTTPClient.Timeout = time.Duration(ctx.Int(flgHTTPTimeout)) * time.Second } if ctx.Bool(flgTLSSkipVerify) { defaultTransport, ok := config.HTTPClient.Transport.(*http.Transport) if ok { // This is always true because the default client used by the CLI defined the transport. tr := defaultTransport.Clone() tr.TLSClientConfig.InsecureSkipVerify = true config.HTTPClient.Transport = tr } } retryClient := retryablehttp.NewClient() retryClient.RetryMax = 5 retryClient.HTTPClient = config.HTTPClient retryClient.CheckRetry = checkRetry retryClient.Logger = nil if _, v := os.LookupEnv("LEGO_DEBUG_ACME_HTTP_CLIENT"); v { retryClient.Logger = log.Logger } config.HTTPClient = retryClient.StandardClient() client, err := lego.NewClient(config) if err != nil { log.Fatalf("Could not create client: %v", err) } if client.GetExternalAccountRequired() && !ctx.IsSet(flgEAB) { log.Fatalf("Server requires External Account Binding. Use --%s with --%s and --%s.", flgEAB, flgKID, flgHMAC) } return client } // getKeyType the type from which private keys should be generated. func getKeyType(ctx *cli.Context) certcrypto.KeyType { keyType := ctx.String(flgKeyType) switch strings.ToUpper(keyType) { case "RSA2048": return certcrypto.RSA2048 case "RSA3072": return certcrypto.RSA3072 case "RSA4096": return certcrypto.RSA4096 case "RSA8192": return certcrypto.RSA8192 case "EC256": return certcrypto.EC256 case "EC384": return certcrypto.EC384 } log.Fatalf("Unsupported KeyType: %s", keyType) return "" } func getUserAgent(ctx *cli.Context) string { return strings.TrimSpace(fmt.Sprintf("%s lego-cli/%s", ctx.String(flgUserAgent), ctx.App.Version)) } func createNonExistingFolder(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return os.MkdirAll(path, 0o700) } else if err != nil { return err } return nil } func readCSRFile(filename string) (*x509.CertificateRequest, error) { bytes, err := os.ReadFile(filename) if err != nil { return nil, err } raw := bytes // see if we can find a PEM-encoded CSR var p *pem.Block rest := bytes for { // decode a PEM block p, rest = pem.Decode(rest) // did we fail? if p == nil { break } // did we get a CSR? if p.Type == "CERTIFICATE REQUEST" || p.Type == "NEW CERTIFICATE REQUEST" { raw = p.Bytes } } // no PEM-encoded CSR // assume we were given a DER-encoded ASN.1 CSR // (if this assumption is wrong, parsing these bytes will fail) return x509.ParseCertificateRequest(raw) } func checkRetry(ctx context.Context, resp *http.Response, err error) (bool, error) { rt, err := retryablehttp.ErrorPropagatedRetryPolicy(ctx, resp, err) if err != nil { return rt, err } if resp == nil { return rt, nil } if resp.StatusCode/100 == 2 { return rt, nil } all, err := io.ReadAll(resp.Body) if err == nil { var errorDetails *acme.ProblemDetails err = json.Unmarshal(all, &errorDetails) if err != nil { return rt, fmt.Errorf("%s %s: %s", resp.Request.Method, resp.Request.URL.Redacted(), string(all)) } switch errorDetails.Type { case acme.BadNonceErr: return false, &acme.NonceError{ ProblemDetails: errorDetails, } case acme.AlreadyReplacedErr: if errorDetails.HTTPStatus == http.StatusConflict { return false, &acme.AlreadyReplacedError{ ProblemDetails: errorDetails, } } default: log.Warnf("retry: %v", errorDetails) return rt, errorDetails } } return rt, nil } ================================================ FILE: cmd/setup_challenges.go ================================================ package cmd import ( "fmt" "net" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/challenge/http01" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/providers/dns" "github.com/go-acme/lego/v4/providers/http/memcached" "github.com/go-acme/lego/v4/providers/http/s3" "github.com/go-acme/lego/v4/providers/http/webroot" "github.com/urfave/cli/v2" ) func setupChallenges(ctx *cli.Context, client *lego.Client) { if !ctx.Bool(flgHTTP) && !ctx.Bool(flgTLS) && !ctx.IsSet(flgDNS) { log.Fatalf("No challenge selected. You must specify at least one challenge: `--%s`, `--%s`, `--%s`.", flgHTTP, flgTLS, flgDNS) } if ctx.Bool(flgHTTP) { err := client.Challenge.SetHTTP01Provider(setupHTTPProvider(ctx), http01.SetDelay(ctx.Duration(flgHTTPDelay))) if err != nil { log.Fatal(err) } } if ctx.Bool(flgTLS) { err := client.Challenge.SetTLSALPN01Provider(setupTLSProvider(ctx), tlsalpn01.SetDelay(ctx.Duration(flgTLSDelay))) if err != nil { log.Fatal(err) } } if ctx.IsSet(flgDNS) { err := setupDNS(ctx, client) if err != nil { log.Fatal(err) } } } //nolint:gocyclo // the complexity is expected. func setupHTTPProvider(ctx *cli.Context) challenge.Provider { switch { case ctx.IsSet(flgHTTPWebroot): ps, err := webroot.NewHTTPProvider(ctx.String(flgHTTPWebroot)) if err != nil { log.Fatal(err) } return ps case ctx.IsSet(flgHTTPMemcachedHost): ps, err := memcached.NewMemcachedProvider(ctx.StringSlice(flgHTTPMemcachedHost)) if err != nil { log.Fatal(err) } return ps case ctx.IsSet(flgHTTPS3Bucket): ps, err := s3.NewHTTPProvider(ctx.String(flgHTTPS3Bucket)) if err != nil { log.Fatal(err) } return ps case ctx.IsSet(flgHTTPPort): iface := ctx.String(flgHTTPPort) if !strings.Contains(iface, ":") { log.Fatalf("The --%s switch only accepts interface:port or :port for its argument.", flgHTTPPort) } host, port, err := net.SplitHostPort(iface) if err != nil { log.Fatal(err) } srv := http01.NewProviderServer(host, port) if header := ctx.String(flgHTTPProxyHeader); header != "" { srv.SetProxyHeader(header) } return srv case ctx.Bool(flgHTTP): srv := http01.NewProviderServer("", "") if header := ctx.String(flgHTTPProxyHeader); header != "" { srv.SetProxyHeader(header) } return srv default: log.Fatal("Invalid HTTP challenge options.") return nil } } func setupTLSProvider(ctx *cli.Context) challenge.Provider { switch { case ctx.IsSet(flgTLSPort): iface := ctx.String(flgTLSPort) if !strings.Contains(iface, ":") { log.Fatalf("The --%s switch only accepts interface:port or :port for its argument.", flgTLSPort) } host, port, err := net.SplitHostPort(iface) if err != nil { log.Fatal(err) } return tlsalpn01.NewProviderServer(host, port) case ctx.Bool(flgTLS): return tlsalpn01.NewProviderServer("", "") default: log.Fatal("Invalid HTTP challenge options.") return nil } } func setupDNS(ctx *cli.Context, client *lego.Client) error { err := checkPropagationExclusiveOptions(ctx) if err != nil { return err } wait := ctx.Duration(flgDNSPropagationWait) if wait < 0 { return fmt.Errorf("'%s' cannot be negative", flgDNSPropagationWait) } provider, err := dns.NewDNSChallengeProviderByName(ctx.String(flgDNS)) if err != nil { return err } servers := ctx.StringSlice(flgDNSResolvers) err = client.Challenge.SetDNS01Provider(provider, dns01.CondOption(len(servers) > 0, dns01.AddRecursiveNameservers(dns01.ParseNameservers(ctx.StringSlice(flgDNSResolvers)))), dns01.CondOption(ctx.Bool(flgDNSDisableCP) || ctx.Bool(flgDNSPropagationDisableANS), dns01.DisableAuthoritativeNssPropagationRequirement()), dns01.CondOption(ctx.Duration(flgDNSPropagationWait) > 0, // TODO(ldez): inside the next major version we will use flgDNSDisableCP here. // This will change the meaning of this flag to really disable all propagation checks. dns01.PropagationWait(wait, true)), dns01.CondOption(ctx.Bool(flgDNSPropagationRNS), dns01.RecursiveNSsPropagationRequirement()), dns01.CondOption(ctx.IsSet(flgDNSTimeout), dns01.AddDNSTimeout(time.Duration(ctx.Int(flgDNSTimeout))*time.Second)), ) return err } func checkPropagationExclusiveOptions(ctx *cli.Context) error { if ctx.IsSet(flgDNSDisableCP) { log.Printf("The flag '%s' is deprecated use '%s' instead.", flgDNSDisableCP, flgDNSPropagationDisableANS) } if (isSetBool(ctx, flgDNSDisableCP) || isSetBool(ctx, flgDNSPropagationDisableANS)) && ctx.IsSet(flgDNSPropagationWait) { return fmt.Errorf("'%s' and '%s' are mutually exclusive", flgDNSPropagationDisableANS, flgDNSPropagationWait) } if isSetBool(ctx, flgDNSPropagationRNS) && ctx.IsSet(flgDNSPropagationWait) { return fmt.Errorf("'%s' and '%s' are mutually exclusive", flgDNSPropagationRNS, flgDNSPropagationWait) } return nil } func isSetBool(ctx *cli.Context, name string) bool { return ctx.IsSet(name) && ctx.Bool(name) } ================================================ FILE: cmd/testdata/sleeping_beauty.sh ================================================ #!/bin/bash -e sleep 50 ================================================ FILE: cmd/testdata/sleepy.sh ================================================ #!/bin/bash -e for i in `seq 1 10` do echo $i sleep 0.2 done ================================================ FILE: cmd/zz_gen_cmd_dnshelp.go ================================================ // Code generated by 'make generate-dns'; DO NOT EDIT. package cmd import ( "fmt" "io" "sort" "strings" "text/tabwriter" ) func allDNSCodes() string { providers := []string{ "acme-dns", "active24", "alidns", "aliesa", "allinkl", "alwaysdata", "anexia", "artfiles", "arvancloud", "auroradns", "autodns", "axelname", "azion", "azure", "azuredns", "baiducloud", "beget", "binarylane", "bindman", "bluecat", "bluecatv2", "bookmyname", "brandit", "bunny", "checkdomain", "civo", "clouddns", "cloudflare", "cloudns", "cloudru", "cloudxns", "com35", "conoha", "conohav3", "constellix", "corenetworks", "cpanel", "czechia", "ddnss", "derak", "desec", "designate", "digitalocean", "directadmin", "dnsexit", "dnshomede", "dnsimple", "dnsmadeeasy", "dnspod", "dode", "domeneshop", "dreamhost", "duckdns", "dyn", "dyndnsfree", "dynu", "easydns", "edgecenter", "edgedns", "edgeone", "efficientip", "epik", "eurodns", "excedo", "exec", "exoscale", "f5xc", "freemyip", "gandi", "gandiv5", "gcloud", "gcore", "gigahostno", "glesys", "godaddy", "googledomains", "gravity", "hetzner", "hostingde", "hostinger", "hostingnl", "hosttech", "httpnet", "httpreq", "huaweicloud", "hurricane", "hyperone", "ibmcloud", "iij", "iijdpf", "infoblox", "infomaniak", "internetbs", "inwx", "ionos", "ionoscloud", "ipv64", "ispconfig", "ispconfigddns", "iwantmyname", "jdcloud", "joker", "keyhelp", "leaseweb", "liara", "lightsail", "limacity", "linode", "liquidweb", "loopia", "luadns", "mailinabox", "manageengine", "manual", "metaname", "metaregistrar", "mijnhost", "mittwald", "myaddr", "mydnsjp", "mythicbeasts", "namecheap", "namedotcom", "namesilo", "namesurfer", "nearlyfreespeech", "neodigit", "netcup", "netlify", "nicmanager", "nicru", "nifcloud", "njalla", "nodion", "ns1", "octenium", "oraclecloud", "otc", "ovh", "pdns", "plesk", "porkbun", "rackspace", "rainyun", "rcodezero", "regfish", "regru", "rfc2136", "rimuhosting", "route53", "safedns", "sakuracloud", "scaleway", "selectel", "selectelv2", "selfhostde", "servercow", "shellrent", "simply", "sonic", "spaceship", "stackpath", "syse", "technitium", "tencentcloud", "timewebcloud", "todaynic", "transip", "ultradns", "uniteddomains", "variomedia", "vegadns", "vercel", "versio", "vinyldns", "virtualname", "vkcloud", "volcengine", "vscale", "vultr", "webnames", "webnamesca", "websupport", "wedos", "westcn", "yandex", "yandex360", "yandexcloud", "zoneedit", "zoneee", "zonomi", } sort.Strings(providers) return strings.Join(providers, ", ") } func displayDNSHelp(w io.Writer, name string) error { w = tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) ew := &errWriter{w: w} switch name { case "acme-dns": // generated from: providers/dns/acmedns/acmedns.toml ew.writeln(`Configuration for Joohoi's ACME-DNS.`) ew.writeln(`Code: 'acme-dns'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ACME_DNS_API_BASE": The ACME-DNS API address`) ew.writeln(` - "ACME_DNS_STORAGE_BASE_URL": The ACME-DNS JSON account data server.`) ew.writeln(` - "ACME_DNS_STORAGE_PATH": The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates.`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ACME_DNS_ALLOWLIST": Source networks using CIDR notation (multiple values should be separated with a comma).`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/acme-dns`) case "active24": // generated from: providers/dns/active24/active24.toml ew.writeln(`Configuration for Active24.`) ew.writeln(`Code: 'active24'`) ew.writeln(`Since: 'v4.23.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ACTIVE24_API_KEY": API key`) ew.writeln(` - "ACTIVE24_SECRET": Secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ACTIVE24_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ACTIVE24_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ACTIVE24_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "ACTIVE24_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/active24`) case "alidns": // generated from: providers/dns/alidns/alidns.toml ew.writeln(`Configuration for Alibaba Cloud DNS.`) ew.writeln(`Code: 'alidns'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ALICLOUD_ACCESS_KEY": Access key ID`) ew.writeln(` - "ALICLOUD_RAM_ROLE": Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)`) ew.writeln(` - "ALICLOUD_SECRET_KEY": Access Key secret`) ew.writeln(` - "ALICLOUD_SECURITY_TOKEN": STS Security Token (optional)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ALICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "ALICLOUD_LINE": Line (Default: default)`) ew.writeln(` - "ALICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ALICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "ALICLOUD_REGION_ID": Region ID (Default: cn-hangzhou)`) ew.writeln(` - "ALICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/alidns`) case "aliesa": // generated from: providers/dns/aliesa/aliesa.toml ew.writeln(`Configuration for AlibabaCloud ESA.`) ew.writeln(`Code: 'aliesa'`) ew.writeln(`Since: 'v4.29.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ALIESA_ACCESS_KEY": Access key ID`) ew.writeln(` - "ALIESA_RAM_ROLE": Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)`) ew.writeln(` - "ALIESA_SECRET_KEY": Access Key secret`) ew.writeln(` - "ALIESA_SECURITY_TOKEN": STS Security Token (optional)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ALIESA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ALIESA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ALIESA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "ALIESA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/aliesa`) case "allinkl": // generated from: providers/dns/allinkl/allinkl.toml ew.writeln(`Configuration for all-inkl.`) ew.writeln(`Code: 'allinkl'`) ew.writeln(`Since: 'v4.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ALL_INKL_LOGIN": KAS login`) ew.writeln(` - "ALL_INKL_PASSWORD": KAS password`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ALL_INKL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ALL_INKL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ALL_INKL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/allinkl`) case "alwaysdata": // generated from: providers/dns/alwaysdata/alwaysdata.toml ew.writeln(`Configuration for Alwaysdata.`) ew.writeln(`Code: 'alwaysdata'`) ew.writeln(`Since: 'v4.31.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ALWAYSDATA_API_KEY": API Key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ALWAYSDATA_ACCOUNT": Account name`) ew.writeln(` - "ALWAYSDATA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ALWAYSDATA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ALWAYSDATA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "ALWAYSDATA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/alwaysdata`) case "anexia": // generated from: providers/dns/anexia/anexia.toml ew.writeln(`Configuration for Anexia CloudDNS.`) ew.writeln(`Code: 'anexia'`) ew.writeln(`Since: 'v4.28.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ANEXIA_TOKEN": API token for Anexia Engine`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ANEXIA_API_URL": API endpoint URL (default: https://engine.anexia-it.com)`) ew.writeln(` - "ANEXIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ANEXIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ANEXIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "ANEXIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/anexia`) case "artfiles": // generated from: providers/dns/artfiles/artfiles.toml ew.writeln(`Configuration for ArtFiles.`) ew.writeln(`Code: 'artfiles'`) ew.writeln(`Since: 'v4.32.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ARTFILES_PASSWORD": API password`) ew.writeln(` - "ARTFILES_USERNAME": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ARTFILES_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ARTFILES_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ARTFILES_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) ew.writeln(` - "ARTFILES_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/artfiles`) case "arvancloud": // generated from: providers/dns/arvancloud/arvancloud.toml ew.writeln(`Configuration for ArvanCloud.`) ew.writeln(`Code: 'arvancloud'`) ew.writeln(`Since: 'v3.8.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ARVANCLOUD_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ARVANCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ARVANCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ARVANCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "ARVANCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/arvancloud`) case "auroradns": // generated from: providers/dns/auroradns/auroradns.toml ew.writeln(`Configuration for Aurora DNS.`) ew.writeln(`Code: 'auroradns'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AURORA_API_KEY": API key or username to used`) ew.writeln(` - "AURORA_SECRET": Secret password to be used`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AURORA_ENDPOINT": API endpoint URL`) ew.writeln(` - "AURORA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "AURORA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "AURORA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/auroradns`) case "autodns": // generated from: providers/dns/autodns/autodns.toml ew.writeln(`Configuration for Autodns.`) ew.writeln(`Code: 'autodns'`) ew.writeln(`Since: 'v3.2.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AUTODNS_API_PASSWORD": User Password`) ew.writeln(` - "AUTODNS_API_USER": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AUTODNS_CONTEXT": API context (4 for production, 1 for testing. Defaults to 4)`) ew.writeln(` - "AUTODNS_ENDPOINT": API endpoint URL, defaults to https://api.autodns.com/v1/`) ew.writeln(` - "AUTODNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "AUTODNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "AUTODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "AUTODNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/autodns`) case "axelname": // generated from: providers/dns/axelname/axelname.toml ew.writeln(`Configuration for Axelname.`) ew.writeln(`Code: 'axelname'`) ew.writeln(`Since: 'v4.23.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AXELNAME_NICKNAME": Account nickname`) ew.writeln(` - "AXELNAME_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AXELNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "AXELNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "AXELNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "AXELNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/axelname`) case "azion": // generated from: providers/dns/azion/azion.toml ew.writeln(`Configuration for Azion.`) ew.writeln(`Code: 'azion'`) ew.writeln(`Since: 'v4.24.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AZION_PERSONAL_TOKEN": Your Azion personal token.`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AZION_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "AZION_PAGE_SIZE": The page size for the API request (Default: 50)`) ew.writeln(` - "AZION_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "AZION_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "AZION_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/azion`) case "azure": // generated from: providers/dns/azure/azure.toml ew.writeln(`Configuration for Azure (deprecated).`) ew.writeln(`Code: 'azure'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AZURE_CLIENT_ID": Client ID`) ew.writeln(` - "AZURE_CLIENT_SECRET": Client secret`) ew.writeln(` - "AZURE_ENVIRONMENT": Azure environment, one of: public, usgovernment, german, and china`) ew.writeln(` - "AZURE_RESOURCE_GROUP": Resource group`) ew.writeln(` - "AZURE_SUBSCRIPTION_ID": Subscription ID`) ew.writeln(` - "AZURE_TENANT_ID": Tenant ID`) ew.writeln(` - "instance metadata service": If the credentials are **not** set via the environment, then it will attempt to get a bearer token via the [instance metadata service](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service).`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AZURE_METADATA_ENDPOINT": Metadata Service endpoint URL`) ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "AZURE_PRIVATE_ZONE": Set to true to use Azure Private DNS Zones and not public`) ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln(` - "AZURE_ZONE_NAME": Zone name to use inside Azure DNS service to add the TXT record in`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/azure`) case "azuredns": // generated from: providers/dns/azuredns/azuredns.toml ew.writeln(`Configuration for Azure DNS.`) ew.writeln(`Code: 'azuredns'`) ew.writeln(`Since: 'v4.13.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AZURE_CLIENT_CERTIFICATE_PATH": Client certificate path`) ew.writeln(` - "AZURE_CLIENT_ID": Client ID`) ew.writeln(` - "AZURE_CLIENT_SECRET": Client secret`) ew.writeln(` - "AZURE_TENANT_ID": Tenant ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AZURE_AUTH_METHOD": Specify which authentication method to use`) ew.writeln(` - "AZURE_AUTH_MSI_TIMEOUT": Managed Identity timeout duration`) ew.writeln(` - "AZURE_ENVIRONMENT": Azure environment, one of: public, usgovernment, and china`) ew.writeln(` - "AZURE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "AZURE_PRIVATE_ZONE": Set to true to use Azure Private DNS Zones and not public`) ew.writeln(` - "AZURE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "AZURE_RESOURCE_GROUP": DNS zone resource group`) ew.writeln(` - "AZURE_SERVICEDISCOVERY_FILTER": Advanced ServiceDiscovery filter using Kusto query condition`) ew.writeln(` - "AZURE_SUBSCRIPTION_ID": DNS zone subscription ID`) ew.writeln(` - "AZURE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln(` - "AZURE_ZONE_NAME": Zone name to use inside Azure DNS service to add the TXT record in`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/azuredns`) case "baiducloud": // generated from: providers/dns/baiducloud/baiducloud.toml ew.writeln(`Configuration for Baidu Cloud.`) ew.writeln(`Code: 'baiducloud'`) ew.writeln(`Since: 'v4.23.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BAIDUCLOUD_ACCESS_KEY_ID": Access key`) ew.writeln(` - "BAIDUCLOUD_SECRET_ACCESS_KEY": Secret access key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BAIDUCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "BAIDUCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "BAIDUCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/baiducloud`) case "beget": // generated from: providers/dns/beget/beget.toml ew.writeln(`Configuration for Beget.com.`) ew.writeln(`Code: 'beget'`) ew.writeln(`Since: 'v4.27.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BEGET_PASSWORD": API password`) ew.writeln(` - "BEGET_USERNAME": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BEGET_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "BEGET_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) ew.writeln(` - "BEGET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "BEGET_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/beget`) case "binarylane": // generated from: providers/dns/binarylane/binarylane.toml ew.writeln(`Configuration for Binary Lane.`) ew.writeln(`Code: 'binarylane'`) ew.writeln(`Since: 'v4.26.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BINARYLANE_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BINARYLANE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "BINARYLANE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "BINARYLANE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "BINARYLANE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/binarylane`) case "bindman": // generated from: providers/dns/bindman/bindman.toml ew.writeln(`Configuration for Bindman.`) ew.writeln(`Code: 'bindman'`) ew.writeln(`Since: 'v2.6.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BINDMAN_MANAGER_ADDRESS": The server URL, should have scheme, hostname, and port (if required) of the Bindman-DNS Manager server`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BINDMAN_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) ew.writeln(` - "BINDMAN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "BINDMAN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bindman`) case "bluecat": // generated from: providers/dns/bluecat/bluecat.toml ew.writeln(`Configuration for Bluecat.`) ew.writeln(`Code: 'bluecat'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BLUECAT_CONFIG_NAME": Configuration name`) ew.writeln(` - "BLUECAT_DNS_VIEW": External DNS View Name`) ew.writeln(` - "BLUECAT_PASSWORD": API password`) ew.writeln(` - "BLUECAT_SERVER_URL": The server URL, should have scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve`) ew.writeln(` - "BLUECAT_USER_NAME": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BLUECAT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "BLUECAT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "BLUECAT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "BLUECAT_SKIP_DEPLOY": Skip deployements`) ew.writeln(` - "BLUECAT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecat`) case "bluecatv2": // generated from: providers/dns/bluecatv2/bluecatv2.toml ew.writeln(`Configuration for Bluecat v2.`) ew.writeln(`Code: 'bluecatv2'`) ew.writeln(`Since: 'v4.32.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BLUECATV2_CONFIG_NAME": Configuration name`) ew.writeln(` - "BLUECATV2_PASSWORD": API password`) ew.writeln(` - "BLUECATV2_USERNAME": API username`) ew.writeln(` - "BLUECATV2_VIEW_NAME": DNS View Name`) ew.writeln(` - "BLUECAT_SERVER_URL": The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BLUECATV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "BLUECATV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "BLUECATV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "BLUECATV2_SKIP_DEPLOY": Skip quick deployements`) ew.writeln(` - "BLUECATV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecatv2`) case "bookmyname": // generated from: providers/dns/bookmyname/bookmyname.toml ew.writeln(`Configuration for BookMyName.`) ew.writeln(`Code: 'bookmyname'`) ew.writeln(`Since: 'v4.23.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BOOKMYNAME_PASSWORD": Password`) ew.writeln(` - "BOOKMYNAME_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BOOKMYNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "BOOKMYNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "BOOKMYNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "BOOKMYNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bookmyname`) case "brandit": // generated from: providers/dns/brandit/brandit.toml ew.writeln(`Configuration for Brandit (deprecated).`) ew.writeln(`Code: 'brandit'`) ew.writeln(`Since: 'v4.11.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BRANDIT_API_KEY": The API key`) ew.writeln(` - "BRANDIT_API_USERNAME": The API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BRANDIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "BRANDIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "BRANDIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) ew.writeln(` - "BRANDIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/brandit`) case "bunny": // generated from: providers/dns/bunny/bunny.toml ew.writeln(`Configuration for Bunny.`) ew.writeln(`Code: 'bunny'`) ew.writeln(`Since: 'v4.11.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "BUNNY_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "BUNNY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "BUNNY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "BUNNY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "BUNNY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bunny`) case "checkdomain": // generated from: providers/dns/checkdomain/checkdomain.toml ew.writeln(`Configuration for Checkdomain.`) ew.writeln(`Code: 'checkdomain'`) ew.writeln(`Since: 'v3.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CHECKDOMAIN_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CHECKDOMAIN_ENDPOINT": API endpoint URL, defaults to https://api.checkdomain.de`) ew.writeln(` - "CHECKDOMAIN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CHECKDOMAIN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 300)`) ew.writeln(` - "CHECKDOMAIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 7)`) ew.writeln(` - "CHECKDOMAIN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/checkdomain`) case "civo": // generated from: providers/dns/civo/civo.toml ew.writeln(`Configuration for Civo.`) ew.writeln(`Code: 'civo'`) ew.writeln(`Since: 'v4.9.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CIVO_TOKEN": Authentication token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CIVO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) ew.writeln(` - "CIVO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "CIVO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/civo`) case "clouddns": // generated from: providers/dns/clouddns/clouddns.toml ew.writeln(`Configuration for CloudDNS.`) ew.writeln(`Code: 'clouddns'`) ew.writeln(`Since: 'v3.6.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CLOUDDNS_CLIENT_ID": Client ID`) ew.writeln(` - "CLOUDDNS_EMAIL": Account email`) ew.writeln(` - "CLOUDDNS_PASSWORD": Account password`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CLOUDDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CLOUDDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "CLOUDDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "CLOUDDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/clouddns`) case "cloudflare": // generated from: providers/dns/cloudflare/cloudflare.toml ew.writeln(`Configuration for Cloudflare.`) ew.writeln(`Code: 'cloudflare'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CF_API_EMAIL": Account email`) ew.writeln(` - "CF_API_KEY": API key`) ew.writeln(` - "CF_DNS_API_TOKEN": API token with DNS:Edit permission (since v3.1.0)`) ew.writeln(` - "CF_ZONE_API_TOKEN": API token with Zone:Read permission (since v3.1.0)`) ew.writeln(` - "CLOUDFLARE_API_KEY": Alias to CF_API_KEY`) ew.writeln(` - "CLOUDFLARE_DNS_API_TOKEN": Alias to CF_DNS_API_TOKEN`) ew.writeln(` - "CLOUDFLARE_EMAIL": Alias to CF_API_EMAIL`) ew.writeln(` - "CLOUDFLARE_ZONE_API_TOKEN": Alias to CF_ZONE_API_TOKEN`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CLOUDFLARE_BASE_URL": API base URL (Default: https://api.cloudflare.com/client/v4)`) ew.writeln(` - "CLOUDFLARE_HTTP_TIMEOUT": API request timeout in seconds (Default: )`) ew.writeln(` - "CLOUDFLARE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "CLOUDFLARE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "CLOUDFLARE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudflare`) case "cloudns": // generated from: providers/dns/cloudns/cloudns.toml ew.writeln(`Configuration for ClouDNS.`) ew.writeln(`Code: 'cloudns'`) ew.writeln(`Since: 'v2.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CLOUDNS_AUTH_ID": The API user ID`) ew.writeln(` - "CLOUDNS_AUTH_PASSWORD": The password for API user ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CLOUDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CLOUDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "CLOUDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`) ew.writeln(` - "CLOUDNS_SUB_AUTH_ID": The API sub user ID`) ew.writeln(` - "CLOUDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudns`) case "cloudru": // generated from: providers/dns/cloudru/cloudru.toml ew.writeln(`Configuration for Cloud.ru.`) ew.writeln(`Code: 'cloudru'`) ew.writeln(`Since: 'v4.14.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CLOUDRU_KEY_ID": Key ID (login)`) ew.writeln(` - "CLOUDRU_SECRET": Key Secret`) ew.writeln(` - "CLOUDRU_SERVICE_INSTANCE_ID": Service Instance ID (parentId)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CLOUDRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CLOUDRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "CLOUDRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "CLOUDRU_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`) ew.writeln(` - "CLOUDRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudru`) case "cloudxns": // generated from: providers/dns/cloudxns/cloudxns.toml ew.writeln(`Configuration for CloudXNS (Deprecated).`) ew.writeln(`Code: 'cloudxns'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CLOUDXNS_API_KEY": The API key`) ew.writeln(` - "CLOUDXNS_SECRET_KEY": The API secret key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CLOUDXNS_HTTP_TIMEOUT": API request timeout in seconds (Default: )`) ew.writeln(` - "CLOUDXNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: )`) ew.writeln(` - "CLOUDXNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: )`) ew.writeln(` - "CLOUDXNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: )`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cloudxns`) case "com35": // generated from: providers/dns/com35/com35.toml ew.writeln(`Configuration for 35.com/三五互联.`) ew.writeln(`Code: 'com35'`) ew.writeln(`Since: 'v4.31.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "COM35_PASSWORD": API password`) ew.writeln(` - "COM35_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "COM35_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "COM35_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "COM35_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "COM35_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/com35`) case "conoha": // generated from: providers/dns/conoha/conoha.toml ew.writeln(`Configuration for ConoHa v2.`) ew.writeln(`Code: 'conoha'`) ew.writeln(`Since: 'v1.2.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CONOHA_API_PASSWORD": The API password`) ew.writeln(` - "CONOHA_API_USERNAME": The API username`) ew.writeln(` - "CONOHA_TENANT_ID": Tenant ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CONOHA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CONOHA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "CONOHA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "CONOHA_REGION": The region (Default: tyo1)`) ew.writeln(` - "CONOHA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/conoha`) case "conohav3": // generated from: providers/dns/conohav3/conohav3.toml ew.writeln(`Configuration for ConoHa v3.`) ew.writeln(`Code: 'conohav3'`) ew.writeln(`Since: 'v4.24.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CONOHAV3_API_PASSWORD": The API password`) ew.writeln(` - "CONOHAV3_API_USER_ID": The API user ID`) ew.writeln(` - "CONOHAV3_TENANT_ID": Tenant ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CONOHAV3_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CONOHAV3_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "CONOHAV3_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "CONOHAV3_REGION": The region (Default: c3j1)`) ew.writeln(` - "CONOHAV3_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/conohav3`) case "constellix": // generated from: providers/dns/constellix/constellix.toml ew.writeln(`Configuration for Constellix.`) ew.writeln(`Code: 'constellix'`) ew.writeln(`Since: 'v3.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CONSTELLIX_API_KEY": User API key`) ew.writeln(` - "CONSTELLIX_SECRET_KEY": User secret key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CONSTELLIX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CONSTELLIX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "CONSTELLIX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "CONSTELLIX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/constellix`) case "corenetworks": // generated from: providers/dns/corenetworks/corenetworks.toml ew.writeln(`Configuration for Core-Networks.`) ew.writeln(`Code: 'corenetworks'`) ew.writeln(`Since: 'v4.20.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CORENETWORKS_LOGIN": The username of the API account`) ew.writeln(` - "CORENETWORKS_PASSWORD": The password`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CORENETWORKS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CORENETWORKS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "CORENETWORKS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "CORENETWORKS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "CORENETWORKS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/corenetworks`) case "cpanel": // generated from: providers/dns/cpanel/cpanel.toml ew.writeln(`Configuration for CPanel/WHM.`) ew.writeln(`Code: 'cpanel'`) ew.writeln(`Since: 'v4.16.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CPANEL_BASE_URL": API server URL`) ew.writeln(` - "CPANEL_TOKEN": API token`) ew.writeln(` - "CPANEL_USERNAME": username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CPANEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CPANEL_MODE": use cpanel API or WHM API (Default: cpanel)`) ew.writeln(` - "CPANEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "CPANEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "CPANEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/cpanel`) case "czechia": // generated from: providers/dns/czechia/czechia.toml ew.writeln(`Configuration for Czechia.`) ew.writeln(`Code: 'czechia'`) ew.writeln(`Since: 'v4.33.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "CZECHIA_TOKEN": Authorization token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "CZECHIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "CZECHIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "CZECHIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "CZECHIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/czechia`) case "ddnss": // generated from: providers/dns/ddnss/ddnss.toml ew.writeln(`Configuration for DDnss (DynDNS Service).`) ew.writeln(`Code: 'ddnss'`) ew.writeln(`Since: 'v4.32.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DDNSS_KEY": Update key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DDNSS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DDNSS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "DDNSS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DDNSS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "DDNSS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ddnss`) case "derak": // generated from: providers/dns/derak/derak.toml ew.writeln(`Configuration for Derak Cloud.`) ew.writeln(`Code: 'derak'`) ew.writeln(`Since: 'v4.12.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DERAK_API_KEY": The API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DERAK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DERAK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "DERAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "DERAK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "DERAK_WEBSITE_ID": Force the zone/website ID`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/derak`) case "desec": // generated from: providers/dns/desec/desec.toml ew.writeln(`Configuration for deSEC.io.`) ew.writeln(`Code: 'desec'`) ew.writeln(`Since: 'v3.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DESEC_TOKEN": Domain token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DESEC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DESEC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) ew.writeln(` - "DESEC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "DESEC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/desec`) case "designate": // generated from: providers/dns/designate/designate.toml ew.writeln(`Configuration for Designate DNSaaS for Openstack.`) ew.writeln(`Code: 'designate'`) ew.writeln(`Since: 'v2.2.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "OS_APPLICATION_CREDENTIAL_ID": Application credential ID`) ew.writeln(` - "OS_APPLICATION_CREDENTIAL_NAME": Application credential name`) ew.writeln(` - "OS_APPLICATION_CREDENTIAL_SECRET": Application credential secret`) ew.writeln(` - "OS_AUTH_URL": Identity endpoint URL`) ew.writeln(` - "OS_PASSWORD": Password`) ew.writeln(` - "OS_PROJECT_NAME": Project name`) ew.writeln(` - "OS_REGION_NAME": Region name`) ew.writeln(` - "OS_USERNAME": Username`) ew.writeln(` - "OS_USER_ID": User ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DESIGNATE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "DESIGNATE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) ew.writeln(` - "DESIGNATE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`) ew.writeln(` - "DESIGNATE_ZONE_NAME": The zone name to use in the OpenStack Project to manage TXT records.`) ew.writeln(` - "OS_PROJECT_ID": Project ID`) ew.writeln(` - "OS_TENANT_NAME": Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/designate`) case "digitalocean": // generated from: providers/dns/digitalocean/digitalocean.toml ew.writeln(`Configuration for Digital Ocean.`) ew.writeln(`Code: 'digitalocean'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DO_AUTH_TOKEN": Authentication token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DO_API_URL": The URL of the API`) ew.writeln(` - "DO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "DO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/digitalocean`) case "directadmin": // generated from: providers/dns/directadmin/directadmin.toml ew.writeln(`Configuration for DirectAdmin.`) ew.writeln(`Code: 'directadmin'`) ew.writeln(`Since: 'v4.18.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DIRECTADMIN_API_URL": URL of the API`) ew.writeln(` - "DIRECTADMIN_PASSWORD": API password`) ew.writeln(` - "DIRECTADMIN_USERNAME": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DIRECTADMIN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DIRECTADMIN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "DIRECTADMIN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DIRECTADMIN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`) ew.writeln(` - "DIRECTADMIN_ZONE_NAME": Zone name used to add the TXT record`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/directadmin`) case "dnsexit": // generated from: providers/dns/dnsexit/dnsexit.toml ew.writeln(`Configuration for DNSExit.`) ew.writeln(`Code: 'dnsexit'`) ew.writeln(`Since: 'v4.32.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DNSEXIT_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DNSEXIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DNSEXIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "DNSEXIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "DNSEXIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsexit`) case "dnshomede": // generated from: providers/dns/dnshomede/dnshomede.toml ew.writeln(`Configuration for dnsHome.de.`) ew.writeln(`Code: 'dnshomede'`) ew.writeln(`Since: 'v4.10.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DNSHOMEDE_CREDENTIALS": Comma-separated list of domain:password credential pairs`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DNSHOMEDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DNSHOMEDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 1200)`) ew.writeln(` - "DNSHOMEDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2)`) ew.writeln(` - "DNSHOMEDE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnshomede`) case "dnsimple": // generated from: providers/dns/dnsimple/dnsimple.toml ew.writeln(`Configuration for DNSimple.`) ew.writeln(`Code: 'dnsimple'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DNSIMPLE_OAUTH_TOKEN": OAuth token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DNSIMPLE_BASE_URL": API endpoint URL`) ew.writeln(` - "DNSIMPLE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "DNSIMPLE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DNSIMPLE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsimple`) case "dnsmadeeasy": // generated from: providers/dns/dnsmadeeasy/dnsmadeeasy.toml ew.writeln(`Configuration for DNS Made Easy.`) ew.writeln(`Code: 'dnsmadeeasy'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DNSMADEEASY_API_KEY": The API key`) ew.writeln(` - "DNSMADEEASY_API_SECRET": The API Secret key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DNSMADEEASY_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "DNSMADEEASY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "DNSMADEEASY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DNSMADEEASY_SANDBOX": Activate the sandbox (boolean)`) ew.writeln(` - "DNSMADEEASY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnsmadeeasy`) case "dnspod": // generated from: providers/dns/dnspod/dnspod.toml ew.writeln(`Configuration for DNSPod (deprecated).`) ew.writeln(`Code: 'dnspod'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DNSPOD_API_KEY": The user token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DNSPOD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DNSPOD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "DNSPOD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DNSPOD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dnspod`) case "dode": // generated from: providers/dns/dode/dode.toml ew.writeln(`Configuration for Domain Offensive (do.de).`) ew.writeln(`Code: 'dode'`) ew.writeln(`Since: 'v2.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DODE_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DODE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DODE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "DODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DODE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dode`) case "domeneshop": // generated from: providers/dns/domeneshop/domeneshop.toml ew.writeln(`Configuration for Domeneshop.`) ew.writeln(`Code: 'domeneshop'`) ew.writeln(`Since: 'v4.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DOMENESHOP_API_SECRET": API secret`) ew.writeln(` - "DOMENESHOP_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DOMENESHOP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DOMENESHOP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) ew.writeln(` - "DOMENESHOP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/domeneshop`) case "dreamhost": // generated from: providers/dns/dreamhost/dreamhost.toml ew.writeln(`Configuration for DreamHost.`) ew.writeln(`Code: 'dreamhost'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DREAMHOST_API_KEY": The API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DREAMHOST_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DREAMHOST_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) ew.writeln(` - "DREAMHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dreamhost`) case "duckdns": // generated from: providers/dns/duckdns/duckdns.toml ew.writeln(`Configuration for Duck DNS.`) ew.writeln(`Code: 'duckdns'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DUCKDNS_TOKEN": Account token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DUCKDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DUCKDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "DUCKDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DUCKDNS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/duckdns`) case "dyn": // generated from: providers/dns/dyn/dyn.toml ew.writeln(`Configuration for Dyn.`) ew.writeln(`Code: 'dyn'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DYN_CUSTOMER_NAME": Customer name`) ew.writeln(` - "DYN_PASSWORD": Password`) ew.writeln(` - "DYN_USER_NAME": User name`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DYN_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "DYN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "DYN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "DYN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dyn`) case "dyndnsfree": // generated from: providers/dns/dyndnsfree/dyndnsfree.toml ew.writeln(`Configuration for DynDnsFree.de.`) ew.writeln(`Code: 'dyndnsfree'`) ew.writeln(`Since: 'v4.23.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DYNDNSFREE_PASSWORD": Password`) ew.writeln(` - "DYNDNSFREE_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DYNDNSFREE_HTTP_TIMEOUT": Request timeout in seconds (Default: 30)`) ew.writeln(` - "DYNDNSFREE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "DYNDNSFREE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dyndnsfree`) case "dynu": // generated from: providers/dns/dynu/dynu.toml ew.writeln(`Configuration for Dynu.`) ew.writeln(`Code: 'dynu'`) ew.writeln(`Since: 'v3.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "DYNU_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "DYNU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "DYNU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "DYNU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`) ew.writeln(` - "DYNU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/dynu`) case "easydns": // generated from: providers/dns/easydns/easydns.toml ew.writeln(`Configuration for EasyDNS.`) ew.writeln(`Code: 'easydns'`) ew.writeln(`Since: 'v2.6.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "EASYDNS_KEY": API Key`) ew.writeln(` - "EASYDNS_TOKEN": API Token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "EASYDNS_ENDPOINT": The endpoint URL of the API Server`) ew.writeln(` - "EASYDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "EASYDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "EASYDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "EASYDNS_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "EASYDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/easydns`) case "edgecenter": // generated from: providers/dns/edgecenter/edgecenter.toml ew.writeln(`Configuration for EdgeCenter.`) ew.writeln(`Code: 'edgecenter'`) ew.writeln(`Since: 'v4.29.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "EDGECENTER_PERMANENT_API_TOKEN": Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "EDGECENTER_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "EDGECENTER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) ew.writeln(` - "EDGECENTER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) ew.writeln(` - "EDGECENTER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgecenter`) case "edgedns": // generated from: providers/dns/edgedns/edgedns.toml ew.writeln(`Configuration for Akamai EdgeDNS.`) ew.writeln(`Code: 'edgedns'`) ew.writeln(`Since: 'v3.9.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AKAMAI_ACCESS_TOKEN": Access token, managed by the Akamai EdgeGrid client`) ew.writeln(` - "AKAMAI_CLIENT_SECRET": Client secret, managed by the Akamai EdgeGrid client`) ew.writeln(` - "AKAMAI_CLIENT_TOKEN": Client token, managed by the Akamai EdgeGrid client`) ew.writeln(` - "AKAMAI_EDGERC": Path to the .edgerc file, managed by the Akamai EdgeGrid client`) ew.writeln(` - "AKAMAI_EDGERC_SECTION": Configuration section, managed by the Akamai EdgeGrid client`) ew.writeln(` - "AKAMAI_HOST": API host, managed by the Akamai EdgeGrid client`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AKAMAI_ACCOUNT_SWITCH_KEY": Target account ID when the DNS zone and credentials belong to different accounts`) ew.writeln(` - "AKAMAI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`) ew.writeln(` - "AKAMAI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`) ew.writeln(` - "AKAMAI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgedns`) case "edgeone": // generated from: providers/dns/edgeone/edgeone.toml ew.writeln(`Configuration for Tencent EdgeOne.`) ew.writeln(`Code: 'edgeone'`) ew.writeln(`Since: 'v4.26.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "EDGEONE_SECRET_ID": Access key ID`) ew.writeln(` - "EDGEONE_SECRET_KEY": Access Key secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "EDGEONE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "EDGEONE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) ew.writeln(` - "EDGEONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`) ew.writeln(` - "EDGEONE_REGION": Region`) ew.writeln(` - "EDGEONE_SESSION_TOKEN": Access Key token`) ew.writeln(` - "EDGEONE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln(` - "EDGEONE_ZONES_MAPPING": Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2')`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/edgeone`) case "efficientip": // generated from: providers/dns/efficientip/efficientip.toml ew.writeln(`Configuration for Efficient IP.`) ew.writeln(`Code: 'efficientip'`) ew.writeln(`Since: 'v4.13.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "EFFICIENTIP_DNS_NAME": DNS name (ex: dns.smart)`) ew.writeln(` - "EFFICIENTIP_HOSTNAME": Hostname (ex: foo.example.com)`) ew.writeln(` - "EFFICIENTIP_PASSWORD": Password`) ew.writeln(` - "EFFICIENTIP_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "EFFICIENTIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "EFFICIENTIP_INSECURE_SKIP_VERIFY": Whether or not to verify EfficientIP API certificate`) ew.writeln(` - "EFFICIENTIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "EFFICIENTIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "EFFICIENTIP_VIEW_NAME": View name (ex: external)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/efficientip`) case "epik": // generated from: providers/dns/epik/epik.toml ew.writeln(`Configuration for Epik.`) ew.writeln(`Code: 'epik'`) ew.writeln(`Since: 'v4.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "EPIK_SIGNATURE": Epik API signature (https://registrar.epik.com/account/api-settings/)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "EPIK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "EPIK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "EPIK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "EPIK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/epik`) case "eurodns": // generated from: providers/dns/eurodns/eurodns.toml ew.writeln(`Configuration for EuroDNS.`) ew.writeln(`Code: 'eurodns'`) ew.writeln(`Since: 'v4.33.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "EURODNS_API_KEY": API key`) ew.writeln(` - "EURODNS_APP_ID": Application ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "EURODNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "EURODNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "EURODNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "EURODNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/eurodns`) case "excedo": // generated from: providers/dns/excedo/excedo.toml ew.writeln(`Configuration for Excedo.`) ew.writeln(`Code: 'excedo'`) ew.writeln(`Since: 'v4.33.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "EXCEDO_API_KEY": API key`) ew.writeln(` - "EXCEDO_API_URL": API base URL`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "EXCEDO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "EXCEDO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "EXCEDO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "EXCEDO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/excedo`) case "exec": // generated from: providers/dns/exec/exec.toml ew.writeln(`Configuration for External program.`) ew.writeln(`Code: 'exec'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/exec`) case "exoscale": // generated from: providers/dns/exoscale/exoscale.toml ew.writeln(`Configuration for Exoscale.`) ew.writeln(`Code: 'exoscale'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "EXOSCALE_API_KEY": API key`) ew.writeln(` - "EXOSCALE_API_SECRET": API secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "EXOSCALE_ENDPOINT": API endpoint URL`) ew.writeln(` - "EXOSCALE_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) ew.writeln(` - "EXOSCALE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "EXOSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "EXOSCALE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/exoscale`) case "f5xc": // generated from: providers/dns/f5xc/f5xc.toml ew.writeln(`Configuration for F5 XC.`) ew.writeln(`Code: 'f5xc'`) ew.writeln(`Since: 'v4.23.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "F5XC_API_TOKEN": API token`) ew.writeln(` - "F5XC_GROUP_NAME": Group name`) ew.writeln(` - "F5XC_TENANT_NAME": XC Tenant shortname`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "F5XC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "F5XC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "F5XC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "F5XC_SERVER": Server domain (Default: console.ves.volterra.io)`) ew.writeln(` - "F5XC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/f5xc`) case "freemyip": // generated from: providers/dns/freemyip/freemyip.toml ew.writeln(`Configuration for freemyip.com.`) ew.writeln(`Code: 'freemyip'`) ew.writeln(`Since: 'v4.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "FREEMYIP_TOKEN": Account token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "FREEMYIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "FREEMYIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "FREEMYIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "FREEMYIP_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "FREEMYIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/freemyip`) case "gandi": // generated from: providers/dns/gandi/gandi.toml ew.writeln(`Configuration for Gandi.`) ew.writeln(`Code: 'gandi'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "GANDI_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GANDI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) ew.writeln(` - "GANDI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) ew.writeln(` - "GANDI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2400)`) ew.writeln(` - "GANDI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gandi`) case "gandiv5": // generated from: providers/dns/gandiv5/gandiv5.toml ew.writeln(`Configuration for Gandi Live DNS (v5).`) ew.writeln(`Code: 'gandiv5'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "GANDIV5_API_KEY": API key (Deprecated)`) ew.writeln(` - "GANDIV5_PERSONAL_ACCESS_TOKEN": Personal Access Token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GANDIV5_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "GANDIV5_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) ew.writeln(` - "GANDIV5_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`) ew.writeln(` - "GANDIV5_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gandiv5`) case "gcloud": // generated from: providers/dns/gcloud/gcloud.toml ew.writeln(`Configuration for Google Cloud.`) ew.writeln(`Code: 'gcloud'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "Application Default Credentials": [Documentation](https://cloud.google.com/docs/authentication/production#providing_credentials_to_your_application)`) ew.writeln(` - "GCE_PROJECT": Project name (by default, the project name is auto-detected by using the metadata service)`) ew.writeln(` - "GCE_SERVICE_ACCOUNT": Account`) ew.writeln(` - "GCE_SERVICE_ACCOUNT_FILE": Account file path`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GCE_ALLOW_PRIVATE_ZONE": Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false)`) ew.writeln(` - "GCE_IMPERSONATE_SERVICE_ACCOUNT": Service account email to impersonate`) ew.writeln(` - "GCE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "GCE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 180)`) ew.writeln(` - "GCE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "GCE_ZONE_ID": Allows to skip the automatic detection of the zone`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gcloud`) case "gcore": // generated from: providers/dns/gcore/gcore.toml ew.writeln(`Configuration for G-Core.`) ew.writeln(`Code: 'gcore'`) ew.writeln(`Since: 'v4.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "GCORE_PERMANENT_API_TOKEN": Permanent API token (https://gcore.com/blog/permanent-api-token-explained/)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GCORE_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "GCORE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) ew.writeln(` - "GCORE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) ew.writeln(` - "GCORE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gcore`) case "gigahostno": // generated from: providers/dns/gigahostno/gigahostno.toml ew.writeln(`Configuration for Gigahost.no.`) ew.writeln(`Code: 'gigahostno'`) ew.writeln(`Since: 'v4.29.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "GIGAHOSTNO_PASSWORD": Password`) ew.writeln(` - "GIGAHOSTNO_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GIGAHOSTNO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "GIGAHOSTNO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "GIGAHOSTNO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "GIGAHOSTNO_SECRET": TOTP secret`) ew.writeln(` - "GIGAHOSTNO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gigahostno`) case "glesys": // generated from: providers/dns/glesys/glesys.toml ew.writeln(`Configuration for Glesys.`) ew.writeln(`Code: 'glesys'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "GLESYS_API_KEY": API key`) ew.writeln(` - "GLESYS_API_USER": API user`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GLESYS_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "GLESYS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) ew.writeln(` - "GLESYS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`) ew.writeln(` - "GLESYS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/glesys`) case "godaddy": // generated from: providers/dns/godaddy/godaddy.toml ew.writeln(`Configuration for Go Daddy.`) ew.writeln(`Code: 'godaddy'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "GODADDY_API_KEY": API key`) ew.writeln(` - "GODADDY_API_SECRET": API secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GODADDY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "GODADDY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "GODADDY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "GODADDY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/godaddy`) case "googledomains": // generated from: providers/dns/googledomains/googledomains.toml ew.writeln(`Configuration for Google Domains.`) ew.writeln(`Code: 'googledomains'`) ew.writeln(`Since: 'v4.11.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "GOOGLE_DOMAINS_ACCESS_TOKEN": Access token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GOOGLE_DOMAINS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "GOOGLE_DOMAINS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "GOOGLE_DOMAINS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/googledomains`) case "gravity": // generated from: providers/dns/gravity/gravity.toml ew.writeln(`Configuration for Gravity.`) ew.writeln(`Code: 'gravity'`) ew.writeln(`Since: 'v4.30.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "GRAVITY_PASSWORD": Password`) ew.writeln(` - "GRAVITY_SERVER_URL": URL of the server`) ew.writeln(` - "GRAVITY_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "GRAVITY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "GRAVITY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "GRAVITY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "GRAVITY_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 1)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gravity`) case "hetzner": // generated from: providers/dns/hetzner/hetzner.toml ew.writeln(`Configuration for Hetzner.`) ew.writeln(`Code: 'hetzner'`) ew.writeln(`Since: 'v3.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HETZNER_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HETZNER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HETZNER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HETZNER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "HETZNER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hetzner`) case "hostingde": // generated from: providers/dns/hostingde/hostingde.toml ew.writeln(`Configuration for Hosting.de.`) ew.writeln(`Code: 'hostingde'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HOSTINGDE_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HOSTINGDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HOSTINGDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HOSTINGDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "HOSTINGDE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "HOSTINGDE_ZONE_NAME": Zone name in ACE format`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostingde`) case "hostinger": // generated from: providers/dns/hostinger/hostinger.toml ew.writeln(`Configuration for Hostinger.`) ew.writeln(`Code: 'hostinger'`) ew.writeln(`Since: 'v4.27.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HOSTINGER_API_TOKEN": API Token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HOSTINGER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HOSTINGER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HOSTINGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "HOSTINGER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostinger`) case "hostingnl": // generated from: providers/dns/hostingnl/hostingnl.toml ew.writeln(`Configuration for Hosting.nl.`) ew.writeln(`Code: 'hostingnl'`) ew.writeln(`Since: 'v4.30.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HOSTINGNL_API_KEY": The API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HOSTINGNL_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "HOSTINGNL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HOSTINGNL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "HOSTINGNL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hostingnl`) case "hosttech": // generated from: providers/dns/hosttech/hosttech.toml ew.writeln(`Configuration for Hosttech.`) ew.writeln(`Code: 'hosttech'`) ew.writeln(`Since: 'v4.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HOSTTECH_API_KEY": API login`) ew.writeln(` - "HOSTTECH_PASSWORD": API password`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HOSTTECH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HOSTTECH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HOSTTECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "HOSTTECH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hosttech`) case "httpnet": // generated from: providers/dns/httpnet/httpnet.toml ew.writeln(`Configuration for http.net.`) ew.writeln(`Code: 'httpnet'`) ew.writeln(`Since: 'v4.15.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HTTPNET_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HTTPNET_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HTTPNET_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HTTPNET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "HTTPNET_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "HTTPNET_ZONE_NAME": Zone name in ACE format`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/httpnet`) case "httpreq": // generated from: providers/dns/httpreq/httpreq.toml ew.writeln(`Configuration for HTTP request.`) ew.writeln(`Code: 'httpreq'`) ew.writeln(`Since: 'v2.0.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HTTPREQ_ENDPOINT": The URL of the server`) ew.writeln(` - "HTTPREQ_MODE": 'RAW', none`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HTTPREQ_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HTTPREQ_PASSWORD": Basic authentication password`) ew.writeln(` - "HTTPREQ_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HTTPREQ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "HTTPREQ_USERNAME": Basic authentication username`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/httpreq`) case "huaweicloud": // generated from: providers/dns/huaweicloud/huaweicloud.toml ew.writeln(`Configuration for Huawei Cloud.`) ew.writeln(`Code: 'huaweicloud'`) ew.writeln(`Since: 'v4.19'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HUAWEICLOUD_ACCESS_KEY_ID": Access key ID`) ew.writeln(` - "HUAWEICLOUD_REGION": Region`) ew.writeln(` - "HUAWEICLOUD_SECRET_ACCESS_KEY": Access Key secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HUAWEICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HUAWEICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HUAWEICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "HUAWEICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/huaweicloud`) case "hurricane": // generated from: providers/dns/hurricane/hurricane.toml ew.writeln(`Configuration for Hurricane Electric DNS.`) ew.writeln(`Code: 'hurricane'`) ew.writeln(`Since: 'v4.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "HURRICANE_TOKENS": TXT record names and tokens`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "HURRICANE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "HURRICANE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "HURRICANE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation (Default: 300)`) ew.writeln(` - "HURRICANE_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hurricane`) case "hyperone": // generated from: providers/dns/hyperone/hyperone.toml ew.writeln(`Configuration for HyperOne.`) ew.writeln(`Code: 'hyperone'`) ew.writeln(`Since: 'v3.9.0'`) 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_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) 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 in seconds (Default: 60)`) ew.writeln(` - "HYPERONE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 2)`) ew.writeln(` - "HYPERONE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/hyperone`) case "ibmcloud": // generated from: providers/dns/ibmcloud/ibmcloud.toml ew.writeln(`Configuration for IBM Cloud (SoftLayer).`) ew.writeln(`Code: 'ibmcloud'`) ew.writeln(`Since: 'v4.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SOFTLAYER_API_KEY": Classic Infrastructure API key`) ew.writeln(` - "SOFTLAYER_USERNAME": Username (IBM Cloud is {accountID}_{emailAddress})`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SOFTLAYER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "SOFTLAYER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "SOFTLAYER_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SOFTLAYER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ibmcloud`) case "iij": // generated from: providers/dns/iij/iij.toml ew.writeln(`Configuration for Internet Initiative Japan.`) ew.writeln(`Code: 'iij'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "IIJ_API_ACCESS_KEY": API access key`) ew.writeln(` - "IIJ_API_SECRET_KEY": API secret key`) ew.writeln(` - "IIJ_DO_SERVICE_CODE": DO service code`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "IIJ_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) ew.writeln(` - "IIJ_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`) ew.writeln(` - "IIJ_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/iij`) case "iijdpf": // generated from: providers/dns/iijdpf/iijdpf.toml ew.writeln(`Configuration for IIJ DNS Platform Service.`) ew.writeln(`Code: 'iijdpf'`) ew.writeln(`Since: 'v4.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "IIJ_DPF_API_TOKEN": API token`) ew.writeln(` - "IIJ_DPF_DPM_SERVICE_CODE": IIJ Managed DNS Service's service code`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "IIJ_DPF_API_ENDPOINT": API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1`) ew.writeln(` - "IIJ_DPF_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "IIJ_DPF_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 660)`) ew.writeln(` - "IIJ_DPF_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/iijdpf`) case "infoblox": // generated from: providers/dns/infoblox/infoblox.toml ew.writeln(`Configuration for Infoblox.`) ew.writeln(`Code: 'infoblox'`) ew.writeln(`Since: 'v4.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "INFOBLOX_HOST": Host URI`) ew.writeln(` - "INFOBLOX_PASSWORD": Account Password`) ew.writeln(` - "INFOBLOX_USERNAME": Account Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "INFOBLOX_CA_CERTIFICATE": The path to the CA certificate (PEM encoded)`) ew.writeln(` - "INFOBLOX_DNS_VIEW": The view for the TXT records (Default: External)`) ew.writeln(` - "INFOBLOX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "INFOBLOX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "INFOBLOX_PORT": The port for the infoblox grid manager (Default: 443)`) ew.writeln(` - "INFOBLOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "INFOBLOX_SSL_VERIFY": Whether or not to verify the TLS certificate (Default: true)`) ew.writeln(` - "INFOBLOX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "INFOBLOX_WAPI_VERSION": The version of WAPI being used (Default: 2.11)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/infoblox`) case "infomaniak": // generated from: providers/dns/infomaniak/infomaniak.toml ew.writeln(`Configuration for Infomaniak.`) ew.writeln(`Code: 'infomaniak'`) ew.writeln(`Since: 'v4.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "INFOMANIAK_ACCESS_TOKEN": Access token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "INFOMANIAK_ENDPOINT": https://api.infomaniak.com`) ew.writeln(` - "INFOMANIAK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "INFOMANIAK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "INFOMANIAK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "INFOMANIAK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/infomaniak`) case "internetbs": // generated from: providers/dns/internetbs/internetbs.toml ew.writeln(`Configuration for Internet.bs.`) ew.writeln(`Code: 'internetbs'`) ew.writeln(`Since: 'v4.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "INTERNET_BS_API_KEY": API key`) ew.writeln(` - "INTERNET_BS_PASSWORD": API password`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "INTERNET_BS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "INTERNET_BS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "INTERNET_BS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "INTERNET_BS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/internetbs`) case "inwx": // generated from: providers/dns/inwx/inwx.toml ew.writeln(`Configuration for INWX.`) ew.writeln(`Code: 'inwx'`) ew.writeln(`Since: 'v2.0.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "INWX_PASSWORD": Password`) ew.writeln(` - "INWX_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "INWX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "INWX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) ew.writeln(` - "INWX_SANDBOX": Activate the sandbox (boolean)`) ew.writeln(` - "INWX_SHARED_SECRET": shared secret related to 2FA`) ew.writeln(` - "INWX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/inwx`) case "ionos": // generated from: providers/dns/ionos/ionos.toml ew.writeln(`Configuration for Ionos.`) ew.writeln(`Code: 'ionos'`) ew.writeln(`Since: 'v4.2.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "IONOS_API_KEY": API key '.' https://developer.hosting.ionos.com/docs/getstarted`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "IONOS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "IONOS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "IONOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`) ew.writeln(` - "IONOS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ionos`) case "ionoscloud": // generated from: providers/dns/ionoscloud/ionoscloud.toml ew.writeln(`Configuration for Ionos Cloud.`) ew.writeln(`Code: 'ionoscloud'`) ew.writeln(`Since: 'v4.30.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "IONOSCLOUD_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "IONOSCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "IONOSCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "IONOSCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "IONOSCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ionoscloud`) case "ipv64": // generated from: providers/dns/ipv64/ipv64.toml ew.writeln(`Configuration for IPv64.`) ew.writeln(`Code: 'ipv64'`) ew.writeln(`Since: 'v4.13.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "IPV64_API_KEY": Account API Key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "IPV64_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "IPV64_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "IPV64_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ipv64`) case "ispconfig": // generated from: providers/dns/ispconfig/ispconfig.toml ew.writeln(`Configuration for ISPConfig 3.`) ew.writeln(`Code: 'ispconfig'`) ew.writeln(`Since: 'v4.31.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ISPCONFIG_PASSWORD": Password`) ew.writeln(` - "ISPCONFIG_SERVER_URL": Server URL`) ew.writeln(` - "ISPCONFIG_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ISPCONFIG_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ISPCONFIG_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`) ew.writeln(` - "ISPCONFIG_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ISPCONFIG_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "ISPCONFIG_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ispconfig`) case "ispconfigddns": // generated from: providers/dns/ispconfigddns/ispconfigddns.toml ew.writeln(`Configuration for ISPConfig 3 - Dynamic DNS (DDNS) Module.`) ew.writeln(`Code: 'ispconfigddns'`) ew.writeln(`Since: 'v4.31.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ISPCONFIG_DDNS_SERVER_URL": API server URL (ex: https://panel.example.com:8080)`) ew.writeln(` - "ISPCONFIG_DDNS_TOKEN": DDNS API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ISPCONFIG_DDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ISPCONFIG_DDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ISPCONFIG_DDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "ISPCONFIG_DDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ispconfigddns`) case "iwantmyname": // generated from: providers/dns/iwantmyname/iwantmyname.toml ew.writeln(`Configuration for iwantmyname (Deprecated).`) ew.writeln(`Code: 'iwantmyname'`) ew.writeln(`Since: 'v4.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "IWANTMYNAME_PASSWORD": API password`) ew.writeln(` - "IWANTMYNAME_USERNAME": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "IWANTMYNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "IWANTMYNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "IWANTMYNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "IWANTMYNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/iwantmyname`) case "jdcloud": // generated from: providers/dns/jdcloud/jdcloud.toml ew.writeln(`Configuration for JD Cloud.`) ew.writeln(`Code: 'jdcloud'`) ew.writeln(`Since: 'v4.31.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "JDCLOUD_ACCESS_KEY_ID": Access key ID`) ew.writeln(` - "JDCLOUD_ACCESS_KEY_SECRET": Access key secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "JDCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "JDCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "JDCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "JDCLOUD_REGION_ID": Region ID (Default: cn-north-1)`) ew.writeln(` - "JDCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/jdcloud`) case "joker": // generated from: providers/dns/joker/joker.toml ew.writeln(`Configuration for Joker.`) ew.writeln(`Code: 'joker'`) ew.writeln(`Since: 'v2.6.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "JOKER_API_KEY": API key (only with DMAPI mode)`) ew.writeln(` - "JOKER_API_MODE": 'DMAPI' or 'SVC'. DMAPI is for resellers accounts. (Default: DMAPI)`) ew.writeln(` - "JOKER_PASSWORD": Joker.com password`) ew.writeln(` - "JOKER_USERNAME": Joker.com username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "JOKER_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) ew.writeln(` - "JOKER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "JOKER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "JOKER_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60), only with 'SVC' mode`) ew.writeln(` - "JOKER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/joker`) case "keyhelp": // generated from: providers/dns/keyhelp/keyhelp.toml ew.writeln(`Configuration for KeyHelp.`) ew.writeln(`Code: 'keyhelp'`) ew.writeln(`Since: 'v4.26.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "KEYHELP_API_KEY": API key`) ew.writeln(` - "KEYHELP_BASE_URL": Server URL`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "KEYHELP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "KEYHELP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "KEYHELP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "KEYHELP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/keyhelp`) case "leaseweb": // generated from: providers/dns/leaseweb/leaseweb.toml ew.writeln(`Configuration for Leaseweb.`) ew.writeln(`Code: 'leaseweb'`) ew.writeln(`Since: 'v4.32.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "LEASEWEB_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "LEASEWEB_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "LEASEWEB_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "LEASEWEB_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "LEASEWEB_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/leaseweb`) case "liara": // generated from: providers/dns/liara/liara.toml ew.writeln(`Configuration for Liara.`) ew.writeln(`Code: 'liara'`) ew.writeln(`Since: 'v4.10.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "LIARA_API_KEY": The API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "LIARA_TEAM_ID": The team ID to access services in a team`) ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/liara`) case "lightsail": // generated from: providers/dns/lightsail/lightsail.toml ew.writeln(`Configuration for Amazon Lightsail.`) ew.writeln(`Code: 'lightsail'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AWS_ACCESS_KEY_ID": Managed by the AWS client. Access key ID ('AWS_ACCESS_KEY_ID_FILE' is not supported, use 'AWS_SHARED_CREDENTIALS_FILE' instead)`) ew.writeln(` - "AWS_SECRET_ACCESS_KEY": Managed by the AWS client. Secret access key ('AWS_SECRET_ACCESS_KEY_FILE' is not supported, use 'AWS_SHARED_CREDENTIALS_FILE' instead)`) ew.writeln(` - "DNS_ZONE": Domain name of the DNS zone`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AWS_SHARED_CREDENTIALS_FILE": Managed by the AWS client. Shared credentials file.`) ew.writeln(` - "LIGHTSAIL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "LIGHTSAIL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/lightsail`) case "limacity": // generated from: providers/dns/limacity/limacity.toml ew.writeln(`Configuration for Lima-City.`) ew.writeln(`Code: 'limacity'`) ew.writeln(`Since: 'v4.18.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "LIMACITY_API_KEY": The API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "LIMACITY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "LIMACITY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 80)`) ew.writeln(` - "LIMACITY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 480)`) ew.writeln(` - "LIMACITY_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 90)`) ew.writeln(` - "LIMACITY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/limacity`) case "linode": // generated from: providers/dns/linode/linode.toml ew.writeln(`Configuration for Linode (v4).`) ew.writeln(`Code: 'linode'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "LINODE_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "LINODE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "LINODE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`) ew.writeln(` - "LINODE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "LINODE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/linode`) case "liquidweb": // generated from: providers/dns/liquidweb/liquidweb.toml ew.writeln(`Configuration for Liquid Web.`) ew.writeln(`Code: 'liquidweb'`) ew.writeln(`Since: 'v3.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "LWAPI_PASSWORD": Liquid Web API Password`) ew.writeln(` - "LWAPI_USERNAME": Liquid Web API Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "LWAPI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) ew.writeln(` - "LWAPI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "LWAPI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "LWAPI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln(` - "LWAPI_URL": Liquid Web API endpoint`) ew.writeln(` - "LWAPI_ZONE": DNS Zone`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/liquidweb`) case "loopia": // generated from: providers/dns/loopia/loopia.toml ew.writeln(`Configuration for Loopia.`) ew.writeln(`Code: 'loopia'`) ew.writeln(`Since: 'v4.2.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "LOOPIA_API_PASSWORD": API password`) ew.writeln(` - "LOOPIA_API_USER": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "LOOPIA_API_URL": API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV`) ew.writeln(` - "LOOPIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) ew.writeln(` - "LOOPIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2400)`) ew.writeln(` - "LOOPIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "LOOPIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/loopia`) case "luadns": // generated from: providers/dns/luadns/luadns.toml ew.writeln(`Configuration for LuaDNS.`) ew.writeln(`Code: 'luadns'`) ew.writeln(`Since: 'v3.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "LUADNS_API_TOKEN": API token`) ew.writeln(` - "LUADNS_API_USERNAME": Username (your email)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "LUADNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "LUADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "LUADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "LUADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/luadns`) case "mailinabox": // generated from: providers/dns/mailinabox/mailinabox.toml ew.writeln(`Configuration for Mail-in-a-Box.`) ew.writeln(`Code: 'mailinabox'`) ew.writeln(`Since: 'v4.16.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "MAILINABOX_BASE_URL": Base API URL (ex: https://box.example.com)`) ew.writeln(` - "MAILINABOX_EMAIL": User email`) ew.writeln(` - "MAILINABOX_PASSWORD": User password`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "MAILINABOX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "MAILINABOX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) ew.writeln(` - "MAILINABOX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mailinabox`) case "manageengine": // generated from: providers/dns/manageengine/manageengine.toml ew.writeln(`Configuration for ManageEngine CloudDNS.`) ew.writeln(`Code: 'manageengine'`) ew.writeln(`Since: 'v4.21.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "MANAGEENGINE_CLIENT_ID": Client ID`) ew.writeln(` - "MANAGEENGINE_CLIENT_SECRET": Client Secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "MANAGEENGINE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "MANAGEENGINE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "MANAGEENGINE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/manageengine`) case "manual": // generated from: providers/dns/manual/manual.toml ew.writeln(`Configuration for Manual.`) ew.writeln(`Code: 'manual'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/manual`) case "metaname": // generated from: providers/dns/metaname/metaname.toml ew.writeln(`Configuration for Metaname.`) ew.writeln(`Code: 'metaname'`) ew.writeln(`Since: 'v4.13.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "METANAME_ACCOUNT_REFERENCE": The four-digit reference of a Metaname account`) ew.writeln(` - "METANAME_API_KEY": API Key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "METANAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "METANAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "METANAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/metaname`) case "metaregistrar": // generated from: providers/dns/metaregistrar/metaregistrar.toml ew.writeln(`Configuration for Metaregistrar.`) ew.writeln(`Code: 'metaregistrar'`) ew.writeln(`Since: 'v4.23.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "METAREGISTRAR_API_TOKEN": The API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "METAREGISTRAR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "METAREGISTRAR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "METAREGISTRAR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "METAREGISTRAR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/metaregistrar`) case "mijnhost": // generated from: providers/dns/mijnhost/mijnhost.toml ew.writeln(`Configuration for mijn.host.`) ew.writeln(`Code: 'mijnhost'`) ew.writeln(`Since: 'v4.18.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "MIJNHOST_API_KEY": The API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "MIJNHOST_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "MIJNHOST_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "MIJNHOST_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "MIJNHOST_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "MIJNHOST_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mijnhost`) case "mittwald": // generated from: providers/dns/mittwald/mittwald.toml ew.writeln(`Configuration for Mittwald.`) ew.writeln(`Code: 'mittwald'`) ew.writeln(`Since: 'v1.48.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "MITTWALD_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "MITTWALD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "MITTWALD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "MITTWALD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "MITTWALD_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 120)`) ew.writeln(` - "MITTWALD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mittwald`) case "myaddr": // generated from: providers/dns/myaddr/myaddr.toml ew.writeln(`Configuration for myaddr.{tools,dev,io}.`) ew.writeln(`Code: 'myaddr'`) ew.writeln(`Since: 'v4.22.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "MYADDR_PRIVATE_KEYS_MAPPING": Mapping between subdomains and private keys. The format is: ':,:,:'`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "MYADDR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "MYADDR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "MYADDR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "MYADDR_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 2)`) ew.writeln(` - "MYADDR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/myaddr`) case "mydnsjp": // generated from: providers/dns/mydnsjp/mydnsjp.toml ew.writeln(`Configuration for MyDNS.jp.`) ew.writeln(`Code: 'mydnsjp'`) ew.writeln(`Since: 'v1.2.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "MYDNSJP_MASTER_ID": Master ID`) ew.writeln(` - "MYDNSJP_PASSWORD": Password`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "MYDNSJP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "MYDNSJP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "MYDNSJP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mydnsjp`) case "mythicbeasts": // generated from: providers/dns/mythicbeasts/mythicbeasts.toml ew.writeln(`Configuration for MythicBeasts.`) ew.writeln(`Code: 'mythicbeasts'`) ew.writeln(`Since: 'v0.3.7'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "MYTHICBEASTS_PASSWORD": Password`) ew.writeln(` - "MYTHICBEASTS_USERNAME": User name`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "MYTHICBEASTS_API_ENDPOINT": The endpoint for the API (must implement v2)`) ew.writeln(` - "MYTHICBEASTS_AUTH_API_ENDPOINT": The endpoint for Mythic Beasts' Authentication`) ew.writeln(` - "MYTHICBEASTS_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "MYTHICBEASTS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "MYTHICBEASTS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "MYTHICBEASTS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/mythicbeasts`) case "namecheap": // generated from: providers/dns/namecheap/namecheap.toml ew.writeln(`Configuration for Namecheap.`) ew.writeln(`Code: 'namecheap'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NAMECHEAP_API_KEY": API key`) ew.writeln(` - "NAMECHEAP_API_USER": API user`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NAMECHEAP_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) ew.writeln(` - "NAMECHEAP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 15)`) ew.writeln(` - "NAMECHEAP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 3600)`) ew.writeln(` - "NAMECHEAP_SANDBOX": Activate the sandbox (boolean)`) ew.writeln(` - "NAMECHEAP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namecheap`) case "namedotcom": // generated from: providers/dns/namedotcom/namedotcom.toml ew.writeln(`Configuration for Name.com.`) ew.writeln(`Code: 'namedotcom'`) ew.writeln(`Since: 'v0.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NAMECOM_API_TOKEN": API token`) ew.writeln(` - "NAMECOM_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NAMECOM_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "NAMECOM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 20)`) ew.writeln(` - "NAMECOM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`) ew.writeln(` - "NAMECOM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namedotcom`) case "namesilo": // generated from: providers/dns/namesilo/namesilo.toml ew.writeln(`Configuration for Namesilo.`) ew.writeln(`Code: 'namesilo'`) ew.writeln(`Since: 'v2.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NAMESILO_API_KEY": Client ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NAMESILO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NAMESILO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes`) ew.writeln(` - "NAMESILO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000]`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesilo`) case "namesurfer": // generated from: providers/dns/namesurfer/namesurfer.toml ew.writeln(`Configuration for FusionLayer NameSurfer.`) ew.writeln(`Code: 'namesurfer'`) ew.writeln(`Since: 'v4.32.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NAMESURFER_API_KEY": API key name`) ew.writeln(` - "NAMESURFER_API_SECRET": API secret`) ew.writeln(` - "NAMESURFER_BASE_URL": The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NAMESURFER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "NAMESURFER_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`) ew.writeln(` - "NAMESURFER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NAMESURFER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "NAMESURFER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln(` - "NAMESURFER_VIEW": DNS view name (optional, default: empty string)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesurfer`) case "nearlyfreespeech": // generated from: providers/dns/nearlyfreespeech/nearlyfreespeech.toml ew.writeln(`Configuration for NearlyFreeSpeech.NET.`) ew.writeln(`Code: 'nearlyfreespeech'`) ew.writeln(`Since: 'v4.8.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NEARLYFREESPEECH_API_KEY": API Key for API requests`) ew.writeln(` - "NEARLYFREESPEECH_LOGIN": Username for API requests`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NEARLYFREESPEECH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "NEARLYFREESPEECH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NEARLYFREESPEECH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "NEARLYFREESPEECH_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "NEARLYFREESPEECH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nearlyfreespeech`) case "neodigit": // generated from: providers/dns/neodigit/neodigit.toml ew.writeln(`Configuration for Neodigit.`) ew.writeln(`Code: 'neodigit'`) ew.writeln(`Since: 'v4.30.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NEODIGIT_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NEODIGIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "NEODIGIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "NEODIGIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "NEODIGIT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/neodigit`) case "netcup": // generated from: providers/dns/netcup/netcup.toml ew.writeln(`Configuration for Netcup.`) ew.writeln(`Code: 'netcup'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NETCUP_API_KEY": API key`) ew.writeln(` - "NETCUP_API_PASSWORD": API password`) ew.writeln(` - "NETCUP_CUSTOMER_NUMBER": Customer number`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NETCUP_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "NETCUP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) ew.writeln(` - "NETCUP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/netcup`) case "netlify": // generated from: providers/dns/netlify/netlify.toml ew.writeln(`Configuration for Netlify.`) ew.writeln(`Code: 'netlify'`) ew.writeln(`Since: 'v3.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NETLIFY_TOKEN": Token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NETLIFY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "NETLIFY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NETLIFY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "NETLIFY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/netlify`) case "nicmanager": // generated from: providers/dns/nicmanager/nicmanager.toml ew.writeln(`Configuration for Nicmanager.`) ew.writeln(`Code: 'nicmanager'`) ew.writeln(`Since: 'v4.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NICMANAGER_API_EMAIL": Email-based login`) ew.writeln(` - "NICMANAGER_API_LOGIN": Login, used for Username-based login`) ew.writeln(` - "NICMANAGER_API_PASSWORD": Password, always required`) ew.writeln(` - "NICMANAGER_API_USERNAME": Username, used for Username-based login`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NICMANAGER_API_MODE": mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast')`) ew.writeln(` - "NICMANAGER_API_OTP": TOTP Secret (optional)`) ew.writeln(` - "NICMANAGER_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "NICMANAGER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NICMANAGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "NICMANAGER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 900)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicmanager`) case "nicru": // generated from: providers/dns/nicru/nicru.toml ew.writeln(`Configuration for RU CENTER.`) ew.writeln(`Code: 'nicru'`) ew.writeln(`Since: 'v4.24.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NICRU_PASSWORD": Password for an account in RU CENTER`) ew.writeln(` - "NICRU_SECRET": Secret for application in DNS-hosting RU CENTER`) ew.writeln(` - "NICRU_SERVICE_ID": Service ID for application in DNS-hosting RU CENTER`) ew.writeln(` - "NICRU_SERVICE_NAME": Service Name for DNS-hosting RU CENTER`) ew.writeln(` - "NICRU_USER": Agreement for an account in RU CENTER`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NICRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) ew.writeln(` - "NICRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) ew.writeln(` - "NICRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicru`) case "nifcloud": // generated from: providers/dns/nifcloud/nifcloud.toml ew.writeln(`Configuration for NIFCloud.`) ew.writeln(`Code: 'nifcloud'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NIFCLOUD_ACCESS_KEY_ID": Access key`) ew.writeln(` - "NIFCLOUD_SECRET_ACCESS_KEY": Secret access key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NIFCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "NIFCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NIFCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "NIFCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nifcloud`) case "njalla": // generated from: providers/dns/njalla/njalla.toml ew.writeln(`Configuration for Njalla.`) ew.writeln(`Code: 'njalla'`) ew.writeln(`Since: 'v4.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NJALLA_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NJALLA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "NJALLA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NJALLA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "NJALLA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/njalla`) case "nodion": // generated from: providers/dns/nodion/nodion.toml ew.writeln(`Configuration for Nodion.`) ew.writeln(`Code: 'nodion'`) ew.writeln(`Since: 'v4.11.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NODION_API_TOKEN": The API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NODION_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "NODION_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NODION_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "NODION_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nodion`) case "ns1": // generated from: providers/dns/ns1/ns1.toml ew.writeln(`Configuration for NS1.`) ew.writeln(`Code: 'ns1'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "NS1_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "NS1_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "NS1_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "NS1_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "NS1_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ns1`) case "octenium": // generated from: providers/dns/octenium/octenium.toml ew.writeln(`Configuration for Octenium.`) ew.writeln(`Code: 'octenium'`) ew.writeln(`Since: 'v4.27.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "OCTENIUM_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "OCTENIUM_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "OCTENIUM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "OCTENIUM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "OCTENIUM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/octenium`) case "oraclecloud": // generated from: providers/dns/oraclecloud/oraclecloud.toml ew.writeln(`Configuration for Oracle Cloud.`) ew.writeln(`Code: 'oraclecloud'`) ew.writeln(`Since: 'v2.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "OCI_COMPARTMENT_OCID": Compartment OCID`) ew.writeln(` - "OCI_FINGERPRINT": Public key fingerprint (ignored if 'OCI_AUTH_TYPE=instance_principal')`) ew.writeln(` - "OCI_PRIVATE_KEY_PASSWORD": Private key password (ignored if 'OCI_AUTH_TYPE=instance_principal')`) ew.writeln(` - "OCI_PRIVATE_KEY_PATH": Private key file (ignored if 'OCI_AUTH_TYPE=instance_principal')`) ew.writeln(` - "OCI_REGION": Region (it can be empty if 'OCI_AUTH_TYPE=instance_principal').`) ew.writeln(` - "OCI_TENANCY_OCID": Tenancy OCID (ignored if 'OCI_AUTH_TYPE=instance_principal')`) ew.writeln(` - "OCI_USER_OCID": User OCID (ignored if 'OCI_AUTH_TYPE=instance_principal')`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "OCI_AUTH_TYPE": Authorization type. Possible values: 'instance_principal', '' (Default: '')`) ew.writeln(` - "OCI_HTTP_TIMEOUT": API request timeout in seconds (Default: 60)`) ew.writeln(` - "OCI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "OCI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "OCI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln(` - "TF_VAR_fingerprint": Alias on 'OCI_FINGERPRINT'`) ew.writeln(` - "TF_VAR_private_key_path": Alias on 'OCI_PRIVATE_KEY_PATH'`) ew.writeln(` - "TF_VAR_region": Alias on 'OCI_REGION'`) ew.writeln(` - "TF_VAR_tenancy_ocid": Alias on 'OCI_TENANCY_OCID'`) ew.writeln(` - "TF_VAR_user_ocid": Alias on 'OCI_USER_OCID'`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/oraclecloud`) case "otc": // generated from: providers/dns/otc/otc.toml ew.writeln(`Configuration for Open Telekom Cloud.`) ew.writeln(`Code: 'otc'`) ew.writeln(`Since: 'v0.4.1'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "OTC_DOMAIN_NAME": Domain name`) ew.writeln(` - "OTC_PASSWORD": Password`) ew.writeln(` - "OTC_PROJECT_NAME": Project name`) ew.writeln(` - "OTC_USER_NAME": User name`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "OTC_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "OTC_IDENTITY_ENDPOINT": Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens)`) ew.writeln(` - "OTC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "OTC_PRIVATE_ZONE": Set to true to use private zones only (default: use public zones only)`) ew.writeln(` - "OTC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "OTC_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "OTC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/otc`) case "ovh": // generated from: providers/dns/ovh/ovh.toml ew.writeln(`Configuration for OVH.`) ew.writeln(`Code: 'ovh'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "OVH_ACCESS_TOKEN": Access token`) ew.writeln(` - "OVH_APPLICATION_KEY": Application key (Application Key authentication)`) ew.writeln(` - "OVH_APPLICATION_SECRET": Application secret (Application Key authentication)`) ew.writeln(` - "OVH_CLIENT_ID": Client ID (OAuth2)`) ew.writeln(` - "OVH_CLIENT_SECRET": Client secret (OAuth2)`) ew.writeln(` - "OVH_CONSUMER_KEY": Consumer key (Application Key authentication)`) ew.writeln(` - "OVH_ENDPOINT": Endpoint URL (ovh-eu or ovh-ca)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "OVH_HTTP_TIMEOUT": API request timeout in seconds (Default: 180)`) ew.writeln(` - "OVH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "OVH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "OVH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ovh`) case "pdns": // generated from: providers/dns/pdns/pdns.toml ew.writeln(`Configuration for PowerDNS.`) ew.writeln(`Code: 'pdns'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "PDNS_API_KEY": API key`) ew.writeln(` - "PDNS_API_URL": API URL`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "PDNS_API_VERSION": Skip API version autodetection and use the provided version number.`) ew.writeln(` - "PDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "PDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "PDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "PDNS_SERVER_NAME": Name of the server in the URL, 'localhost' by default`) ew.writeln(` - "PDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/pdns`) case "plesk": // generated from: providers/dns/plesk/plesk.toml ew.writeln(`Configuration for plesk.com.`) ew.writeln(`Code: 'plesk'`) ew.writeln(`Since: 'v4.11.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "PLESK_PASSWORD": API password`) ew.writeln(` - "PLESK_SERVER_BASE_URL": Base URL of the server (ex: https://plesk.myserver.com:8443)`) ew.writeln(` - "PLESK_USERNAME": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "PLESK_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "PLESK_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "PLESK_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "PLESK_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/plesk`) case "porkbun": // generated from: providers/dns/porkbun/porkbun.toml ew.writeln(`Configuration for Porkbun.`) ew.writeln(`Code: 'porkbun'`) ew.writeln(`Since: 'v4.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "PORKBUN_API_KEY": API key`) ew.writeln(` - "PORKBUN_SECRET_API_KEY": secret API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "PORKBUN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "PORKBUN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "PORKBUN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) ew.writeln(` - "PORKBUN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/porkbun`) case "rackspace": // generated from: providers/dns/rackspace/rackspace.toml ew.writeln(`Configuration for Rackspace.`) ew.writeln(`Code: 'rackspace'`) ew.writeln(`Since: 'v0.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "RACKSPACE_API_KEY": API key`) ew.writeln(` - "RACKSPACE_USER": API user`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "RACKSPACE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "RACKSPACE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 3)`) ew.writeln(` - "RACKSPACE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "RACKSPACE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rackspace`) case "rainyun": // generated from: providers/dns/rainyun/rainyun.toml ew.writeln(`Configuration for Rain Yun/雨云.`) ew.writeln(`Code: 'rainyun'`) ew.writeln(`Since: 'v4.21.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "RAINYUN_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "RAINYUN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "RAINYUN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "RAINYUN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "RAINYUN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rainyun`) case "rcodezero": // generated from: providers/dns/rcodezero/rcodezero.toml ew.writeln(`Configuration for RcodeZero.`) ew.writeln(`Code: 'rcodezero'`) ew.writeln(`Since: 'v4.13'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "RCODEZERO_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "RCODEZERO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "RCODEZERO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "RCODEZERO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`) ew.writeln(` - "RCODEZERO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rcodezero`) case "regfish": // generated from: providers/dns/regfish/regfish.toml ew.writeln(`Configuration for Regfish.`) ew.writeln(`Code: 'regfish'`) ew.writeln(`Since: 'v4.20.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "REGFISH_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "REGFISH_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "REGFISH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "REGFISH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "REGFISH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/regfish`) case "regru": // generated from: providers/dns/regru/regru.toml ew.writeln(`Configuration for reg.ru.`) ew.writeln(`Code: 'regru'`) ew.writeln(`Since: 'v3.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "REGRU_PASSWORD": API password`) ew.writeln(` - "REGRU_USERNAME": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "REGRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "REGRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "REGRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "REGRU_TLS_CERT": authentication certificate`) ew.writeln(` - "REGRU_TLS_KEY": authentication private key`) ew.writeln(` - "REGRU_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/regru`) case "rfc2136": // generated from: providers/dns/rfc2136/rfc2136.toml ew.writeln(`Configuration for RFC2136.`) ew.writeln(`Code: 'rfc2136'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "RFC2136_NAMESERVER": Network address in the form "host" or "host:port"`) ew.writeln(` - "RFC2136_TSIG_ALGORITHM": TSIG algorithm. See [miekg/dns#tsig.go](https://github.com/miekg/dns/blob/master/tsig.go) for supported values. To disable TSIG authentication, leave the 'RFC2136_TSIG_KEY' or 'RFC2136_TSIG_SECRET' variables unset.`) ew.writeln(` - "RFC2136_TSIG_KEY": Name of the secret key as defined in DNS server configuration. To disable TSIG authentication, leave the 'RFC2136_TSIG_KEY' variable unset.`) ew.writeln(` - "RFC2136_TSIG_SECRET": Secret key payload. To disable TSIG authentication, leave the 'RFC2136_TSIG_SECRET' variable unset.`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "RFC2136_DNS_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "RFC2136_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "RFC2136_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "RFC2136_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "RFC2136_TSIG_FILE": Path to a key file generated by tsig-keygen`) ew.writeln(` - "RFC2136_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rfc2136`) case "rimuhosting": // generated from: providers/dns/rimuhosting/rimuhosting.toml ew.writeln(`Configuration for RimuHosting.`) ew.writeln(`Code: 'rimuhosting'`) ew.writeln(`Since: 'v0.3.5'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "RIMUHOSTING_API_KEY": User API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "RIMUHOSTING_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "RIMUHOSTING_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "RIMUHOSTING_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "RIMUHOSTING_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/rimuhosting`) case "route53": // generated from: providers/dns/route53/route53.toml ew.writeln(`Configuration for Amazon Route 53.`) ew.writeln(`Code: 'route53'`) ew.writeln(`Since: 'v0.3.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "AWS_ACCESS_KEY_ID": Managed by the AWS client. Access key ID ('AWS_ACCESS_KEY_ID_FILE' is not supported, use 'AWS_SHARED_CREDENTIALS_FILE' instead)`) ew.writeln(` - "AWS_ASSUME_ROLE_ARN": Managed by the AWS Role ARN ('AWS_ASSUME_ROLE_ARN_FILE' is not supported)`) ew.writeln(` - "AWS_EXTERNAL_ID": Managed by STS AssumeRole API operation ('AWS_EXTERNAL_ID_FILE' is not supported)`) ew.writeln(` - "AWS_HOSTED_ZONE_ID": Override the hosted zone ID.`) ew.writeln(` - "AWS_PROFILE": Managed by the AWS client ('AWS_PROFILE_FILE' is not supported)`) ew.writeln(` - "AWS_REGION": Managed by the AWS client ('AWS_REGION_FILE' is not supported)`) ew.writeln(` - "AWS_SDK_LOAD_CONFIG": Managed by the AWS client. Retrieve the region from the CLI config file ('AWS_SDK_LOAD_CONFIG_FILE' is not supported)`) ew.writeln(` - "AWS_SECRET_ACCESS_KEY": Managed by the AWS client. Secret access key ('AWS_SECRET_ACCESS_KEY_FILE' is not supported, use 'AWS_SHARED_CREDENTIALS_FILE' instead)`) ew.writeln(` - "AWS_WAIT_FOR_RECORD_SETS_CHANGED": Wait for changes to be INSYNC (it can be unstable)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "AWS_MAX_RETRIES": The number of maximum returns the service will use to make an individual API request`) ew.writeln(` - "AWS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) ew.writeln(` - "AWS_PRIVATE_ZONE": Set to true to use private zones only (default: use public zones only)`) ew.writeln(` - "AWS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "AWS_SHARED_CREDENTIALS_FILE": Managed by the AWS client. Shared credentials file.`) ew.writeln(` - "AWS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/route53`) case "safedns": // generated from: providers/dns/safedns/safedns.toml ew.writeln(`Configuration for ANS SafeDNS.`) ew.writeln(`Code: 'safedns'`) ew.writeln(`Since: 'v4.6.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SAFEDNS_AUTH_TOKEN": Authentication token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SAFEDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SAFEDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "SAFEDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "SAFEDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/safedns`) case "sakuracloud": // generated from: providers/dns/sakuracloud/sakuracloud.toml ew.writeln(`Configuration for Sakura Cloud.`) ew.writeln(`Code: 'sakuracloud'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SAKURACLOUD_ACCESS_TOKEN": Access token`) ew.writeln(` - "SAKURACLOUD_ACCESS_TOKEN_SECRET": Access token secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SAKURACLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "SAKURACLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "SAKURACLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "SAKURACLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/sakuracloud`) case "scaleway": // generated from: providers/dns/scaleway/scaleway.toml ew.writeln(`Configuration for Scaleway.`) ew.writeln(`Code: 'scaleway'`) ew.writeln(`Since: 'v3.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SCW_PROJECT_ID": Project to use (optional)`) ew.writeln(` - "SCW_SECRET_KEY": Secret key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SCW_ACCESS_KEY": Access key`) ew.writeln(` - "SCW_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SCW_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "SCW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "SCW_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/scaleway`) case "selectel": // generated from: providers/dns/selectel/selectel.toml ew.writeln(`Configuration for Selectel.`) ew.writeln(`Code: 'selectel'`) ew.writeln(`Since: 'v1.2.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SELECTEL_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SELECTEL_BASE_URL": API endpoint URL`) ew.writeln(` - "SELECTEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SELECTEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "SELECTEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "SELECTEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectel`) case "selectelv2": // generated from: providers/dns/selectelv2/selectelv2.toml ew.writeln(`Configuration for Selectel v2.`) ew.writeln(`Code: 'selectelv2'`) ew.writeln(`Since: 'v4.17.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SELECTELV2_ACCOUNT_ID": Selectel account ID (INT)`) ew.writeln(` - "SELECTELV2_PASSWORD": Openstack username's password`) ew.writeln(` - "SELECTELV2_PROJECT_ID": Cloud project ID (UUID)`) ew.writeln(` - "SELECTELV2_USERNAME": Openstack username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SELECTELV2_AUTH_REGION": Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1')`) ew.writeln(` - "SELECTELV2_AUTH_URL": Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/')`) ew.writeln(` - "SELECTELV2_BASE_URL": API endpoint URL`) ew.writeln(` - "SELECTELV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SELECTELV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "SELECTELV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "SELECTELV2_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln(` - "SELECTELV2_USER_DOMAIN_NAME": To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectelv2`) case "selfhostde": // generated from: providers/dns/selfhostde/selfhostde.toml ew.writeln(`Configuration for SelfHost.(de|eu).`) ew.writeln(`Code: 'selfhostde'`) ew.writeln(`Since: 'v4.19.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SELFHOSTDE_PASSWORD": Password`) ew.writeln(` - "SELFHOSTDE_RECORDS_MAPPING": Record IDs mapping with domains (ex: example.com:123:456,example.org:789,foo.example.com:147)`) ew.writeln(` - "SELFHOSTDE_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SELFHOSTDE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SELFHOSTDE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`) ew.writeln(` - "SELFHOSTDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`) ew.writeln(` - "SELFHOSTDE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/selfhostde`) case "servercow": // generated from: providers/dns/servercow/servercow.toml ew.writeln(`Configuration for Servercow.`) ew.writeln(`Code: 'servercow'`) ew.writeln(`Since: 'v3.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SERVERCOW_PASSWORD": API password`) ew.writeln(` - "SERVERCOW_USERNAME": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SERVERCOW_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SERVERCOW_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "SERVERCOW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "SERVERCOW_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/servercow`) case "shellrent": // generated from: providers/dns/shellrent/shellrent.toml ew.writeln(`Configuration for Shellrent.`) ew.writeln(`Code: 'shellrent'`) ew.writeln(`Since: 'v4.16.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SHELLRENT_TOKEN": Token`) ew.writeln(` - "SHELLRENT_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SHELLRENT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SHELLRENT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "SHELLRENT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "SHELLRENT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/shellrent`) case "simply": // generated from: providers/dns/simply/simply.toml ew.writeln(`Configuration for Simply.com.`) ew.writeln(`Code: 'simply'`) ew.writeln(`Since: 'v4.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SIMPLY_ACCOUNT_NAME": Account name`) ew.writeln(` - "SIMPLY_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SIMPLY_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SIMPLY_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "SIMPLY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "SIMPLY_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/simply`) case "sonic": // generated from: providers/dns/sonic/sonic.toml ew.writeln(`Configuration for Sonic.`) ew.writeln(`Code: 'sonic'`) ew.writeln(`Since: 'v4.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SONIC_API_KEY": API Key`) ew.writeln(` - "SONIC_USER_ID": User ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SONIC_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "SONIC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "SONIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "SONIC_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "SONIC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/sonic`) case "spaceship": // generated from: providers/dns/spaceship/spaceship.toml ew.writeln(`Configuration for Spaceship.`) ew.writeln(`Code: 'spaceship'`) ew.writeln(`Since: 'v4.22.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SPACESHIP_API_KEY": API key`) ew.writeln(` - "SPACESHIP_API_SECRET": API secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SPACESHIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SPACESHIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "SPACESHIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "SPACESHIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/spaceship`) case "stackpath": // generated from: providers/dns/stackpath/stackpath.toml ew.writeln(`Configuration for Stackpath.`) ew.writeln(`Code: 'stackpath'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "STACKPATH_CLIENT_ID": Client ID`) ew.writeln(` - "STACKPATH_CLIENT_SECRET": Client secret`) ew.writeln(` - "STACKPATH_STACK_ID": Stack ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "STACKPATH_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "STACKPATH_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "STACKPATH_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/stackpath`) case "syse": // generated from: providers/dns/syse/syse.toml ew.writeln(`Configuration for Syse.`) ew.writeln(`Code: 'syse'`) ew.writeln(`Since: 'v4.30.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SYSE_CREDENTIALS": Comma-separated list of 'zone:password' credential pairs`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "SYSE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "SYSE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "SYSE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 1200)`) ew.writeln(` - "SYSE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/syse`) case "technitium": // generated from: providers/dns/technitium/technitium.toml ew.writeln(`Configuration for Technitium.`) ew.writeln(`Code: 'technitium'`) ew.writeln(`Since: 'v4.20.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "TECHNITIUM_API_TOKEN": API token`) ew.writeln(` - "TECHNITIUM_SERVER_BASE_URL": Server base URL`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "TECHNITIUM_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "TECHNITIUM_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "TECHNITIUM_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "TECHNITIUM_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/technitium`) case "tencentcloud": // generated from: providers/dns/tencentcloud/tencentcloud.toml ew.writeln(`Configuration for Tencent Cloud DNS.`) ew.writeln(`Code: 'tencentcloud'`) ew.writeln(`Since: 'v4.6.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "TENCENTCLOUD_SECRET_ID": Access key ID`) ew.writeln(` - "TENCENTCLOUD_SECRET_KEY": Access Key secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "TENCENTCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "TENCENTCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "TENCENTCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "TENCENTCLOUD_REGION": Region`) ew.writeln(` - "TENCENTCLOUD_SESSION_TOKEN": Access Key token`) ew.writeln(` - "TENCENTCLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/tencentcloud`) case "timewebcloud": // generated from: providers/dns/timewebcloud/timewebcloud.toml ew.writeln(`Configuration for Timeweb Cloud.`) ew.writeln(`Code: 'timewebcloud'`) ew.writeln(`Since: 'v4.20.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "TIMEWEBCLOUD_AUTH_TOKEN": Authentication token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "TIMEWEBCLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) ew.writeln(` - "TIMEWEBCLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "TIMEWEBCLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/timewebcloud`) case "todaynic": // generated from: providers/dns/todaynic/todaynic.toml ew.writeln(`Configuration for TodayNIC/时代互联.`) ew.writeln(`Code: 'todaynic'`) ew.writeln(`Since: 'v4.32.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "TODAYNIC_API_KEY": API key`) ew.writeln(` - "TODAYNIC_AUTH_USER_ID": account ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "TODAYNIC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "TODAYNIC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "TODAYNIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "TODAYNIC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/todaynic`) case "transip": // generated from: providers/dns/transip/transip.toml ew.writeln(`Configuration for TransIP.`) ew.writeln(`Code: 'transip'`) ew.writeln(`Since: 'v2.0.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "TRANSIP_ACCOUNT_NAME": Account name`) ew.writeln(` - "TRANSIP_PRIVATE_KEY_PATH": Private key path`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "TRANSIP_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "TRANSIP_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "TRANSIP_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) ew.writeln(` - "TRANSIP_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/transip`) case "ultradns": // generated from: providers/dns/ultradns/ultradns.toml ew.writeln(`Configuration for Ultradns.`) ew.writeln(`Code: 'ultradns'`) ew.writeln(`Since: 'v4.10.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ULTRADNS_PASSWORD": API Password`) ew.writeln(` - "ULTRADNS_USERNAME": API Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ULTRADNS_ENDPOINT": API endpoint URL, defaults to https://api.ultradns.com/`) ew.writeln(` - "ULTRADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) ew.writeln(` - "ULTRADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "ULTRADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/ultradns`) case "uniteddomains": // generated from: providers/dns/uniteddomains/uniteddomains.toml ew.writeln(`Configuration for United-Domains.`) ew.writeln(`Code: 'uniteddomains'`) ew.writeln(`Since: 'v4.29.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "UNITEDDOMAINS_API_KEY": API key '.' https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "UNITEDDOMAINS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "UNITEDDOMAINS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "UNITEDDOMAINS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 900)`) ew.writeln(` - "UNITEDDOMAINS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/uniteddomains`) case "variomedia": // generated from: providers/dns/variomedia/variomedia.toml ew.writeln(`Configuration for Variomedia.`) ew.writeln(`Code: 'variomedia'`) ew.writeln(`Since: 'v4.8.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VARIOMEDIA_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VARIOMEDIA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "VARIOMEDIA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "VARIOMEDIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "VARIOMEDIA_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "VARIOMEDIA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/variomedia`) case "vegadns": // generated from: providers/dns/vegadns/vegadns.toml ew.writeln(`Configuration for VegaDNS.`) ew.writeln(`Code: 'vegadns'`) ew.writeln(`Since: 'v1.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "SECRET_VEGADNS_KEY": API key`) ew.writeln(` - "SECRET_VEGADNS_SECRET": API secret`) ew.writeln(` - "VEGADNS_URL": API endpoint URL`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VEGADNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 60)`) ew.writeln(` - "VEGADNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 720)`) ew.writeln(` - "VEGADNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vegadns`) case "vercel": // generated from: providers/dns/vercel/vercel.toml ew.writeln(`Configuration for Vercel.`) ew.writeln(`Code: 'vercel'`) ew.writeln(`Since: 'v4.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VERCEL_API_TOKEN": Authentication token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VERCEL_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "VERCEL_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "VERCEL_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "VERCEL_TEAM_ID": Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx)`) ew.writeln(` - "VERCEL_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vercel`) case "versio": // generated from: providers/dns/versio/versio.toml ew.writeln(`Configuration for Versio.[nl|eu|uk].`) ew.writeln(`Code: 'versio'`) ew.writeln(`Since: 'v2.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VERSIO_PASSWORD": Basic authentication password`) ew.writeln(` - "VERSIO_USERNAME": Basic authentication username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VERSIO_ENDPOINT": The endpoint URL of the API Server`) ew.writeln(` - "VERSIO_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "VERSIO_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "VERSIO_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "VERSIO_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "VERSIO_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/versio`) case "vinyldns": // generated from: providers/dns/vinyldns/vinyldns.toml ew.writeln(`Configuration for VinylDNS.`) ew.writeln(`Code: 'vinyldns'`) ew.writeln(`Since: 'v4.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VINYLDNS_ACCESS_KEY": The VinylDNS API key`) ew.writeln(` - "VINYLDNS_HOST": The VinylDNS API URL`) ew.writeln(` - "VINYLDNS_SECRET_KEY": The VinylDNS API Secret key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VINYLDNS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "VINYLDNS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 4)`) ew.writeln(` - "VINYLDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "VINYLDNS_QUOTE_VALUE": Adds quotes around the TXT record value (Default: false)`) ew.writeln(` - "VINYLDNS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vinyldns`) case "virtualname": // generated from: providers/dns/virtualname/virtualname.toml ew.writeln(`Configuration for Virtualname.`) ew.writeln(`Code: 'virtualname'`) ew.writeln(`Since: 'v4.30.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VIRTUALNAME_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VIRTUALNAME_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "VIRTUALNAME_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "VIRTUALNAME_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln(` - "VIRTUALNAME_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/virtualname`) case "vkcloud": // generated from: providers/dns/vkcloud/vkcloud.toml ew.writeln(`Configuration for VK Cloud.`) ew.writeln(`Code: 'vkcloud'`) ew.writeln(`Since: 'v4.9.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VK_CLOUD_PASSWORD": Password for VK Cloud account`) ew.writeln(` - "VK_CLOUD_PROJECT_ID": String ID of project in VK Cloud`) ew.writeln(` - "VK_CLOUD_USERNAME": Email of VK Cloud account`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VK_CLOUD_DNS_ENDPOINT": URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds`) ew.writeln(` - "VK_CLOUD_DOMAIN_NAME": Openstack users domain name. Defaults to 'users' but can be changed for usage with private clouds`) ew.writeln(` - "VK_CLOUD_IDENTITY_ENDPOINT": URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds`) ew.writeln(` - "VK_CLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "VK_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "VK_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vkcloud`) case "volcengine": // generated from: providers/dns/volcengine/volcengine.toml ew.writeln(`Configuration for Volcano Engine/火山引擎.`) ew.writeln(`Code: 'volcengine'`) ew.writeln(`Since: 'v4.19.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VOLC_ACCESSKEY": Access Key ID (AK)`) ew.writeln(` - "VOLC_SECRETKEY": Secret Access Key (SK)`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VOLC_HOST": API host`) ew.writeln(` - "VOLC_HTTP_TIMEOUT": API request timeout in seconds (Default: 15)`) ew.writeln(` - "VOLC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "VOLC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 240)`) ew.writeln(` - "VOLC_REGION": Region`) ew.writeln(` - "VOLC_SCHEME": API scheme`) ew.writeln(` - "VOLC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/volcengine`) case "vscale": // generated from: providers/dns/vscale/vscale.toml ew.writeln(`Configuration for Vscale.`) ew.writeln(`Code: 'vscale'`) ew.writeln(`Since: 'v2.0.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VSCALE_API_TOKEN": API token`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VSCALE_BASE_URL": API endpoint URL`) ew.writeln(` - "VSCALE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "VSCALE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "VSCALE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "VSCALE_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vscale`) case "vultr": // generated from: providers/dns/vultr/vultr.toml ew.writeln(`Configuration for Vultr.`) ew.writeln(`Code: 'vultr'`) ew.writeln(`Since: 'v0.3.1'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "VULTR_API_KEY": API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "VULTR_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "VULTR_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "VULTR_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "VULTR_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vultr`) case "webnames": // generated from: providers/dns/webnames/webnames.toml ew.writeln(`Configuration for webnames.ru.`) ew.writeln(`Code: 'webnames'`) ew.writeln(`Since: 'v4.15.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "WEBNAMESRU_API_KEY": Domain API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "WEBNAMESRU_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "WEBNAMESRU_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "WEBNAMESRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/webnames`) case "webnamesca": // generated from: providers/dns/webnamesca/webnamesca.toml ew.writeln(`Configuration for webnames.ca.`) ew.writeln(`Code: 'webnamesca'`) ew.writeln(`Since: 'v4.28.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "WEBNAMESCA_API_KEY": API key`) ew.writeln(` - "WEBNAMESCA_API_USER": API username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "WEBNAMESCA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "WEBNAMESCA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "WEBNAMESCA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "WEBNAMESCA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/webnamesca`) case "websupport": // generated from: providers/dns/websupport/websupport.toml ew.writeln(`Configuration for Websupport.`) ew.writeln(`Code: 'websupport'`) ew.writeln(`Since: 'v4.10.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "WEBSUPPORT_API_KEY": API key`) ew.writeln(` - "WEBSUPPORT_SECRET": API secret`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "WEBSUPPORT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "WEBSUPPORT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "WEBSUPPORT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "WEBSUPPORT_SEQUENCE_INTERVAL": Time between sequential requests in seconds (Default: 60)`) ew.writeln(` - "WEBSUPPORT_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/websupport`) case "wedos": // generated from: providers/dns/wedos/wedos.toml ew.writeln(`Configuration for WEDOS.`) ew.writeln(`Code: 'wedos'`) ew.writeln(`Since: 'v4.4.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "WEDOS_USERNAME": Username is the same as for the admin account`) ew.writeln(` - "WEDOS_WAPI_PASSWORD": Password needs to be generated and IP allowed in the admin interface`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "WEDOS_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "WEDOS_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "WEDOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 600)`) ew.writeln(` - "WEDOS_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/wedos`) case "westcn": // generated from: providers/dns/westcn/westcn.toml ew.writeln(`Configuration for West.cn/西部数码.`) ew.writeln(`Code: 'westcn'`) ew.writeln(`Since: 'v4.21.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "WESTCN_PASSWORD": API password`) ew.writeln(` - "WESTCN_USERNAME": Username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "WESTCN_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "WESTCN_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 10)`) ew.writeln(` - "WESTCN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) ew.writeln(` - "WESTCN_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/westcn`) case "yandex": // generated from: providers/dns/yandex/yandex.toml ew.writeln(`Configuration for Yandex PDD.`) ew.writeln(`Code: 'yandex'`) ew.writeln(`Since: 'v3.7.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "YANDEX_PDD_TOKEN": Basic authentication username`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "YANDEX_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "YANDEX_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "YANDEX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "YANDEX_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandex`) case "yandex360": // generated from: providers/dns/yandex360/yandex360.toml ew.writeln(`Configuration for Yandex 360.`) ew.writeln(`Code: 'yandex360'`) ew.writeln(`Since: 'v4.14.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "YANDEX360_OAUTH_TOKEN": The OAuth Token`) ew.writeln(` - "YANDEX360_ORG_ID": The organization ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "YANDEX360_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "YANDEX360_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "YANDEX360_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "YANDEX360_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandex360`) case "yandexcloud": // generated from: providers/dns/yandexcloud/yandexcloud.toml ew.writeln(`Configuration for Yandex Cloud.`) ew.writeln(`Code: 'yandexcloud'`) ew.writeln(`Since: 'v4.9.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "YANDEX_CLOUD_FOLDER_ID": The string id of folder (aka project) in Yandex Cloud`) ew.writeln(` - "YANDEX_CLOUD_IAM_TOKEN": The base64 encoded json which contains information about iam token of service account with 'dns.admin' permissions`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "YANDEX_CLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "YANDEX_CLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "YANDEX_CLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/yandexcloud`) case "zoneedit": // generated from: providers/dns/zoneedit/zoneedit.toml ew.writeln(`Configuration for ZoneEdit.`) ew.writeln(`Code: 'zoneedit'`) ew.writeln(`Since: 'v4.25.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ZONEEDIT_AUTH_TOKEN": Authentication token`) ew.writeln(` - "ZONEEDIT_USER": User ID`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ZONEEDIT_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ZONEEDIT_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ZONEEDIT_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/zoneedit`) case "zoneee": // generated from: providers/dns/zoneee/zoneee.toml ew.writeln(`Configuration for Zone.ee.`) ew.writeln(`Code: 'zoneee'`) ew.writeln(`Since: 'v2.1.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ZONEEE_API_KEY": API key`) ew.writeln(` - "ZONEEE_API_USER": API user`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ZONEEE_ENDPOINT": API endpoint URL`) ew.writeln(` - "ZONEEE_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ZONEEE_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 5)`) ew.writeln(` - "ZONEEE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/zoneee`) case "zonomi": // generated from: providers/dns/zonomi/zonomi.toml ew.writeln(`Configuration for Zonomi.`) ew.writeln(`Code: 'zonomi'`) ew.writeln(`Since: 'v3.5.0'`) ew.writeln() ew.writeln(`Credentials:`) ew.writeln(` - "ZONOMI_API_KEY": User API key`) ew.writeln() ew.writeln(`Additional Configuration:`) ew.writeln(` - "ZONOMI_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "ZONOMI_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ZONOMI_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) ew.writeln(` - "ZONOMI_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/zonomi`) default: return fmt.Errorf("%q is not yet supported", name) } if flusher, ok := w.(interface{ Flush() error }); ok { return flusher.Flush() } return nil } ================================================ FILE: docs/.gitignore ================================================ themes/ public/ .hugo_build.lock ================================================ FILE: docs/Makefile ================================================ .PHONY: default clean serve build default: clean serve clean: rm -rf public/ build: clean hugo --enableGitInfo --source . serve: hugo server --disableFastRender --enableGitInfo --watch --source . # hugo server -D ================================================ FILE: docs/archetypes/default.md ================================================ --- title: "{{ replace .Name "-" " " | title }}" date: {{ .Date }} draft: true --- ================================================ FILE: docs/content/_index.md ================================================ --- title: "Lego" date: 2019-03-03T16:39:46+01:00 draft: false chapter: false --- Let's Encrypt client and ACME library written in Go. {{% notice important %}} lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ This project is not owned by a company. I'm not an employee of a company. I don't have gifted domains/accounts from DNS companies. I've been maintaining it for about 10 years. {{% /notice %}} ## Features - ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html) - Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension - Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): issues certificates for IP addresses - Support [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html): Renewal Information (ARI) Extension - Support [draft-ietf-acme-profiles-00](https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/): Profiles Extension - Comes with about [180 DNS providers]({{% ref "dns" %}}) - Register with CA - Obtain certificates, both from scratch or with an existing CSR - Renew certificates - Revoke certificates - Robust implementation of ACME challenges: - HTTP (http-01) - DNS (dns-01) - TLS (tls-alpn-01) - SAN certificate support - [CNAME support](https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme.html) by default - [Custom challenge solvers]({{% ref "usage/library/Writing-a-Challenge-Solver" %}}) - Certificate bundling - OCSP helper function ================================================ FILE: docs/content/dns/_index.md ================================================ --- title: "DNS Providers" date: 2019-03-03T16:39:46+01:00 draft: false weight: 3 --- {{% notice important %}} lego is an independent, free, and open-source project, if you value it, consider [supporting it](https://donate.ldez.dev)! ❤️ This project is not owned by a company. I'm not an employee of a company. I don't have gifted domains/accounts from DNS companies. I've been maintaining it for about 10 years. {{% /notice %}} ## Configuration and Credentials Credentials and DNS configuration for DNS providers must be passed through environment variables. ### Environment Variables: Value The environment variables can reference a value. Here is an example bash command using the Cloudflare DNS provider: ```bash $ CLOUDFLARE_EMAIL=you@example.com \ CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ lego --dns cloudflare --domains www.example.com --email you@example.com run ``` ### Environment Variables: File The environment variables can reference a path to file. In this case the name of environment variable must be suffixed by `_FILE`. {{% notice note %}} The file must contain only the value. {{% /notice %}} Here is an example bash command using the CloudFlare DNS provider: ```bash $ cat /the/path/to/my/key b9841238feb177a84330febba8a83208921177bffe733 $ cat /the/path/to/my/email you@example.com $ CLOUDFLARE_EMAIL_FILE=/the/path/to/my/email \ CLOUDFLARE_API_KEY_FILE=/the/path/to/my/key \ lego --dns cloudflare --domains www.example.com --email you@example.com run ``` ## DNS Providers {{% tableofdnsproviders %}} ================================================ FILE: docs/content/dns/zz_gen_acme-dns.md ================================================ --- title: "Joohoi's ACME-DNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: acme-dns dnsprovider: since: "v1.1.0" code: "acme-dns" url: "https://github.com/joohoi/acme-dns" --- Configuration for [Joohoi's ACME-DNS](https://github.com/joohoi/acme-dns). - Code: `acme-dns` - Since: v1.1.0 Here is an example bash command using the Joohoi's ACME-DNS provider: ```bash ACME_DNS_API_BASE=http://10.0.0.8:4443 \ ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \ lego --dns "acme-dns" -d '*.example.com' -d example.com run # or ACME_DNS_API_BASE=http://10.0.0.8:4443 \ ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \ lego --dns "acme-dns" -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ACME_DNS_API_BASE` | The ACME-DNS API address | | `ACME_DNS_STORAGE_BASE_URL` | The ACME-DNS JSON account data server. | | `ACME_DNS_STORAGE_PATH` | The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates. | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ACME_DNS_ALLOWLIST` | Source networks using CIDR notation (multiple values should be separated with a comma). | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://github.com/joohoi/acme-dns#api) - [Go client](https://github.com/nrdcg/goacmedns) ================================================ FILE: docs/content/dns/zz_gen_active24.md ================================================ --- title: "Active24" date: 2019-03-03T16:39:46+01:00 draft: false slug: active24 dnsprovider: since: "v4.23.0" code: "active24" url: "https://www.active24.cz" --- Configuration for [Active24](https://www.active24.cz). - Code: `active24` - Since: v4.23.0 Here is an example bash command using the Active24 provider: ```bash ACTIVE24_API_KEY="xxx" \ ACTIVE24_SECRET="yyy" \ lego --dns active24 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ACTIVE24_API_KEY` | API key | | `ACTIVE24_SECRET` | Secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ACTIVE24_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ACTIVE24_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ACTIVE24_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `ACTIVE24_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://rest.active24.cz/v2/docs) ================================================ FILE: docs/content/dns/zz_gen_alidns.md ================================================ --- title: "Alibaba Cloud DNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: alidns dnsprovider: since: "v1.1.0" code: "alidns" url: "https://www.alibabacloud.com/product/dns" --- Configuration for [Alibaba Cloud DNS](https://www.alibabacloud.com/product/dns). - Code: `alidns` - Since: v1.1.0 Here is an example bash command using the Alibaba Cloud DNS provider: ```bash # Setup using instance RAM role ALICLOUD_RAM_ROLE=lego \ lego --dns alidns -d '*.example.com' -d example.com run # Or, using credentials ALICLOUD_ACCESS_KEY=abcdefghijklmnopqrstuvwx \ ALICLOUD_SECRET_KEY=your-secret-key \ ALICLOUD_SECURITY_TOKEN=your-sts-token \ lego --dns alidns - -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ALICLOUD_ACCESS_KEY` | Access key ID | | `ALICLOUD_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance) | | `ALICLOUD_SECRET_KEY` | Access Key secret | | `ALICLOUD_SECURITY_TOKEN` | STS Security Token (optional) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ALICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `ALICLOUD_LINE` | Line (Default: default) | | `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `ALICLOUD_REGION_ID` | Region ID (Default: cn-hangzhou) | | `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.alibabacloud.com/help/en/alibaba-cloud-dns/latest/api-alidns-2015-01-09-dir-parsing-records) - [Go client](https://github.com/alibabacloud-go/alidns-20150109) ================================================ FILE: docs/content/dns/zz_gen_aliesa.md ================================================ --- title: "AlibabaCloud ESA" date: 2019-03-03T16:39:46+01:00 draft: false slug: aliesa dnsprovider: since: "v4.29.0" code: "aliesa" url: "https://www.alibabacloud.com/en/product/esa" --- Configuration for [AlibabaCloud ESA](https://www.alibabacloud.com/en/product/esa). - Code: `aliesa` - Since: v4.29.0 Here is an example bash command using the AlibabaCloud ESA provider: ```bash # Setup using instance RAM role ALIESA_RAM_ROLE=lego \ lego --dns aliesa -d '*.example.com' -d example.com run # Or, using credentials ALIESA_ACCESS_KEY=abcdefghijklmnopqrstuvwx \ ALIESA_SECRET_KEY=your-secret-key \ ALIESA_SECURITY_TOKEN=your-sts-token \ lego --dns aliesa - -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ALIESA_ACCESS_KEY` | Access key ID | | `ALIESA_RAM_ROLE` | Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance) | | `ALIESA_SECRET_KEY` | Access Key secret | | `ALIESA_SECURITY_TOKEN` | STS Security Token (optional) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ALIESA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ALIESA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ALIESA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `ALIESA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-overview?spm=a2c63.p38356.help-menu-2673927.d_6_0_0.20b224c28PSZDc#:~:text=DNS-,DNS%20records,-DNS%20records) - [Go client](https://github.com/alibabacloud-go/esa-20240910) ================================================ FILE: docs/content/dns/zz_gen_allinkl.md ================================================ --- title: "all-inkl" date: 2019-03-03T16:39:46+01:00 draft: false slug: allinkl dnsprovider: since: "v4.5.0" code: "allinkl" url: "https://all-inkl.com" --- Configuration for [all-inkl](https://all-inkl.com). - Code: `allinkl` - Since: v4.5.0 Here is an example bash command using the all-inkl provider: ```bash ALL_INKL_LOGIN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ ALL_INKL_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \ lego --dns allinkl -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ALL_INKL_LOGIN` | KAS login | | `ALL_INKL_PASSWORD` | KAS password | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ALL_INKL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ALL_INKL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ALL_INKL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://kasapi.kasserver.com/dokumentation/phpdoc/index.html) ================================================ FILE: docs/content/dns/zz_gen_alwaysdata.md ================================================ --- title: "Alwaysdata" date: 2019-03-03T16:39:46+01:00 draft: false slug: alwaysdata dnsprovider: since: "v4.31.0" code: "alwaysdata" url: "https://alwaysdata.com/" --- Configuration for [Alwaysdata](https://alwaysdata.com/). - Code: `alwaysdata` - Since: v4.31.0 Here is an example bash command using the Alwaysdata provider: ```bash ALWAYSDATA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns alwaysdata -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ALWAYSDATA_API_KEY` | API Key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ALWAYSDATA_ACCOUNT` | Account name | | `ALWAYSDATA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ALWAYSDATA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ALWAYSDATA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `ALWAYSDATA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://help.alwaysdata.com/en/api/resources/) ================================================ FILE: docs/content/dns/zz_gen_anexia.md ================================================ --- title: "Anexia CloudDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: anexia dnsprovider: since: "v4.28.0" code: "anexia" url: "https://www.anexia-it.com/" --- Configuration for [Anexia CloudDNS](https://www.anexia-it.com/). - Code: `anexia` - Since: v4.28.0 Here is an example bash command using the Anexia CloudDNS provider: ```bash ANEXIA_TOKEN=xxx \ lego --dns anexia -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ANEXIA_TOKEN` | API token for Anexia Engine | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ANEXIA_API_URL` | API endpoint URL (default: https://engine.anexia-it.com) | | `ANEXIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ANEXIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ANEXIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `ANEXIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description You need to create an API token in the [Anexia Engine](https://engine.anexia-it.com/). The token must have permissions to manage DNS zones and records. ## More information - [API documentation](https://engine.anexia-it.com/docs/en/module/clouddns/api) ================================================ FILE: docs/content/dns/zz_gen_artfiles.md ================================================ --- title: "ArtFiles" date: 2019-03-03T16:39:46+01:00 draft: false slug: artfiles dnsprovider: since: "v4.32.0" code: "artfiles" url: "https://www.artfiles.de/extras/domains/" --- Configuration for [ArtFiles](https://www.artfiles.de/extras/domains/). - Code: `artfiles` - Since: v4.32.0 Here is an example bash command using the ArtFiles provider: ```bash ARTFILES_USERNAME="xxx" \ ARTFILES_PASSWORD="yyy" \ lego --dns artfiles -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ARTFILES_PASSWORD` | API password | | `ARTFILES_USERNAME` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ARTFILES_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ARTFILES_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ARTFILES_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | | `ARTFILES_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://support.artfiles.de/DCP-API#dns) ================================================ FILE: docs/content/dns/zz_gen_arvancloud.md ================================================ --- title: "ArvanCloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: arvancloud dnsprovider: since: "v3.8.0" code: "arvancloud" url: "https://arvancloud.ir" --- Configuration for [ArvanCloud](https://arvancloud.ir). - Code: `arvancloud` - Since: v3.8.0 Here is an example bash command using the ArvanCloud provider: ```bash ARVANCLOUD_API_KEY="Apikey xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ lego --dns arvancloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ARVANCLOUD_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ARVANCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ARVANCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ARVANCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `ARVANCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.arvancloud.ir/docs/api/cdn/4.0) ================================================ FILE: docs/content/dns/zz_gen_auroradns.md ================================================ --- title: "Aurora DNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: auroradns dnsprovider: since: "v0.4.0" code: "auroradns" url: "https://www.pcextreme.com/dns-health-checks" --- Configuration for [Aurora DNS](https://www.pcextreme.com/dns-health-checks). - Code: `auroradns` - Since: v0.4.0 Here is an example bash command using the Aurora DNS provider: ```bash AURORA_API_KEY=xxxxx \ AURORA_SECRET=yyyyyy \ lego --dns auroradns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AURORA_API_KEY` | API key or username to used | | `AURORA_SECRET` | Secret password to be used | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AURORA_ENDPOINT` | API endpoint URL | | `AURORA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `AURORA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `AURORA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://libcloud.readthedocs.io/en/latest/dns/drivers/auroradns.html#api-docs) - [Go client](https://github.com/nrdcg/auroradns) ================================================ FILE: docs/content/dns/zz_gen_autodns.md ================================================ --- title: "Autodns" date: 2019-03-03T16:39:46+01:00 draft: false slug: autodns dnsprovider: since: "v3.2.0" code: "autodns" url: "https://www.internetx.com/domains/autodns/" --- Configuration for [Autodns](https://www.internetx.com/domains/autodns/). - Code: `autodns` - Since: v3.2.0 Here is an example bash command using the Autodns provider: ```bash AUTODNS_API_USER=username \ AUTODNS_API_PASSWORD=supersecretpassword \ lego --dns autodns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AUTODNS_API_PASSWORD` | User Password | | `AUTODNS_API_USER` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AUTODNS_CONTEXT` | API context (4 for production, 1 for testing. Defaults to 4) | | `AUTODNS_ENDPOINT` | API endpoint URL, defaults to https://api.autodns.com/v1/ | | `AUTODNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `AUTODNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `AUTODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `AUTODNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://help.internetx.com/display/APIJSONEN) ================================================ FILE: docs/content/dns/zz_gen_axelname.md ================================================ --- title: "Axelname" date: 2019-03-03T16:39:46+01:00 draft: false slug: axelname dnsprovider: since: "v4.23.0" code: "axelname" url: "https://axelname.ru" --- Configuration for [Axelname](https://axelname.ru). - Code: `axelname` - Since: v4.23.0 Here is an example bash command using the Axelname provider: ```bash AXELNAME_NICKNAME="yyy" \ AXELNAME_TOKEN="xxx" \ lego --dns axelname -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AXELNAME_NICKNAME` | Account nickname | | `AXELNAME_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AXELNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `AXELNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `AXELNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `AXELNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://axelname.ru/static/content/files/axelname_api_rest_lite.pdf) ================================================ FILE: docs/content/dns/zz_gen_azion.md ================================================ --- title: "Azion" date: 2019-03-03T16:39:46+01:00 draft: false slug: azion dnsprovider: since: "v4.24.0" code: "azion" url: "https://www.azion.com/en/products/edge-dns/" --- Configuration for [Azion](https://www.azion.com/en/products/edge-dns/). - Code: `azion` - Since: v4.24.0 Here is an example bash command using the Azion provider: ```bash AZION_PERSONAL_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns azion -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AZION_PERSONAL_TOKEN` | Your Azion personal token. | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AZION_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `AZION_PAGE_SIZE` | The page size for the API request (Default: 50) | | `AZION_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `AZION_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `AZION_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.azion.com/) - [Go client](https://github.com/aziontech/azionapi-go-sdk) ================================================ FILE: docs/content/dns/zz_gen_azure.md ================================================ --- title: "Azure (deprecated)" date: 2019-03-03T16:39:46+01:00 draft: false slug: azure dnsprovider: since: "v0.4.0" code: "azure" url: "https://azure.microsoft.com/services/dns/" --- Configuration for [Azure (deprecated)](https://azure.microsoft.com/services/dns/). - Code: `azure` - Since: v0.4.0 {{% notice note %}} _Please contribute by adding a CLI example._ {{% /notice %}} ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AZURE_CLIENT_ID` | Client ID | | `AZURE_CLIENT_SECRET` | Client secret | | `AZURE_ENVIRONMENT` | Azure environment, one of: public, usgovernment, german, and china | | `AZURE_RESOURCE_GROUP` | Resource group | | `AZURE_SUBSCRIPTION_ID` | Subscription ID | | `AZURE_TENANT_ID` | Tenant ID | | `instance metadata service` | If the credentials are **not** set via the environment, then it will attempt to get a bearer token via the [instance metadata service](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service). | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AZURE_METADATA_ENDPOINT` | Metadata Service endpoint URL | | `AZURE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `AZURE_PRIVATE_ZONE` | Set to true to use Azure Private DNS Zones and not public | | `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | | `AZURE_ZONE_NAME` | Zone name to use inside Azure DNS service to add the TXT record in | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.microsoft.com/en-us/go/azure/) - [Go client](https://github.com/Azure/azure-sdk-for-go) ================================================ FILE: docs/content/dns/zz_gen_azuredns.md ================================================ --- title: "Azure DNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: azuredns dnsprovider: since: "v4.13.0" code: "azuredns" url: "https://azure.microsoft.com/services/dns/" --- Configuration for [Azure DNS](https://azure.microsoft.com/services/dns/). - Code: `azuredns` - Since: v4.13.0 Here is an example bash command using the Azure DNS provider: ```bash ### Using client secret AZURE_CLIENT_ID= \ AZURE_TENANT_ID= \ AZURE_CLIENT_SECRET= \ lego --dns azuredns -d '*.example.com' -d example.com run ### Using client certificate AZURE_CLIENT_ID= \ AZURE_TENANT_ID= \ AZURE_CLIENT_CERTIFICATE_PATH= \ lego --dns azuredns -d '*.example.com' -d example.com run ### Using Azure CLI az login \ lego --dns azuredns -d '*.example.com' -d example.com run ### Using Managed Identity (Azure VM) AZURE_TENANT_ID= \ AZURE_RESOURCE_GROUP= \ lego --dns azuredns -d '*.example.com' -d example.com run ### Using Managed Identity (Azure Arc) AZURE_TENANT_ID= \ IMDS_ENDPOINT=http://localhost:40342 \ IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token \ lego --dns azuredns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AZURE_CLIENT_CERTIFICATE_PATH` | Client certificate path | | `AZURE_CLIENT_ID` | Client ID | | `AZURE_CLIENT_SECRET` | Client secret | | `AZURE_TENANT_ID` | Tenant ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AZURE_AUTH_METHOD` | Specify which authentication method to use | | `AZURE_AUTH_MSI_TIMEOUT` | Managed Identity timeout duration | | `AZURE_ENVIRONMENT` | Azure environment, one of: public, usgovernment, and china | | `AZURE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `AZURE_PRIVATE_ZONE` | Set to true to use Azure Private DNS Zones and not public | | `AZURE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `AZURE_RESOURCE_GROUP` | DNS zone resource group | | `AZURE_SERVICEDISCOVERY_FILTER` | Advanced ServiceDiscovery filter using Kusto query condition | | `AZURE_SUBSCRIPTION_ID` | DNS zone subscription ID | | `AZURE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | | `AZURE_ZONE_NAME` | Zone name to use inside Azure DNS service to add the TXT record in | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description Several authentication methods can be used to authenticate against Azure DNS API. ### Default Azure Credentials (default option) Default Azure Credentials automatically detects in the following locations and prioritized in the following order: 1. Environment variables for client secret: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET` 2. Environment variables for client certificate: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_CERTIFICATE_PATH` 3. Workload identity for resources hosted in Azure environment (see below) 4. Shared credentials (defaults to `~/.azure` folder), used by Azure CLI Link: - [Azure Authentication](https://learn.microsoft.com/en-us/azure/developer/go/azure-sdk-authentication) ### Environment variables #### Service Discovery Lego automatically finds all visible Azure (private) DNS zones using [Azure ResourceGraph query](https://learn.microsoft.com/en-us/azure/governance/resource-graph/). This can be limited by specifying environment variable `AZURE_SUBSCRIPTION_ID` and/or `AZURE_RESOURCE_GROUP` which limits the DNS zones to only a subscription or to one resourceGroup. Additionally environment variable `AZURE_SERVICEDISCOVERY_FILTER` can be used to filter DNS zones with an addition Kusto filter eg: ``` resources | where type =~ "microsoft.network/dnszones" | ${AZURE_SERVICEDISCOVERY_FILTER} | project subscriptionId, resourceGroup, name ``` #### Client secret The Azure Credentials can be configured using the following environment variables: * AZURE_CLIENT_ID = "Client ID" * AZURE_CLIENT_SECRET = "Client secret" * AZURE_TENANT_ID = "Tenant ID" This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `env`. #### Client certificate The Azure Credentials can be configured using the following environment variables: * AZURE_CLIENT_ID = "Client ID" * AZURE_CLIENT_CERTIFICATE_PATH = "Client certificate path" * AZURE_TENANT_ID = "Tenant ID" This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `env`. ### Workload identity Workload identity allows workloads running Azure Kubernetes Services (AKS) clusters to authenticate as an Azure AD application identity using federated credentials. This must be configured in kubernetes workload deployment in one hand and on the Azure AD application registration in the other hand. Here is a summary of the steps to follow to use it : * create a `ServiceAccount` resource, add following annotations to reference the targeted Azure AD application registration : `azure.workload.identity/client-id` and `azure.workload.identity/tenant-id`. * on the `Deployment` resource you must reference the previous `ServiceAccount` and add the following label : `azure.workload.identity/use: "true"`. * create a federated credentials of type `Kubernetes accessing Azure resources`, add the cluster issuer URL and add the namespace and name of your kubernetes service account. Link : - [Azure AD Workload identity](https://azure.github.io/azure-workload-identity/docs/topics/service-account-labels-and-annotations.html) This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `wli`. ### Azure Managed Identity #### Azure Managed Identity (with Azure workload) The Azure Managed Identity service allows linking Azure AD identities to Azure resources, without needing to manually manage client IDs and secrets. Workloads with a Managed Identity can manage their own certificates, with permissions on specific domain names set using IAM assignments. For this to work, the Managed Identity requires the **Reader** role on the target DNS Zone, and the **DNS Zone Contributor** on the relevant `_acme-challenge` TXT records. For example, to allow a Managed Identity to create a certificate for "fw01.lab.example.com", using Azure CLI: ```bash export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" export AZURE_RESOURCE_GROUP="rg1" export SERVICE_PRINCIPAL_ID="00000000-0000-0000-0000-000000000000" export AZURE_DNS_ZONE="lab.example.com" export AZ_HOSTNAME="fw01" export AZ_RECORD_SET="_acme-challenge.${AZ_HOSTNAME}" az role assignment create \ --assignee "${SERVICE_PRINCIPAL_ID}" \ --role "Reader" \ --scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${AZURE_DNS_ZONE}" az role assignment create \ --assignee "${SERVICE_PRINCIPAL_ID}" \ --role "DNS Zone Contributor" \ --scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${AZURE_DNS_ZONE}/TXT/${AZ_RECORD_SET}" ``` A timeout wrapper is configured for this authentication method. The duration can be configured by setting the `AZURE_AUTH_MSI_TIMEOUT`. The default timeout is 2 seconds. This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `msi`. #### Azure Managed Identity (with Azure Arc) The Azure Arc agent provides the ability to use a Managed Identity on resources hosted outside of Azure (such as on-prem virtual machines, or VMs in another cloud provider). While the upstream `azidentity` SDK will try to automatically identify and use the Azure Arc metadata service, if you get `azuredns: DefaultAzureCredential: failed to acquire a token.` error messages, you may need to set the environment variables: * `IMDS_ENDPOINT=http://localhost:40342` * `IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token` A timeout wrapper is configured for this authentication method. The duration can be configured by setting the `AZURE_AUTH_MSI_TIMEOUT`. The default timeout is 2 seconds. This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `msi`. ### Azure CLI The Azure CLI is a command-line tool provided by Microsoft to interact with Azure resources. It provides an easy way to authenticate by simply running `az login` command. The generated token will be cached by default in the `~/.azure` folder. This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `cli`. ### Open ID Connect Open ID Connect is a mechanism that establish a trust relationship between a running environment and the Azure AD identity provider. It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oidc`. ### Azure DevOps Pipelines It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `pipeline`. ## More information - [API documentation](https://docs.microsoft.com/en-us/go/azure/) - [Go client](https://github.com/Azure/azure-sdk-for-go) ================================================ FILE: docs/content/dns/zz_gen_baiducloud.md ================================================ --- title: "Baidu Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: baiducloud dnsprovider: since: "v4.23.0" code: "baiducloud" url: "https://cloud.baidu.com" --- Configuration for [Baidu Cloud](https://cloud.baidu.com). - Code: `baiducloud` - Since: v4.23.0 Here is an example bash command using the Baidu Cloud provider: ```bash BAIDUCLOUD_ACCESS_KEY_ID="xxx" \ BAIDUCLOUD_SECRET_ACCESS_KEY="yyy" \ lego --dns baiducloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BAIDUCLOUD_ACCESS_KEY_ID` | Access key | | `BAIDUCLOUD_SECRET_ACCESS_KEY` | Secret access key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BAIDUCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `BAIDUCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `BAIDUCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://cloud.baidu.com/doc/DNS/s/El4s7lssr) - [Go client](https://github.com/baidubce/bce-sdk-go) ================================================ FILE: docs/content/dns/zz_gen_beget.md ================================================ --- title: "Beget.com" date: 2019-03-03T16:39:46+01:00 draft: false slug: beget dnsprovider: since: "v4.27.0" code: "beget" url: "https://beget.com/" --- Configuration for [Beget.com](https://beget.com/). - Code: `beget` - Since: v4.27.0 Here is an example bash command using the Beget.com provider: ```bash BEGET_USERNAME=xxxxxx \ BEGET_PASSWORD=yyyyyy \ lego --dns beget -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BEGET_PASSWORD` | API password | | `BEGET_USERNAME` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BEGET_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `BEGET_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | | `BEGET_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `BEGET_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://beget.com/ru/kb/api/funkczii-upravleniya-dns) ================================================ FILE: docs/content/dns/zz_gen_binarylane.md ================================================ --- title: "Binary Lane" date: 2019-03-03T16:39:46+01:00 draft: false slug: binarylane dnsprovider: since: "v4.26.0" code: "binarylane" url: "https://www.binarylane.com.au/" --- Configuration for [Binary Lane](https://www.binarylane.com.au/). - Code: `binarylane` - Since: v4.26.0 Here is an example bash command using the Binary Lane provider: ```bash BINARYLANE_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns binarylane -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BINARYLANE_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BINARYLANE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `BINARYLANE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `BINARYLANE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `BINARYLANE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.binarylane.com.au/reference/#tag/Domains) ================================================ FILE: docs/content/dns/zz_gen_bindman.md ================================================ --- title: "Bindman" date: 2019-03-03T16:39:46+01:00 draft: false slug: bindman dnsprovider: since: "v2.6.0" code: "bindman" url: "https://github.com/labbsr0x/bindman-dns-webhook" --- Configuration for [Bindman](https://github.com/labbsr0x/bindman-dns-webhook). - Code: `bindman` - Since: v2.6.0 Here is an example bash command using the Bindman provider: ```bash BINDMAN_MANAGER_ADDRESS= \ lego --dns bindman -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BINDMAN_MANAGER_ADDRESS` | The server URL, should have scheme, hostname, and port (if required) of the Bindman-DNS Manager server | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BINDMAN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | | `BINDMAN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `BINDMAN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://gitlab.isc.org/isc-projects/bind9) - [Go client](https://github.com/labbsr0x/bindman-dns-webhook) ================================================ FILE: docs/content/dns/zz_gen_bluecat.md ================================================ --- title: "Bluecat" date: 2019-03-03T16:39:46+01:00 draft: false slug: bluecat dnsprovider: since: "v0.5.0" code: "bluecat" url: "https://www.bluecatnetworks.com" --- Configuration for [Bluecat](https://www.bluecatnetworks.com). - Code: `bluecat` - Since: v0.5.0 Here is an example bash command using the Bluecat provider: ```bash BLUECAT_PASSWORD=mypassword \ BLUECAT_DNS_VIEW=myview \ BLUECAT_USER_NAME=myusername \ BLUECAT_CONFIG_NAME=myconfig \ BLUECAT_SERVER_URL=https://bam.example.com \ BLUECAT_TTL=30 \ lego --dns bluecat -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BLUECAT_CONFIG_NAME` | Configuration name | | `BLUECAT_DNS_VIEW` | External DNS View Name | | `BLUECAT_PASSWORD` | API password | | `BLUECAT_SERVER_URL` | The server URL, should have scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve | | `BLUECAT_USER_NAME` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BLUECAT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `BLUECAT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `BLUECAT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `BLUECAT_SKIP_DEPLOY` | Skip deployements | | `BLUECAT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/REST-API/9.1.0) ================================================ FILE: docs/content/dns/zz_gen_bluecatv2.md ================================================ --- title: "Bluecat v2" date: 2019-03-03T16:39:46+01:00 draft: false slug: bluecatv2 dnsprovider: since: "v4.32.0" code: "bluecatv2" url: "https://www.bluecatnetworks.com" --- Configuration for [Bluecat v2](https://www.bluecatnetworks.com). - Code: `bluecatv2` - Since: v4.32.0 Here is an example bash command using the Bluecat v2 provider: ```bash BLUECATV2_SERVER_URL="https://example.com" \ BLUECATV2_USERNAME="xxx" \ BLUECATV2_PASSWORD="yyy" \ BLUECATV2_CONFIG_NAME="myConfiguration" \ BLUECATV2_VIEW_NAME="myView" \ lego --dns bluecatv2 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BLUECATV2_CONFIG_NAME` | Configuration name | | `BLUECATV2_PASSWORD` | API password | | `BLUECATV2_USERNAME` | API username | | `BLUECATV2_VIEW_NAME` | DNS View Name | | `BLUECAT_SERVER_URL` | The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BLUECATV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `BLUECATV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `BLUECATV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `BLUECATV2_SKIP_DEPLOY` | Skip quick deployements | | `BLUECATV2_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0) ================================================ FILE: docs/content/dns/zz_gen_bookmyname.md ================================================ --- title: "BookMyName" date: 2019-03-03T16:39:46+01:00 draft: false slug: bookmyname dnsprovider: since: "v4.23.0" code: "bookmyname" url: "https://www.bookmyname.com/" --- Configuration for [BookMyName](https://www.bookmyname.com/). - Code: `bookmyname` - Since: v4.23.0 Here is an example bash command using the BookMyName provider: ```bash BOOKMYNAME_USERNAME="xxx" \ BOOKMYNAME_PASSWORD="yyy" \ lego --dns bookmyname -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BOOKMYNAME_PASSWORD` | Password | | `BOOKMYNAME_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BOOKMYNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `BOOKMYNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `BOOKMYNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `BOOKMYNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://fr.faqs.bookmyname.com/frfaqs/dyndns) ================================================ FILE: docs/content/dns/zz_gen_brandit.md ================================================ --- title: "Brandit (deprecated)" date: 2019-03-03T16:39:46+01:00 draft: false slug: brandit dnsprovider: since: "v4.11.0" code: "brandit" url: "https://www.brandit.com/" --- Brandit has been acquired by Abion. Abion has a different API. If you are a Brandit/Albion user, you can try the PR https://github.com/go-acme/lego/pull/2112. - Code: `brandit` - Since: v4.11.0 Here is an example bash command using the Brandit (deprecated) provider: ```bash BRANDIT_API_KEY=xxxxxxxxxxxxxxxxxxxxx \ BRANDIT_API_USERNAME=yyyyyyyyyyyyyyyyyyyy \ lego --dns brandit -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BRANDIT_API_KEY` | The API key | | `BRANDIT_API_USERNAME` | The API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BRANDIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `BRANDIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `BRANDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | | `BRANDIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://portal.brandit.com/apidocv3) ================================================ FILE: docs/content/dns/zz_gen_bunny.md ================================================ --- title: "Bunny" date: 2019-03-03T16:39:46+01:00 draft: false slug: bunny dnsprovider: since: "v4.11.0" code: "bunny" url: "https://bunny.net" --- Configuration for [Bunny](https://bunny.net). - Code: `bunny` - Since: v4.11.0 Here is an example bash command using the Bunny provider: ```bash BUNNY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ lego --dns bunny -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `BUNNY_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `BUNNY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `BUNNY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `BUNNY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `BUNNY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.bunny.net/reference/dnszonepublic_index) ================================================ FILE: docs/content/dns/zz_gen_checkdomain.md ================================================ --- title: "Checkdomain" date: 2019-03-03T16:39:46+01:00 draft: false slug: checkdomain dnsprovider: since: "v3.3.0" code: "checkdomain" url: "https://checkdomain.de/" --- Configuration for [Checkdomain](https://checkdomain.de/). - Code: `checkdomain` - Since: v3.3.0 Here is an example bash command using the Checkdomain provider: ```bash CHECKDOMAIN_TOKEN=yoursecrettoken \ lego --dns checkdomain -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CHECKDOMAIN_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CHECKDOMAIN_ENDPOINT` | API endpoint URL, defaults to https://api.checkdomain.de | | `CHECKDOMAIN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CHECKDOMAIN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 300) | | `CHECKDOMAIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 7) | | `CHECKDOMAIN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developer.checkdomain.de/reference/) ================================================ FILE: docs/content/dns/zz_gen_civo.md ================================================ --- title: "Civo" date: 2019-03-03T16:39:46+01:00 draft: false slug: civo dnsprovider: since: "v4.9.0" code: "civo" url: "https://civo.com" --- Configuration for [Civo](https://civo.com). - Code: `civo` - Since: v4.9.0 Here is an example bash command using the Civo provider: ```bash CIVO_TOKEN=xxxxxx \ lego --dns civo -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CIVO_TOKEN` | Authentication token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CIVO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | | `CIVO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `CIVO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.civo.com/api/dns) ================================================ FILE: docs/content/dns/zz_gen_clouddns.md ================================================ --- title: "CloudDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: clouddns dnsprovider: since: "v3.6.0" code: "clouddns" url: "https://vshosting.eu/" --- Configuration for [CloudDNS](https://vshosting.eu/). - Code: `clouddns` - Since: v3.6.0 Here is an example bash command using the CloudDNS provider: ```bash CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \ CLOUDDNS_EMAIL=you@example.com \ CLOUDDNS_PASSWORD=b9841238feb177a84330f \ lego --dns clouddns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CLOUDDNS_CLIENT_ID` | Client ID | | `CLOUDDNS_EMAIL` | Account email | | `CLOUDDNS_PASSWORD` | Account password | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CLOUDDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CLOUDDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `CLOUDDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `CLOUDDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://admin.vshosting.cloud/clouddns/swagger/) ================================================ FILE: docs/content/dns/zz_gen_cloudflare.md ================================================ --- title: "Cloudflare" date: 2019-03-03T16:39:46+01:00 draft: false slug: cloudflare dnsprovider: since: "v0.3.0" code: "cloudflare" url: "https://www.cloudflare.com/dns/" --- Configuration for [Cloudflare](https://www.cloudflare.com/dns/). - Code: `cloudflare` - Since: v0.3.0 Here is an example bash command using the Cloudflare provider: ```bash CLOUDFLARE_EMAIL=you@example.com \ CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ lego --dns cloudflare -d '*.example.com' -d example.com run # or CLOUDFLARE_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ lego --dns cloudflare -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CF_API_EMAIL` | Account email | | `CF_API_KEY` | API key | | `CF_DNS_API_TOKEN` | API token with DNS:Edit permission (since v3.1.0) | | `CF_ZONE_API_TOKEN` | API token with Zone:Read permission (since v3.1.0) | | `CLOUDFLARE_API_KEY` | Alias to CF_API_KEY | | `CLOUDFLARE_DNS_API_TOKEN` | Alias to CF_DNS_API_TOKEN | | `CLOUDFLARE_EMAIL` | Alias to CF_API_EMAIL | | `CLOUDFLARE_ZONE_API_TOKEN` | Alias to CF_ZONE_API_TOKEN | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CLOUDFLARE_BASE_URL` | API base URL (Default: https://api.cloudflare.com/client/v4) | | `CLOUDFLARE_HTTP_TIMEOUT` | API request timeout in seconds (Default: ) | | `CLOUDFLARE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `CLOUDFLARE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `CLOUDFLARE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description You may use `CF_API_EMAIL` and `CF_API_KEY` to authenticate, or `CF_DNS_API_TOKEN`, or `CF_DNS_API_TOKEN` and `CF_ZONE_API_TOKEN`. ### API keys If using API keys (`CF_API_EMAIL` and `CF_API_KEY`), the Global API Key needs to be used, not the Origin CA Key. Please be aware, that this in principle allows Lego to read and change *everything* related to this account. ### API tokens With API tokens (`CF_DNS_API_TOKEN`, and optionally `CF_ZONE_API_TOKEN`), very specific access can be granted to your resources at Cloudflare. See this [Cloudflare announcement](https://blog.cloudflare.com/api-tokens-general-availability/) for details. The main resources Lego cares for are the DNS entries for your Zones. It also needs to resolve a domain name to an internal Zone ID in order to manipulate DNS entries. Hence, you should create an API token with the following permissions: * Zone / Zone / Read * Zone / DNS / Edit You also need to scope the access to all your domains for this to work. Then pass the API token as `CF_DNS_API_TOKEN` to Lego. **Alternatively,** if you prefer a more strict set of privileges, you can split the access tokens: * Create one with *Zone / Zone / Read* permissions and scope it to all your zones or just the individual zone you need to edit. This is needed to resolve domain names to Zone IDs and can be shared among multiple Lego installations. Pass this API token as `CF_ZONE_API_TOKEN` to Lego. * Create another API token with *Zone / DNS / Edit* permissions and set the scope to the domains you want to manage with a single Lego installation. Pass this token as `CF_DNS_API_TOKEN` to Lego. * Repeat the previous step for each host you want to run Lego on. * It is possible to use the same api token for both variables if it is given `Zone:Read` and `DNS:Edit` permission for the zone. This "paranoid" setup is mainly interesting for users who manage many zones/domains with a single Cloudflare account. It follows the principle of least privilege and limits the possible damage, should one of the hosts become compromised. ## More information - [API documentation](https://api.cloudflare.com/) - [Go client](https://github.com/cloudflare/cloudflare-go) ================================================ FILE: docs/content/dns/zz_gen_cloudns.md ================================================ --- title: "ClouDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: cloudns dnsprovider: since: "v2.3.0" code: "cloudns" url: "https://www.cloudns.net" --- Configuration for [ClouDNS](https://www.cloudns.net). - Code: `cloudns` - Since: v2.3.0 Here is an example bash command using the ClouDNS provider: ```bash CLOUDNS_AUTH_ID=xxxx \ CLOUDNS_AUTH_PASSWORD=yyyy \ lego --dns cloudns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CLOUDNS_AUTH_ID` | The API user ID | | `CLOUDNS_AUTH_PASSWORD` | The password for API user ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CLOUDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CLOUDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `CLOUDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) | | `CLOUDNS_SUB_AUTH_ID` | The API sub user ID | | `CLOUDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.cloudns.net/wiki/article/42/) ================================================ FILE: docs/content/dns/zz_gen_cloudru.md ================================================ --- title: "Cloud.ru" date: 2019-03-03T16:39:46+01:00 draft: false slug: cloudru dnsprovider: since: "v4.14.0" code: "cloudru" url: "https://cloud.ru" --- Configuration for [Cloud.ru](https://cloud.ru). - Code: `cloudru` - Since: v4.14.0 Here is an example bash command using the Cloud.ru provider: ```bash CLOUDRU_SERVICE_INSTANCE_ID=ppp \ CLOUDRU_KEY_ID=xxx \ CLOUDRU_SECRET=yyy \ lego --dns cloudru -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CLOUDRU_KEY_ID` | Key ID (login) | | `CLOUDRU_SECRET` | Key Secret | | `CLOUDRU_SERVICE_INSTANCE_ID` | Service Instance ID (parentId) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CLOUDRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CLOUDRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `CLOUDRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `CLOUDRU_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) | | `CLOUDRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://cloud.ru/ru/docs/clouddns/ug/topics/api-ref.html) ================================================ FILE: docs/content/dns/zz_gen_cloudxns.md ================================================ --- title: "CloudXNS (Deprecated)" date: 2019-03-03T16:39:46+01:00 draft: false slug: cloudxns dnsprovider: since: "v0.5.0" code: "cloudxns" url: "https://github.com/go-acme/lego/issues/2323" --- The CloudXNS DNS provider has shut down. - Code: `cloudxns` - Since: v0.5.0 Here is an example bash command using the CloudXNS (Deprecated) provider: ```bash CLOUDXNS_API_KEY=xxxx \ CLOUDXNS_SECRET_KEY=yyyy \ lego --dns cloudxns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CLOUDXNS_API_KEY` | The API key | | `CLOUDXNS_SECRET_KEY` | The API secret key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CLOUDXNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: ) | | `CLOUDXNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: ) | | `CLOUDXNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: ) | | `CLOUDXNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: ) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ================================================ FILE: docs/content/dns/zz_gen_com35.md ================================================ --- title: "35.com/三五互联" date: 2019-03-03T16:39:46+01:00 draft: false slug: com35 dnsprovider: since: "v4.31.0" code: "com35" url: "https://www.35.cn/" --- Configuration for [35.com/三五互联](https://www.35.cn/). - Code: `com35` - Since: v4.31.0 Here is an example bash command using the 35.com/三五互联 provider: ```bash COM35_USERNAME="xxx" \ COM35_PASSWORD="yyy" \ lego --dns com35 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `COM35_PASSWORD` | API password | | `COM35_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `COM35_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `COM35_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `COM35_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `COM35_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.35.cn/CustomerCenter/doc/domain_v2.html) ================================================ FILE: docs/content/dns/zz_gen_conoha.md ================================================ --- title: "ConoHa v2" date: 2019-03-03T16:39:46+01:00 draft: false slug: conoha dnsprovider: since: "v1.2.0" code: "conoha" url: "https://www.conoha.jp/" --- Configuration for [ConoHa v2](https://www.conoha.jp/). - Code: `conoha` - Since: v1.2.0 Here is an example bash command using the ConoHa v2 provider: ```bash CONOHA_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \ CONOHA_API_USERNAME=xxxx \ CONOHA_API_PASSWORD=yyyy \ lego --dns conoha -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CONOHA_API_PASSWORD` | The API password | | `CONOHA_API_USERNAME` | The API username | | `CONOHA_TENANT_ID` | Tenant ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CONOHA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CONOHA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `CONOHA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `CONOHA_REGION` | The region (Default: tyo1) | | `CONOHA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://doc.conoha.jp/reference/api-vps2/api-dns-vps2) ================================================ FILE: docs/content/dns/zz_gen_conohav3.md ================================================ --- title: "ConoHa v3" date: 2019-03-03T16:39:46+01:00 draft: false slug: conohav3 dnsprovider: since: "v4.24.0" code: "conohav3" url: "https://www.conoha.jp/" --- Configuration for [ConoHa v3](https://www.conoha.jp/). - Code: `conohav3` - Since: v4.24.0 Here is an example bash command using the ConoHa v3 provider: ```bash CONOHAV3_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \ CONOHAV3_API_USER_ID=xxxx \ CONOHAV3_API_PASSWORD=yyyy \ lego --dns conohav3 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CONOHAV3_API_PASSWORD` | The API password | | `CONOHAV3_API_USER_ID` | The API user ID | | `CONOHAV3_TENANT_ID` | Tenant ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CONOHAV3_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CONOHAV3_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `CONOHAV3_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `CONOHAV3_REGION` | The region (Default: c3j1) | | `CONOHAV3_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/) ================================================ FILE: docs/content/dns/zz_gen_constellix.md ================================================ --- title: "Constellix" date: 2019-03-03T16:39:46+01:00 draft: false slug: constellix dnsprovider: since: "v3.4.0" code: "constellix" url: "https://constellix.com" --- Configuration for [Constellix](https://constellix.com). - Code: `constellix` - Since: v3.4.0 Here is an example bash command using the Constellix provider: ```bash CONSTELLIX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ CONSTELLIX_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ lego --dns constellix -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CONSTELLIX_API_KEY` | User API key | | `CONSTELLIX_SECRET_KEY` | User secret key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CONSTELLIX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CONSTELLIX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `CONSTELLIX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `CONSTELLIX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api-docs.constellix.com) ================================================ FILE: docs/content/dns/zz_gen_corenetworks.md ================================================ --- title: "Core-Networks" date: 2019-03-03T16:39:46+01:00 draft: false slug: corenetworks dnsprovider: since: "v4.20.0" code: "corenetworks" url: "https://www.core-networks.de/" --- Configuration for [Core-Networks](https://www.core-networks.de/). - Code: `corenetworks` - Since: v4.20.0 Here is an example bash command using the Core-Networks provider: ```bash CORENETWORKS_LOGIN="xxxx" \ CORENETWORKS_PASSWORD="yyyy" \ lego --dns corenetworks -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CORENETWORKS_LOGIN` | The username of the API account | | `CORENETWORKS_PASSWORD` | The password | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CORENETWORKS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CORENETWORKS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `CORENETWORKS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `CORENETWORKS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `CORENETWORKS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://beta.api.core-networks.de/doc/) ================================================ FILE: docs/content/dns/zz_gen_cpanel.md ================================================ --- title: "CPanel/WHM" date: 2019-03-03T16:39:46+01:00 draft: false slug: cpanel dnsprovider: since: "v4.16.0" code: "cpanel" url: "https://cpanel.net/" --- Configuration for [CPanel/WHM](https://cpanel.net/). - Code: `cpanel` - Since: v4.16.0 Here is an example bash command using the CPanel/WHM provider: ```bash ### CPANEL (default) CPANEL_USERNAME="yyyy" \ CPANEL_TOKEN="xxxx" \ CPANEL_BASE_URL="https://example.com:2083" \ lego --dns cpanel -d '*.example.com' -d example.com run ## WHM CPANEL_MODE=whm \ CPANEL_USERNAME="yyyy" \ CPANEL_TOKEN="xxxx" \ CPANEL_BASE_URL="https://example.com:2087" \ lego --dns cpanel -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CPANEL_BASE_URL` | API server URL | | `CPANEL_TOKEN` | API token | | `CPANEL_USERNAME` | username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CPANEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CPANEL_MODE` | use cpanel API or WHM API (Default: cpanel) | | `CPANEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `CPANEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `CPANEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information ================================================ FILE: docs/content/dns/zz_gen_czechia.md ================================================ --- title: "Czechia" date: 2019-03-03T16:39:46+01:00 draft: false slug: czechia dnsprovider: since: "v4.33.0" code: "czechia" url: "https://www.czechia.com/" --- Configuration for [Czechia](https://www.czechia.com/). - Code: `czechia` - Since: v4.33.0 Here is an example bash command using the Czechia provider: ```bash CZECHIA_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns czechia -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `CZECHIA_TOKEN` | Authorization token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `CZECHIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `CZECHIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `CZECHIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `CZECHIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.czechia.com/swagger/index.html) ================================================ FILE: docs/content/dns/zz_gen_ddnss.md ================================================ --- title: "DDnss (DynDNS Service)" date: 2019-03-03T16:39:46+01:00 draft: false slug: ddnss dnsprovider: since: "v4.32.0" code: "ddnss" url: "https://ddnss.de/" --- Configuration for [DDnss (DynDNS Service)](https://ddnss.de/). - Code: `ddnss` - Since: v4.32.0 Here is an example bash command using the DDnss (DynDNS Service) provider: ```bash DDNSS_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns ddnss -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DDNSS_KEY` | Update key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DDNSS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DDNSS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `DDNSS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DDNSS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `DDNSS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://ddnss.de/info.php) ================================================ FILE: docs/content/dns/zz_gen_derak.md ================================================ --- title: "Derak Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: derak dnsprovider: since: "v4.12.0" code: "derak" url: "https://derak.cloud/" --- Configuration for [Derak Cloud](https://derak.cloud/). - Code: `derak` - Since: v4.12.0 Here is an example bash command using the Derak Cloud provider: ```bash DERAK_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns derak -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DERAK_API_KEY` | The API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DERAK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DERAK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `DERAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `DERAK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `DERAK_WEBSITE_ID` | Force the zone/website ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ================================================ FILE: docs/content/dns/zz_gen_desec.md ================================================ --- title: "deSEC.io" date: 2019-03-03T16:39:46+01:00 draft: false slug: desec dnsprovider: since: "v3.7.0" code: "desec" url: "https://desec.io" --- Configuration for [deSEC.io](https://desec.io). - Code: `desec` - Since: v3.7.0 Here is an example bash command using the deSEC.io provider: ```bash DESEC_TOKEN=x-xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns desec -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DESEC_TOKEN` | Domain token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DESEC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DESEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | | `DESEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `DESEC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://desec.readthedocs.io/en/latest/) ================================================ FILE: docs/content/dns/zz_gen_designate.md ================================================ --- title: "Designate DNSaaS for Openstack" date: 2019-03-03T16:39:46+01:00 draft: false slug: designate dnsprovider: since: "v2.2.0" code: "designate" url: "https://docs.openstack.org/designate/latest/" --- Configuration for [Designate DNSaaS for Openstack](https://docs.openstack.org/designate/latest/). - Code: `designate` - Since: v2.2.0 Here is an example bash command using the Designate DNSaaS for Openstack provider: ```bash # With a `clouds.yaml` OS_CLOUD=my_openstack \ lego --dns designate -d '*.example.com' -d example.com run # or OS_AUTH_URL=https://openstack.example.org \ OS_REGION_NAME=RegionOne \ OS_PROJECT_ID=23d4522a987d4ab529f722a007c27846 OS_USERNAME=myuser \ OS_PASSWORD=passw0rd \ lego --dns designate -d '*.example.com' -d example.com run # or OS_AUTH_URL=https://openstack.example.org \ OS_REGION_NAME=RegionOne \ OS_AUTH_TYPE=v3applicationcredential \ OS_APPLICATION_CREDENTIAL_ID=imn74uq0or7dyzz20dwo1ytls4me8dry \ OS_APPLICATION_CREDENTIAL_SECRET=68FuSPSdQqkFQYH5X1OoriEIJOwyLtQ8QSqXZOc9XxFK1A9tzZT6He2PfPw0OMja \ lego --dns designate -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `OS_APPLICATION_CREDENTIAL_ID` | Application credential ID | | `OS_APPLICATION_CREDENTIAL_NAME` | Application credential name | | `OS_APPLICATION_CREDENTIAL_SECRET` | Application credential secret | | `OS_AUTH_URL` | Identity endpoint URL | | `OS_PASSWORD` | Password | | `OS_PROJECT_NAME` | Project name | | `OS_REGION_NAME` | Region name | | `OS_USERNAME` | Username | | `OS_USER_ID` | User ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DESIGNATE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `DESIGNATE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | | `DESIGNATE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) | | `DESIGNATE_ZONE_NAME` | The zone name to use in the OpenStack Project to manage TXT records. | | `OS_PROJECT_ID` | Project ID | | `OS_TENANT_NAME` | Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description There are three main ways of authenticating with Designate: 1. The first one is by using the `OS_CLOUD` environment variable and a `clouds.yaml` file. 2. The second one is using your username and password, via the `OS_USERNAME`, `OS_PASSWORD` and `OS_PROJECT_NAME` environment variables. 3. The third one is by using an application credential, via the `OS_APPLICATION_CREDENTIAL_*` and `OS_USER_ID` environment variables. For the username/password and application methods, the `OS_AUTH_URL` and `OS_REGION_NAME` environment variables are required. For more information, you can read about the different methods of authentication with OpenStack in the Keystone's documentation and the gophercloud documentation: - [Keystone username/password](https://docs.openstack.org/keystone/latest/user/supported_clients.html) - [Keystone application credentials](https://docs.openstack.org/keystone/latest/user/application_credentials.html) Public cloud providers with support for Designate: - [Fuga Cloud](https://fuga.cloud/) ## More information - [API documentation](https://docs.openstack.org/designate/latest/) - [Go client](https://pkg.go.dev/github.com/gophercloud/gophercloud/openstack/dns/v2) ================================================ FILE: docs/content/dns/zz_gen_digitalocean.md ================================================ --- title: "Digital Ocean" date: 2019-03-03T16:39:46+01:00 draft: false slug: digitalocean dnsprovider: since: "v0.3.0" code: "digitalocean" url: "https://www.digitalocean.com/docs/networking/dns/" --- Configuration for [Digital Ocean](https://www.digitalocean.com/docs/networking/dns/). - Code: `digitalocean` - Since: v0.3.0 Here is an example bash command using the Digital Ocean provider: ```bash DO_AUTH_TOKEN=xxxxxx \ lego --dns digitalocean -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DO_AUTH_TOKEN` | Authentication token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DO_API_URL` | The URL of the API | | `DO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `DO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.digitalocean.com/documentation/v2/#domain-records) ================================================ FILE: docs/content/dns/zz_gen_directadmin.md ================================================ --- title: "DirectAdmin" date: 2019-03-03T16:39:46+01:00 draft: false slug: directadmin dnsprovider: since: "v4.18.0" code: "directadmin" url: "https://www.directadmin.com" --- Configuration for [DirectAdmin](https://www.directadmin.com). - Code: `directadmin` - Since: v4.18.0 Here is an example bash command using the DirectAdmin provider: ```bash DIRECTADMIN_API_URL="http://example.com:2222" \ DIRECTADMIN_USERNAME=xxxx \ DIRECTADMIN_PASSWORD=yyy \ lego --dns directadmin -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DIRECTADMIN_API_URL` | URL of the API | | `DIRECTADMIN_PASSWORD` | API password | | `DIRECTADMIN_USERNAME` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DIRECTADMIN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DIRECTADMIN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `DIRECTADMIN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DIRECTADMIN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) | | `DIRECTADMIN_ZONE_NAME` | Zone name used to add the TXT record | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.directadmin.com/api.php) ================================================ FILE: docs/content/dns/zz_gen_dnsexit.md ================================================ --- title: "DNSExit" date: 2019-03-03T16:39:46+01:00 draft: false slug: dnsexit dnsprovider: since: "v4.32.0" code: "dnsexit" url: "https://dnsexit.com" --- Configuration for [DNSExit](https://dnsexit.com). - Code: `dnsexit` - Since: v4.32.0 Here is an example bash command using the DNSExit provider: ```bash DNSEXIT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns dnsexit -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DNSEXIT_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DNSEXIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DNSEXIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `DNSEXIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `DNSEXIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://dnsexit.com/dns/dns-api/) ================================================ FILE: docs/content/dns/zz_gen_dnshomede.md ================================================ --- title: "dnsHome.de" date: 2019-03-03T16:39:46+01:00 draft: false slug: dnshomede dnsprovider: since: "v4.10.0" code: "dnshomede" url: "https://www.dnshome.de" --- Configuration for [dnsHome.de](https://www.dnshome.de). - Code: `dnshomede` - Since: v4.10.0 Here is an example bash command using the dnsHome.de provider: ```bash DNSHOMEDE_CREDENTIALS=example.org:password \ lego --dns dnshomede -d '*.example.com' -d example.com run DNSHOMEDE_CREDENTIALS=my.example.org:password1,demo.example.org:password2 \ lego --dns dnshomede -d my.example.org -d demo.example.org ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DNSHOMEDE_CREDENTIALS` | Comma-separated list of domain:password credential pairs | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DNSHOMEDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DNSHOMEDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 1200) | | `DNSHOMEDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2) | | `DNSHOMEDE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ================================================ FILE: docs/content/dns/zz_gen_dnsimple.md ================================================ --- title: "DNSimple" date: 2019-03-03T16:39:46+01:00 draft: false slug: dnsimple dnsprovider: since: "v0.3.0" code: "dnsimple" url: "https://dnsimple.com/" --- Configuration for [DNSimple](https://dnsimple.com/). - Code: `dnsimple` - Since: v0.3.0 Here is an example bash command using the DNSimple provider: ```bash DNSIMPLE_OAUTH_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ lego --dns dnsimple -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DNSIMPLE_OAUTH_TOKEN` | OAuth token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DNSIMPLE_BASE_URL` | API endpoint URL | | `DNSIMPLE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `DNSIMPLE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DNSIMPLE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description `DNSIMPLE_BASE_URL` is optional and must be set to production (https://api.dnsimple.com). if `DNSIMPLE_BASE_URL` is not defined or empty, the production URL is used by default. While you can manage DNS records in the [DNSimple Sandbox environment](https://developer.dnsimple.com/sandbox/), DNS records will not resolve, and you will not be able to satisfy the ACME DNS challenge. To authenticate you need to provide a valid API token. HTTP Basic Authentication is intentionally not supported. ### API tokens You can [generate a new API token](https://support.dnsimple.com/articles/api-access-token/) from your account page. Only Account API tokens are supported, if you try to use a User API token you will receive an error message. ## More information - [API documentation](https://developer.dnsimple.com/v2/) - [Go client](https://github.com/dnsimple/dnsimple-go) ================================================ FILE: docs/content/dns/zz_gen_dnsmadeeasy.md ================================================ --- title: "DNS Made Easy" date: 2019-03-03T16:39:46+01:00 draft: false slug: dnsmadeeasy dnsprovider: since: "v0.4.0" code: "dnsmadeeasy" url: "https://dnsmadeeasy.com/" --- Configuration for [DNS Made Easy](https://dnsmadeeasy.com/). - Code: `dnsmadeeasy` - Since: v0.4.0 Here is an example bash command using the DNS Made Easy provider: ```bash DNSMADEEASY_API_KEY=xxxxxx \ DNSMADEEASY_API_SECRET=yyyyy \ lego --dns dnsmadeeasy -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DNSMADEEASY_API_KEY` | The API key | | `DNSMADEEASY_API_SECRET` | The API Secret key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DNSMADEEASY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `DNSMADEEASY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `DNSMADEEASY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DNSMADEEASY_SANDBOX` | Activate the sandbox (boolean) | | `DNSMADEEASY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api-docs.dnsmadeeasy.com/) ================================================ FILE: docs/content/dns/zz_gen_dnspod.md ================================================ --- title: "DNSPod (deprecated)" date: 2019-03-03T16:39:46+01:00 draft: false slug: dnspod dnsprovider: since: "v0.4.0" code: "dnspod" url: "https://www.dnspod.com/" --- Use the Tencent Cloud provider instead. - Code: `dnspod` - Since: v0.4.0 Here is an example bash command using the DNSPod (deprecated) provider: ```bash DNSPOD_API_KEY=xxxxxx \ lego --dns dnspod -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DNSPOD_API_KEY` | The user token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DNSPOD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DNSPOD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `DNSPOD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DNSPOD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.dnspod.com/api/) - [Go client](https://github.com/nrdcg/dnspod-go) ================================================ FILE: docs/content/dns/zz_gen_dode.md ================================================ --- title: "Domain Offensive (do.de)" date: 2019-03-03T16:39:46+01:00 draft: false slug: dode dnsprovider: since: "v2.4.0" code: "dode" url: "https://www.do.de/" --- Configuration for [Domain Offensive (do.de)](https://www.do.de/). - Code: `dode` - Since: v2.4.0 Here is an example bash command using the Domain Offensive (do.de) provider: ```bash DODE_TOKEN=xxxxxx \ lego --dns dode -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DODE_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DODE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DODE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `DODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DODE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.do.de/wiki/freie-ssl-tls-zertifikate-ueber-acme/) ================================================ FILE: docs/content/dns/zz_gen_domeneshop.md ================================================ --- title: "Domeneshop" date: 2019-03-03T16:39:46+01:00 draft: false slug: domeneshop dnsprovider: since: "v4.3.0" code: "domeneshop" url: "https://domene.shop" --- Configuration for [Domeneshop](https://domene.shop). - Code: `domeneshop` - Since: v4.3.0 Here is an example bash command using the Domeneshop provider: ```bash DOMENESHOP_API_TOKEN= \ DOMENESHOP_API_SECRET= \ lego --dns domeneshop -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DOMENESHOP_API_SECRET` | API secret | | `DOMENESHOP_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DOMENESHOP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DOMENESHOP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | | `DOMENESHOP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ### API credentials Visit the following page for information on how to create API credentials with Domeneshop: https://api.domeneshop.no/docs/#section/Authentication ## More information - [API documentation](https://api.domeneshop.no/docs) ================================================ FILE: docs/content/dns/zz_gen_dreamhost.md ================================================ --- title: "DreamHost" date: 2019-03-03T16:39:46+01:00 draft: false slug: dreamhost dnsprovider: since: "v1.1.0" code: "dreamhost" url: "https://www.dreamhost.com" --- Configuration for [DreamHost](https://www.dreamhost.com). - Code: `dreamhost` - Since: v1.1.0 Here is an example bash command using the DreamHost provider: ```bash DREAMHOST_API_KEY="YOURAPIKEY" \ lego --dns dreamhost -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DREAMHOST_API_KEY` | The API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DREAMHOST_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DREAMHOST_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | | `DREAMHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://help.dreamhost.com/hc/en-us/articles/217560167-API_overview) ================================================ FILE: docs/content/dns/zz_gen_duckdns.md ================================================ --- title: "Duck DNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: duckdns dnsprovider: since: "v0.5.0" code: "duckdns" url: "https://www.duckdns.org/" --- Configuration for [Duck DNS](https://www.duckdns.org/). - Code: `duckdns` - Since: v0.5.0 Here is an example bash command using the Duck DNS provider: ```bash DUCKDNS_TOKEN=xxxxxx \ lego --dns duckdns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DUCKDNS_TOKEN` | Account token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DUCKDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DUCKDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `DUCKDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DUCKDNS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.duckdns.org/spec.jsp) ================================================ FILE: docs/content/dns/zz_gen_dyn.md ================================================ --- title: "Dyn" date: 2019-03-03T16:39:46+01:00 draft: false slug: dyn dnsprovider: since: "v0.3.0" code: "dyn" url: "https://dyn.com/" --- Configuration for [Dyn](https://dyn.com/). - Code: `dyn` - Since: v0.3.0 Here is an example bash command using the Dyn provider: ```bash DYN_CUSTOMER_NAME=xxxxxx \ DYN_USER_NAME=yyyyy \ DYN_PASSWORD=zzzz \ lego --dns dyn -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DYN_CUSTOMER_NAME` | Customer name | | `DYN_PASSWORD` | Password | | `DYN_USER_NAME` | User name | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DYN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `DYN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `DYN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `DYN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://help.dyn.com/rest/) ================================================ FILE: docs/content/dns/zz_gen_dyndnsfree.md ================================================ --- title: "DynDnsFree.de" date: 2019-03-03T16:39:46+01:00 draft: false slug: dyndnsfree dnsprovider: since: "v4.23.0" code: "dyndnsfree" url: "https://www.dyndnsfree.de" --- Configuration for [DynDnsFree.de](https://www.dyndnsfree.de). - Code: `dyndnsfree` - Since: v4.23.0 Here is an example bash command using the DynDnsFree.de provider: ```bash DYNDNSFREE_USERNAME="xxx" \ DYNDNSFREE_PASSWORD="yyy" \ lego --dns dyndnsfree -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DYNDNSFREE_PASSWORD` | Password | | `DYNDNSFREE_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DYNDNSFREE_HTTP_TIMEOUT` | Request timeout in seconds (Default: 30) | | `DYNDNSFREE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `DYNDNSFREE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.dyndnsfree.de/user/hilfe.php?hsm=2) ================================================ FILE: docs/content/dns/zz_gen_dynu.md ================================================ --- title: "Dynu" date: 2019-03-03T16:39:46+01:00 draft: false slug: dynu dnsprovider: since: "v3.5.0" code: "dynu" url: "https://www.dynu.com/" --- Configuration for [Dynu](https://www.dynu.com/). - Code: `dynu` - Since: v3.5.0 Here is an example bash command using the Dynu provider: ```bash DYNU_API_KEY=1234567890abcdefghijklmnopqrstuvwxyz \ lego --dns dynu -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `DYNU_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `DYNU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `DYNU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `DYNU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) | | `DYNU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.dynu.com/en-US/Support/API) ================================================ FILE: docs/content/dns/zz_gen_easydns.md ================================================ --- title: "EasyDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: easydns dnsprovider: since: "v2.6.0" code: "easydns" url: "https://easydns.com/" --- Configuration for [EasyDNS](https://easydns.com/). - Code: `easydns` - Since: v2.6.0 Here is an example bash command using the EasyDNS provider: ```bash EASYDNS_TOKEN=xxx \ EASYDNS_KEY=yyy \ lego --dns easydns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `EASYDNS_KEY` | API Key | | `EASYDNS_TOKEN` | API Token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `EASYDNS_ENDPOINT` | The endpoint URL of the API Server | | `EASYDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `EASYDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `EASYDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `EASYDNS_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `EASYDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). To test with the sandbox environment set ```EASYDNS_ENDPOINT=https://sandbox.rest.easydns.net``` ## More information - [API documentation](https://docs.sandbox.rest.easydns.net) ================================================ FILE: docs/content/dns/zz_gen_edgecenter.md ================================================ --- title: "EdgeCenter" date: 2019-03-03T16:39:46+01:00 draft: false slug: edgecenter dnsprovider: since: "v4.29.0" code: "edgecenter" url: "https://edgecenter.ru/dns" --- Configuration for [EdgeCenter](https://edgecenter.ru/dns). - Code: `edgecenter` - Since: v4.29.0 Here is an example bash command using the EdgeCenter provider: ```bash EDGECENTER_PERMANENT_API_TOKEN=xxxxx \ lego --dns edgecenter -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `EDGECENTER_PERMANENT_API_TOKEN` | Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `EDGECENTER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `EDGECENTER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | | `EDGECENTER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | | `EDGECENTER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://apidocs.edgecenter.ru/dns) ================================================ FILE: docs/content/dns/zz_gen_edgedns.md ================================================ --- title: "Akamai EdgeDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: edgedns dnsprovider: since: "v3.9.0" code: "edgedns" url: "https://www.akamai.com/us/en/products/security/edge-dns.jsp" --- Akamai edgedns supersedes FastDNS; implementing a DNS provider for solving the DNS-01 challenge using Akamai EdgeDNS - Code: `edgedns` - Since: v3.9.0 Here is an example bash command using the Akamai EdgeDNS provider: ```bash AKAMAI_CLIENT_SECRET=abcdefghijklmnopqrstuvwxyz1234567890ABCDEFG= \ AKAMAI_CLIENT_TOKEN=akab-mnbvcxzlkjhgfdsapoiuytrewq1234567 \ AKAMAI_HOST=akab-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.luna.akamaiapis.net \ AKAMAI_ACCESS_TOKEN=akab-1234567890qwerty-asdfghjklzxcvtnu \ lego --dns edgedns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AKAMAI_ACCESS_TOKEN` | Access token, managed by the Akamai EdgeGrid client | | `AKAMAI_CLIENT_SECRET` | Client secret, managed by the Akamai EdgeGrid client | | `AKAMAI_CLIENT_TOKEN` | Client token, managed by the Akamai EdgeGrid client | | `AKAMAI_EDGERC` | Path to the .edgerc file, managed by the Akamai EdgeGrid client | | `AKAMAI_EDGERC_SECTION` | Configuration section, managed by the Akamai EdgeGrid client | | `AKAMAI_HOST` | API host, managed by the Akamai EdgeGrid client | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AKAMAI_ACCOUNT_SWITCH_KEY` | Target account ID when the DNS zone and credentials belong to different accounts | | `AKAMAI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) | | `AKAMAI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) | | `AKAMAI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). Akamai's credentials are automatically detected in the following locations and prioritized in the following order: 1. Section-specific environment variables (where `{SECTION}` is specified using `AKAMAI_EDGERC_SECTION`): - `AKAMAI_{SECTION}_HOST` - `AKAMAI_{SECTION}_ACCESS_TOKEN` - `AKAMAI_{SECTION}_CLIENT_TOKEN` - `AKAMAI_{SECTION}_CLIENT_SECRET` 2. If `AKAMAI_EDGERC_SECTION` is not defined or is set to `default`, environment variables: - `AKAMAI_HOST` - `AKAMAI_ACCESS_TOKEN` - `AKAMAI_CLIENT_TOKEN` - `AKAMAI_CLIENT_SECRET` 3. `.edgerc` file located at `AKAMAI_EDGERC` - defaults to `~/.edgerc`, sections can be specified using `AKAMAI_EDGERC_SECTION` 4. Default environment variables: - `AKAMAI_HOST` - `AKAMAI_ACCESS_TOKEN` - `AKAMAI_CLIENT_TOKEN` - `AKAMAI_CLIENT_SECRET` See also: - [Setting up Akamai credentials](https://developer.akamai.com/api/getting-started) - [.edgerc Format](https://developer.akamai.com/legacy/introduction/Conf_Client.html#edgercformat) - [API Client Authentication](https://developer.akamai.com/legacy/introduction/Client_Auth.html) - [Config from Env](https://github.com/akamai/AkamaiOPEN-edgegrid-golang/blob/master/pkg/edgegrid/config.go#L118) - [Manage many accounts](https://techdocs.akamai.com/developer/docs/manage-many-accounts-with-one-api-client) ## More information - [API documentation](https://developer.akamai.com/api/cloud_security/edge_dns_zone_management/v2.html) - [Go client](https://github.com/akamai/AkamaiOPEN-edgegrid-golang) ================================================ FILE: docs/content/dns/zz_gen_edgeone.md ================================================ --- title: "Tencent EdgeOne" date: 2019-03-03T16:39:46+01:00 draft: false slug: edgeone dnsprovider: since: "v4.26.0" code: "edgeone" url: "https://edgeone.ai" --- Configuration for [Tencent EdgeOne](https://edgeone.ai). - Code: `edgeone` - Since: v4.26.0 Here is an example bash command using the Tencent EdgeOne provider: ```bash EDGEONE_SECRET_ID=abcdefghijklmnopqrstuvwx \ EDGEONE_SECRET_KEY=your-secret-key \ lego --dns edgeone -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `EDGEONE_SECRET_ID` | Access key ID | | `EDGEONE_SECRET_KEY` | Access Key secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `EDGEONE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `EDGEONE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | | `EDGEONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) | | `EDGEONE_REGION` | Region | | `EDGEONE_SESSION_TOKEN` | Access Key token | | `EDGEONE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | | `EDGEONE_ZONES_MAPPING` | Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2') | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://edgeone.ai/document/50454#dns-record-apis) - [Go client](https://github.com/tencentcloud/tencentcloud-sdk-go) ================================================ FILE: docs/content/dns/zz_gen_efficientip.md ================================================ --- title: "Efficient IP" date: 2019-03-03T16:39:46+01:00 draft: false slug: efficientip dnsprovider: since: "v4.13.0" code: "efficientip" url: "https://efficientip.com/" --- Configuration for [Efficient IP](https://efficientip.com/). - Code: `efficientip` - Since: v4.13.0 Here is an example bash command using the Efficient IP provider: ```bash EFFICIENTIP_USERNAME="user" \ EFFICIENTIP_PASSWORD="secret" \ EFFICIENTIP_HOSTNAME="ipam.example.org" \ EFFICIENTIP_DNS_NAME="dns.smart" \ lego --dns efficientip -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `EFFICIENTIP_DNS_NAME` | DNS name (ex: dns.smart) | | `EFFICIENTIP_HOSTNAME` | Hostname (ex: foo.example.com) | | `EFFICIENTIP_PASSWORD` | Password | | `EFFICIENTIP_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `EFFICIENTIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `EFFICIENTIP_INSECURE_SKIP_VERIFY` | Whether or not to verify EfficientIP API certificate | | `EFFICIENTIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `EFFICIENTIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `EFFICIENTIP_VIEW_NAME` | View name (ex: external) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ================================================ FILE: docs/content/dns/zz_gen_epik.md ================================================ --- title: "Epik" date: 2019-03-03T16:39:46+01:00 draft: false slug: epik dnsprovider: since: "v4.5.0" code: "epik" url: "https://www.epik.com/" --- Configuration for [Epik](https://www.epik.com/). - Code: `epik` - Since: v4.5.0 Here is an example bash command using the Epik provider: ```bash EPIK_SIGNATURE=xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns epik -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `EPIK_SIGNATURE` | Epik API signature (https://registrar.epik.com/account/api-settings/) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `EPIK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `EPIK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `EPIK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `EPIK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs-userapi.epik.com/v2/) ================================================ FILE: docs/content/dns/zz_gen_eurodns.md ================================================ --- title: "EuroDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: eurodns dnsprovider: since: "v4.33.0" code: "eurodns" url: "https://www.eurodns.com/" --- Configuration for [EuroDNS](https://www.eurodns.com/). - Code: `eurodns` - Since: v4.33.0 Here is an example bash command using the EuroDNS provider: ```bash EURODNS_APP_ID="xxx" \ EURODNS_API_KEY="yyy" \ lego --dns eurodns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `EURODNS_API_KEY` | API key | | `EURODNS_APP_ID` | Application ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `EURODNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `EURODNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `EURODNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `EURODNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docapi.eurodns.com/) ================================================ FILE: docs/content/dns/zz_gen_excedo.md ================================================ --- title: "Excedo" date: 2019-03-03T16:39:46+01:00 draft: false slug: excedo dnsprovider: since: "v4.33.0" code: "excedo" url: "https://excedo.se/" --- Configuration for [Excedo](https://excedo.se/). - Code: `excedo` - Since: v4.33.0 Here is an example bash command using the Excedo provider: ```bash EXCEDO_API_KEY=your-api-key \ EXCEDO_API_URL=your-base-url \ lego --dns excedo -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `EXCEDO_API_KEY` | API key | | `EXCEDO_API_URL` | API base URL | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `EXCEDO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `EXCEDO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `EXCEDO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `EXCEDO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](none) ================================================ FILE: docs/content/dns/zz_gen_exec.md ================================================ --- title: "External program" date: 2019-03-03T16:39:46+01:00 draft: false slug: exec dnsprovider: since: "v0.5.0" code: "exec" url: "/dns/exec" --- Solving the DNS-01 challenge using an external program. - Code: `exec` - Since: v0.5.0 Here is an example bash command using the External program provider: ```bash EXEC_PATH=/the/path/to/myscript.sh \ lego --dns exec -d '*.example.com' -d example.com run ``` ## Base Configuration | Environment Variable Name | Description | |---------------------------|---------------------------------------| | `EXEC_MODE` | `RAW`, none | | `EXEC_PATH` | The path of the the external program. | ## Additional Configuration | Environment Variable Name | Description | |----------------------------|--------------------------------------------------------------------| | `EXEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3). | | `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60). | | `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60). | ## Description The file name of the external program is specified in the environment variable `EXEC_PATH`. When it is run by lego, three command-line parameters are passed to it: The action ("present" or "cleanup"), the fully-qualified domain name and the value for the record. For example, requesting a certificate for the domain 'my.example.org' can be achieved by calling lego as follows: ```bash EXEC_PATH=./update-dns.sh \ lego --dns exec --d my.example.org run ``` It will then call the program './update-dns.sh' with like this: ```bash ./update-dns.sh "present" "_acme-challenge.my.example.org." "MsijOYZxqyjGnFGwhjrhfg-Xgbl5r68WPda0J9EgqqI" ``` The program then needs to make sure the record is inserted. When it returns an error via a non-zero exit code, lego aborts. When the record is to be removed again, the program is called with the first command-line parameter set to `cleanup` instead of `present`. If you want to use the raw domain, token, and keyAuth values with your program, you can set `EXEC_MODE=RAW`: ```bash EXEC_MODE=RAW \ EXEC_PATH=./update-dns.sh \ lego --dns exec -d my.example.org run ``` It will then call the program `./update-dns.sh` like this: ```bash ./update-dns.sh "present" "--" "my.example.org." "some-token" "KxAy-J3NwUmg9ZQuM-gP_Mq1nStaYSaP9tYQs5_-YsE.ksT-qywTd8058G-SHHWA3RAN72Pr0yWtPYmmY5UBpQ8" ``` ## Commands {{% notice note %}} The `--` is because the token MAY start with a `-`, and the called program may try and interpret a `-` as indicating a flag. In the case of urfave, which is commonly used, you can use the `--` delimiter to specify the start of positional arguments, and handle such a string safely. {{% /notice %}} ### Present | Mode | Command | |---------|----------------------------------------------------| | default | `myprogram present ` | | `RAW` | `myprogram present -- ` | ### Cleanup | Mode | Command | |---------|----------------------------------------------------| | default | `myprogram cleanup ` | | `RAW` | `myprogram cleanup -- ` | ================================================ FILE: docs/content/dns/zz_gen_exoscale.md ================================================ --- title: "Exoscale" date: 2019-03-03T16:39:46+01:00 draft: false slug: exoscale dnsprovider: since: "v0.4.0" code: "exoscale" url: "https://www.exoscale.com/" --- Configuration for [Exoscale](https://www.exoscale.com/). - Code: `exoscale` - Since: v0.4.0 Here is an example bash command using the Exoscale provider: ```bash EXOSCALE_API_KEY=abcdefghijklmnopqrstuvwx \ EXOSCALE_API_SECRET=xxxxxxx \ lego --dns exoscale -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `EXOSCALE_API_KEY` | API key | | `EXOSCALE_API_SECRET` | API secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `EXOSCALE_ENDPOINT` | API endpoint URL | | `EXOSCALE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | | `EXOSCALE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `EXOSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `EXOSCALE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://openapi-v2.exoscale.com/#endpoint-dns) - [Go client](https://github.com/exoscale/egoscale) ================================================ FILE: docs/content/dns/zz_gen_f5xc.md ================================================ --- title: "F5 XC" date: 2019-03-03T16:39:46+01:00 draft: false slug: f5xc dnsprovider: since: "v4.23.0" code: "f5xc" url: "https://www.f5.com/products/distributed-cloud-services" --- Configuration for [F5 XC](https://www.f5.com/products/distributed-cloud-services). - Code: `f5xc` - Since: v4.23.0 Here is an example bash command using the F5 XC provider: ```bash F5XC_API_TOKEN="xxx" \ F5XC_TENANT_NAME="yyy" \ F5XC_GROUP_NAME="zzz" \ lego --dns f5xc -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `F5XC_API_TOKEN` | API token | | `F5XC_GROUP_NAME` | Group name | | `F5XC_TENANT_NAME` | XC Tenant shortname | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `F5XC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `F5XC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `F5XC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `F5XC_SERVER` | Server domain (Default: console.ves.volterra.io) | | `F5XC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset) ================================================ FILE: docs/content/dns/zz_gen_freemyip.md ================================================ --- title: "freemyip.com" date: 2019-03-03T16:39:46+01:00 draft: false slug: freemyip dnsprovider: since: "v4.5.0" code: "freemyip" url: "https://freemyip.com/" --- Configuration for [freemyip.com](https://freemyip.com/). - Code: `freemyip` - Since: v4.5.0 Here is an example bash command using the freemyip.com provider: ```bash FREEMYIP_TOKEN=xxxxxx \ lego --dns freemyip -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `FREEMYIP_TOKEN` | Account token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `FREEMYIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `FREEMYIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `FREEMYIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `FREEMYIP_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `FREEMYIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://freemyip.com/help) ================================================ FILE: docs/content/dns/zz_gen_gandi.md ================================================ --- title: "Gandi" date: 2019-03-03T16:39:46+01:00 draft: false slug: gandi dnsprovider: since: "v0.3.0" code: "gandi" url: "https://www.gandi.net" --- Configuration for [Gandi](https://www.gandi.net). - Code: `gandi` - Since: v0.3.0 Here is an example bash command using the Gandi provider: ```bash GANDI_API_KEY=abcdefghijklmnopqrstuvwx \ lego --dns gandi -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `GANDI_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GANDI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | | `GANDI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | | `GANDI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2400) | | `GANDI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://doc.rpc.gandi.net/index.html) ================================================ FILE: docs/content/dns/zz_gen_gandiv5.md ================================================ --- title: "Gandi Live DNS (v5)" date: 2019-03-03T16:39:46+01:00 draft: false slug: gandiv5 dnsprovider: since: "v0.5.0" code: "gandiv5" url: "https://www.gandi.net" --- Configuration for [Gandi Live DNS (v5)](https://www.gandi.net). - Code: `gandiv5` - Since: v0.5.0 Here is an example bash command using the Gandi Live DNS (v5) provider: ```bash GANDIV5_PERSONAL_ACCESS_TOKEN=abcdefghijklmnopqrstuvwx \ lego --dns gandiv5 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `GANDIV5_API_KEY` | API key (Deprecated) | | `GANDIV5_PERSONAL_ACCESS_TOKEN` | Personal Access Token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GANDIV5_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `GANDIV5_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | | `GANDIV5_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) | | `GANDIV5_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.gandi.net/docs/livedns/) ================================================ FILE: docs/content/dns/zz_gen_gcloud.md ================================================ --- title: "Google Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: gcloud dnsprovider: since: "v0.3.0" code: "gcloud" url: "https://cloud.google.com" --- Configuration for [Google Cloud](https://cloud.google.com). - Code: `gcloud` - Since: v0.3.0 Here is an example bash command using the Google Cloud provider: ```bash # Using a service account file GCE_PROJECT="gc-project-id" \ GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \ lego --dns gcloud -d '*.example.com' -d example.com run # Using default credentials with impersonation GCE_PROJECT="gc-project-id" \ GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \ lego --dns gcloud -d '*.example.com' -d example.com run # Using service account key with impersonation GCE_PROJECT="gc-project-id" \ GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \ GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \ lego --dns gcloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `Application Default Credentials` | [Documentation](https://cloud.google.com/docs/authentication/production#providing_credentials_to_your_application) | | `GCE_PROJECT` | Project name (by default, the project name is auto-detected by using the metadata service) | | `GCE_SERVICE_ACCOUNT` | Account | | `GCE_SERVICE_ACCOUNT_FILE` | Account file path | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GCE_ALLOW_PRIVATE_ZONE` | Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false) | | `GCE_IMPERSONATE_SERVICE_ACCOUNT` | Service account email to impersonate | | `GCE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `GCE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 180) | | `GCE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `GCE_ZONE_ID` | Allows to skip the automatic detection of the zone | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). Supports service account impersonation to access Google Cloud DNS resources across different projects or with restricted permissions. When using impersonation, the source service account must have: 1. The "Service Account Token Creator" role on the source service account 2. The "https://www.googleapis.com/auth/cloud-platform" scope ## More information - [API documentation](https://cloud.google.com/dns/api/v1/) - [Go client](https://github.com/googleapis/google-api-go-client) ================================================ FILE: docs/content/dns/zz_gen_gcore.md ================================================ --- title: "G-Core" date: 2019-03-03T16:39:46+01:00 draft: false slug: gcore dnsprovider: since: "v4.5.0" code: "gcore" url: "https://gcore.com/dns/" --- Configuration for [G-Core](https://gcore.com/dns/). - Code: `gcore` - Since: v4.5.0 Here is an example bash command using the G-Core provider: ```bash GCORE_PERMANENT_API_TOKEN=xxxxx \ lego --dns gcore -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `GCORE_PERMANENT_API_TOKEN` | Permanent API token (https://gcore.com/blog/permanent-api-token-explained/) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GCORE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `GCORE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | | `GCORE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | | `GCORE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.gcore.com/docs/dns#tag/zones) ================================================ FILE: docs/content/dns/zz_gen_gigahostno.md ================================================ --- title: "Gigahost.no" date: 2019-03-03T16:39:46+01:00 draft: false slug: gigahostno dnsprovider: since: "v4.29.0" code: "gigahostno" url: "https://gigahost.no/" --- Configuration for [Gigahost.no](https://gigahost.no/). - Code: `gigahostno` - Since: v4.29.0 Here is an example bash command using the Gigahost.no provider: ```bash GIGAHOSTNO_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \ GIGAHOSTNO_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \ lego --dns gigahostno -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `GIGAHOSTNO_PASSWORD` | Password | | `GIGAHOSTNO_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GIGAHOSTNO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `GIGAHOSTNO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `GIGAHOSTNO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `GIGAHOSTNO_SECRET` | TOTP secret | | `GIGAHOSTNO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://gigahost.no/api-dokumentasjon) ================================================ FILE: docs/content/dns/zz_gen_glesys.md ================================================ --- title: "Glesys" date: 2019-03-03T16:39:46+01:00 draft: false slug: glesys dnsprovider: since: "v0.5.0" code: "glesys" url: "https://glesys.com/" --- Configuration for [Glesys](https://glesys.com/). - Code: `glesys` - Since: v0.5.0 Here is an example bash command using the Glesys provider: ```bash GLESYS_API_USER=xxxxx \ GLESYS_API_KEY=yyyyy \ lego --dns glesys -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `GLESYS_API_KEY` | API key | | `GLESYS_API_USER` | API user | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GLESYS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `GLESYS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | | `GLESYS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) | | `GLESYS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://github.com/GleSYS/API/wiki/API-Documentation) ================================================ FILE: docs/content/dns/zz_gen_godaddy.md ================================================ --- title: "Go Daddy" date: 2019-03-03T16:39:46+01:00 draft: false slug: godaddy dnsprovider: since: "v0.5.0" code: "godaddy" url: "https://godaddy.com" --- Configuration for [Go Daddy](https://godaddy.com). - Code: `godaddy` - Since: v0.5.0 Here is an example bash command using the Go Daddy provider: ```bash GODADDY_API_KEY=xxxxxxxx \ GODADDY_API_SECRET=yyyyyyyy \ lego --dns godaddy -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `GODADDY_API_KEY` | API key | | `GODADDY_API_SECRET` | API secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GODADDY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `GODADDY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `GODADDY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `GODADDY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). GoDaddy has recently (2024-04) updated the account requirements to access parts of their production Domains API: - Availability API: Limited to accounts with 50 or more domains. - Management and DNS APIs: Limited to accounts with 10 or more domains and/or an active Discount Domain Club plan. https://community.letsencrypt.org/t/getting-unauthorized-url-error-while-trying-to-get-cert-for-subdomains/217329/12 ## More information - [API documentation](https://developer.godaddy.com/doc/endpoint/domains) ================================================ FILE: docs/content/dns/zz_gen_googledomains.md ================================================ --- title: "Google Domains" date: 2019-03-03T16:39:46+01:00 draft: false slug: googledomains dnsprovider: since: "v4.11.0" code: "googledomains" url: "https://github.com/go-acme/lego/issues/2553" --- The Google Domains DNS provider has shut down. - Code: `googledomains` - Since: v4.11.0 Here is an example bash command using the Google Domains provider: ```bash GOOGLE_DOMAINS_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns googledomains -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `GOOGLE_DOMAINS_ACCESS_TOKEN` | Access token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GOOGLE_DOMAINS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `GOOGLE_DOMAINS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `GOOGLE_DOMAINS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [Go client](https://github.com/googleapis/google-api-go-client) ================================================ FILE: docs/content/dns/zz_gen_gravity.md ================================================ --- title: "Gravity" date: 2019-03-03T16:39:46+01:00 draft: false slug: gravity dnsprovider: since: "v4.30.0" code: "gravity" url: "https://gravity.beryju.io/" --- Configuration for [Gravity](https://gravity.beryju.io/). - Code: `gravity` - Since: v4.30.0 Here is an example bash command using the Gravity provider: ```bash GRAVITY_SERVER_URL="https://example.org:1234" \ GRAVITY_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \ GRAVITY_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \ lego --dns gravity -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `GRAVITY_PASSWORD` | Password | | `GRAVITY_SERVER_URL` | URL of the server | | `GRAVITY_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `GRAVITY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `GRAVITY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `GRAVITY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `GRAVITY_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 1) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://gravity.beryju.io/docs/api/reference/) ================================================ FILE: docs/content/dns/zz_gen_hetzner.md ================================================ --- title: "Hetzner" date: 2019-03-03T16:39:46+01:00 draft: false slug: hetzner dnsprovider: since: "v3.7.0" code: "hetzner" url: "https://hetzner.com" --- Configuration for [Hetzner](https://hetzner.com). - Code: `hetzner` - Since: v3.7.0 Here is an example bash command using the Hetzner provider: ```bash HETZNER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns hetzner -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HETZNER_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HETZNER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HETZNER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HETZNER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `HETZNER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.hetzner.cloud/reference/cloud#dns) ================================================ FILE: docs/content/dns/zz_gen_hostingde.md ================================================ --- title: "Hosting.de" date: 2019-03-03T16:39:46+01:00 draft: false slug: hostingde dnsprovider: since: "v1.1.0" code: "hostingde" url: "https://www.hosting.de/" --- Configuration for [Hosting.de](https://www.hosting.de/). - Code: `hostingde` - Since: v1.1.0 Here is an example bash command using the Hosting.de provider: ```bash HOSTINGDE_API_KEY=xxxxxxxx \ lego --dns hostingde -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HOSTINGDE_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HOSTINGDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HOSTINGDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HOSTINGDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `HOSTINGDE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `HOSTINGDE_ZONE_NAME` | Zone name in ACE format | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.hosting.de/api/#dns) ================================================ FILE: docs/content/dns/zz_gen_hostinger.md ================================================ --- title: "Hostinger" date: 2019-03-03T16:39:46+01:00 draft: false slug: hostinger dnsprovider: since: "v4.27.0" code: "hostinger" url: "https://www.hostinger.com/" --- Configuration for [Hostinger](https://www.hostinger.com/). - Code: `hostinger` - Since: v4.27.0 Here is an example bash command using the Hostinger provider: ```bash HOSTINGER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns hostinger -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HOSTINGER_API_TOKEN` | API Token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HOSTINGER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HOSTINGER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HOSTINGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `HOSTINGER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.hostinger.com/#tag/dns-zone) ================================================ FILE: docs/content/dns/zz_gen_hostingnl.md ================================================ --- title: "Hosting.nl" date: 2019-03-03T16:39:46+01:00 draft: false slug: hostingnl dnsprovider: since: "v4.30.0" code: "hostingnl" url: "https://hosting.nl" --- Configuration for [Hosting.nl](https://hosting.nl). - Code: `hostingnl` - Since: v4.30.0 Here is an example bash command using the Hosting.nl provider: ```bash HOSTINGNL_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns hostingnl -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HOSTINGNL_API_KEY` | The API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HOSTINGNL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `HOSTINGNL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HOSTINGNL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `HOSTINGNL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.hosting.nl/api/documentation) ================================================ FILE: docs/content/dns/zz_gen_hosttech.md ================================================ --- title: "Hosttech" date: 2019-03-03T16:39:46+01:00 draft: false slug: hosttech dnsprovider: since: "v4.5.0" code: "hosttech" url: "https://www.hosttech.eu/" --- Configuration for [Hosttech](https://www.hosttech.eu/). - Code: `hosttech` - Since: v4.5.0 Here is an example bash command using the Hosttech provider: ```bash HOSTTECH_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns hosttech -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HOSTTECH_API_KEY` | API login | | `HOSTTECH_PASSWORD` | API password | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HOSTTECH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HOSTTECH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HOSTTECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `HOSTTECH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.ns1.hosttech.eu/api/documentation) ================================================ FILE: docs/content/dns/zz_gen_httpnet.md ================================================ --- title: "http.net" date: 2019-03-03T16:39:46+01:00 draft: false slug: httpnet dnsprovider: since: "v4.15.0" code: "httpnet" url: "https://www.http.net/" --- Configuration for [http.net](https://www.http.net/). - Code: `httpnet` - Since: v4.15.0 Here is an example bash command using the http.net provider: ```bash HTTPNET_API_KEY=xxxxxxxx \ lego --dns httpnet -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HTTPNET_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HTTPNET_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HTTPNET_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HTTPNET_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `HTTPNET_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `HTTPNET_ZONE_NAME` | Zone name in ACE format | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.http.net/docs/api/#dns) ================================================ FILE: docs/content/dns/zz_gen_httpreq.md ================================================ --- title: "HTTP request" date: 2019-03-03T16:39:46+01:00 draft: false slug: httpreq dnsprovider: since: "v2.0.0" code: "httpreq" url: "/lego/dns/httpreq/" --- Configuration for [HTTP request](/lego/dns/httpreq/). - Code: `httpreq` - Since: v2.0.0 Here is an example bash command using the HTTP request provider: ```bash HTTPREQ_ENDPOINT=http://my.server.com:9090 \ lego --dns httpreq -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HTTPREQ_ENDPOINT` | The URL of the server | | `HTTPREQ_MODE` | `RAW`, none | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HTTPREQ_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HTTPREQ_PASSWORD` | Basic authentication password | | `HTTPREQ_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HTTPREQ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `HTTPREQ_USERNAME` | Basic authentication username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description The server must provide: - `POST` `/present` - `POST` `/cleanup` The URL of the server must be defined by `HTTPREQ_ENDPOINT`. ### Mode There are 2 modes (`HTTPREQ_MODE`): - default mode: ```json { "fqdn": "_acme-challenge.domain.", "value": "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM" } ``` - `RAW` ```json { "domain": "domain", "token": "token", "keyAuth": "key" } ``` ### Authentication Basic authentication (optional) can be set with some environment variables: - `HTTPREQ_USERNAME` and `HTTPREQ_PASSWORD` - both values must be set, otherwise basic authentication is not defined. ================================================ FILE: docs/content/dns/zz_gen_huaweicloud.md ================================================ --- title: "Huawei Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: huaweicloud dnsprovider: since: "v4.19" code: "huaweicloud" url: "https://huaweicloud.com" --- Configuration for [Huawei Cloud](https://huaweicloud.com). - Code: `huaweicloud` - Since: v4.19 Here is an example bash command using the Huawei Cloud provider: ```bash HUAWEICLOUD_ACCESS_KEY_ID=your-access-key-id \ HUAWEICLOUD_SECRET_ACCESS_KEY=your-secret-access-key \ HUAWEICLOUD_REGION=cn-south-1 \ lego --dns huaweicloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HUAWEICLOUD_ACCESS_KEY_ID` | Access key ID | | `HUAWEICLOUD_REGION` | Region | | `HUAWEICLOUD_SECRET_ACCESS_KEY` | Access Key secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HUAWEICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HUAWEICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HUAWEICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `HUAWEICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://console-intl.huaweicloud.com/apiexplorer/#/openapi/DNS/doc?locale=en-us) - [Go client](https://github.com/huaweicloud/huaweicloud-sdk-go-v3) ================================================ FILE: docs/content/dns/zz_gen_hurricane.md ================================================ --- title: "Hurricane Electric DNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: hurricane dnsprovider: since: "v4.3.0" code: "hurricane" url: "https://dns.he.net/" --- Configuration for [Hurricane Electric DNS](https://dns.he.net/). - Code: `hurricane` - Since: v4.3.0 Here is an example bash command using the Hurricane Electric DNS provider: ```bash HURRICANE_TOKENS=example.org:token \ lego --dns hurricane -d '*.example.com' -d example.com run HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 \ lego --dns hurricane -d my.example.org -d demo.example.org ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `HURRICANE_TOKENS` | TXT record names and tokens | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `HURRICANE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `HURRICANE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `HURRICANE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation (Default: 300) | | `HURRICANE_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). Before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), create a TXT record named `_acme-challenge.my.example.org`, and enable dynamic updates on it. Generate a token for each URL with Hurricane Electric's UI, and copy it down. Stick to alphanumeric tokens for greatest reliability. To authenticate with the Hurricane Electric API, add each record name/token pair you want to update to the `HURRICANE_TOKENS` environment variable, as shown in the examples. Record names (without the `_acme-challenge.` component) and their tokens are separated with colons, while the credential pairs are concatenated into a comma-separated list, like so: ``` HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 ``` If you are issuing both a wildcard certificate and a standard certificate for a given subdomain, you should not have repeat entries for that name, as both will use the same credential. ``` HURRICANE_TOKENS=example.org:token ``` ## More information - [API documentation](https://dns.he.net/) ================================================ FILE: docs/content/dns/zz_gen_hyperone.md ================================================ --- title: "HyperOne" date: 2019-03-03T16:39:46+01:00 draft: false slug: hyperone dnsprovider: since: "v3.9.0" code: "hyperone" url: "https://www.hyperone.com" --- Configuration for [HyperOne](https://www.hyperone.com). - Code: `hyperone` - Since: v3.9.0 Here is an example bash command using the HyperOne provider: ```bash lego --dns hyperone -d '*.example.com' -d example.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_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `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 in seconds (Default: 60) | | `HYPERONE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 2) | | `HYPERONE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "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 iam project sa credential generate --name my-passport --project --sa --passport-output-file ~/.h1/passport.json ``` ### Required permissions The application requires following permissions: - `dns/zone/list` - `dns/zone.recordset/list` - `dns/zone.recordset/create` - `dns/zone.recordset/delete` - `dns/zone.record/create` - `dns/zone.record/list` - `dns/zone.record/delete` All required permissions are available via platform role `tool.lego`. ## More information - [API documentation](https://api.hyperone.com/v2/docs) ================================================ FILE: docs/content/dns/zz_gen_ibmcloud.md ================================================ --- title: "IBM Cloud (SoftLayer)" date: 2019-03-03T16:39:46+01:00 draft: false slug: ibmcloud dnsprovider: since: "v4.5.0" code: "ibmcloud" url: "https://www.ibm.com/cloud/" --- Configuration for [IBM Cloud (SoftLayer)](https://www.ibm.com/cloud/). - Code: `ibmcloud` - Since: v4.5.0 Here is an example bash command using the IBM Cloud (SoftLayer) provider: ```bash SOFTLAYER_USERNAME=xxxxx \ SOFTLAYER_API_KEY=yyyyy \ lego --dns ibmcloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SOFTLAYER_API_KEY` | Classic Infrastructure API key | | `SOFTLAYER_USERNAME` | Username (IBM Cloud is {accountID}_{emailAddress}) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SOFTLAYER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `SOFTLAYER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `SOFTLAYER_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SOFTLAYER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://cloud.ibm.com/docs/dns?topic=dns-getting-started-with-the-dns-api) - [Go client](https://github.com/softlayer/softlayer-go) ================================================ FILE: docs/content/dns/zz_gen_iij.md ================================================ --- title: "Internet Initiative Japan" date: 2019-03-03T16:39:46+01:00 draft: false slug: iij dnsprovider: since: "v1.1.0" code: "iij" url: "https://www.iij.ad.jp/en/" --- Configuration for [Internet Initiative Japan](https://www.iij.ad.jp/en/). - Code: `iij` - Since: v1.1.0 Here is an example bash command using the Internet Initiative Japan provider: ```bash IIJ_API_ACCESS_KEY=xxxxxxxx \ IIJ_API_SECRET_KEY=yyyyyy \ IIJ_DO_SERVICE_CODE=zzzzzz \ lego --dns iij -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `IIJ_API_ACCESS_KEY` | API access key | | `IIJ_API_SECRET_KEY` | API secret key | | `IIJ_DO_SERVICE_CODE` | DO service code | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `IIJ_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | | `IIJ_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) | | `IIJ_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://manual.iij.jp/p2/pubapi/) - [Go client](https://github.com/iij/doapi) ================================================ FILE: docs/content/dns/zz_gen_iijdpf.md ================================================ --- title: "IIJ DNS Platform Service" date: 2019-03-03T16:39:46+01:00 draft: false slug: iijdpf dnsprovider: since: "v4.7.0" code: "iijdpf" url: "https://www.iij.ad.jp/en/biz/dns-pfm/" --- Configuration for [IIJ DNS Platform Service](https://www.iij.ad.jp/en/biz/dns-pfm/). - Code: `iijdpf` - Since: v4.7.0 Here is an example bash command using the IIJ DNS Platform Service provider: ```bash IIJ_DPF_API_TOKEN=xxxxxxxx \ IIJ_DPF_DPM_SERVICE_CODE=yyyyyy \ lego --dns iijdpf -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `IIJ_DPF_API_TOKEN` | API token | | `IIJ_DPF_DPM_SERVICE_CODE` | IIJ Managed DNS Service's service code | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `IIJ_DPF_API_ENDPOINT` | API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1 | | `IIJ_DPF_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `IIJ_DPF_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 660) | | `IIJ_DPF_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://manual.iij.jp/dpf/dpfapi/) - [Go client](https://github.com/mimuret/golang-iij-dpf) ================================================ FILE: docs/content/dns/zz_gen_infoblox.md ================================================ --- title: "Infoblox" date: 2019-03-03T16:39:46+01:00 draft: false slug: infoblox dnsprovider: since: "v4.4.0" code: "infoblox" url: "https://www.infoblox.com/" --- Configuration for [Infoblox](https://www.infoblox.com/). - Code: `infoblox` - Since: v4.4.0 Here is an example bash command using the Infoblox provider: ```bash INFOBLOX_USERNAME=api-user-529 \ INFOBLOX_PASSWORD=b9841238feb177a84330febba8a83208921177bffe733 \ INFOBLOX_HOST=infoblox.example.org lego --dns infoblox -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `INFOBLOX_HOST` | Host URI | | `INFOBLOX_PASSWORD` | Account Password | | `INFOBLOX_USERNAME` | Account Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `INFOBLOX_CA_CERTIFICATE` | The path to the CA certificate (PEM encoded) | | `INFOBLOX_DNS_VIEW` | The view for the TXT records (Default: External) | | `INFOBLOX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `INFOBLOX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `INFOBLOX_PORT` | The port for the infoblox grid manager (Default: 443) | | `INFOBLOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `INFOBLOX_SSL_VERIFY` | Whether or not to verify the TLS certificate (Default: true) | | `INFOBLOX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `INFOBLOX_WAPI_VERSION` | The version of WAPI being used (Default: 2.11) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). When creating an API's user ensure it has the proper permissions for the view you are working with. ## More information - [API documentation](https://your.infoblox.server/wapidoc/) - [Go client](https://github.com/infobloxopen/infoblox-go-client) ================================================ FILE: docs/content/dns/zz_gen_infomaniak.md ================================================ --- title: "Infomaniak" date: 2019-03-03T16:39:46+01:00 draft: false slug: infomaniak dnsprovider: since: "v4.1.0" code: "infomaniak" url: "https://www.infomaniak.com/" --- Configuration for [Infomaniak](https://www.infomaniak.com/). - Code: `infomaniak` - Since: v4.1.0 Here is an example bash command using the Infomaniak provider: ```bash INFOMANIAK_ACCESS_TOKEN=1234567898765432 \ lego --dns infomaniak -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `INFOMANIAK_ACCESS_TOKEN` | Access token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `INFOMANIAK_ENDPOINT` | https://api.infomaniak.com | | `INFOMANIAK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `INFOMANIAK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `INFOMANIAK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `INFOMANIAK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Access token Access token can be created at the url https://manager.infomaniak.com/v3/infomaniak-api. You will need domain scope. ## More information - [API documentation](https://api.infomaniak.com/doc) ================================================ FILE: docs/content/dns/zz_gen_internetbs.md ================================================ --- title: "Internet.bs" date: 2019-03-03T16:39:46+01:00 draft: false slug: internetbs dnsprovider: since: "v4.5.0" code: "internetbs" url: "https://internetbs.net" --- Configuration for [Internet.bs](https://internetbs.net). - Code: `internetbs` - Since: v4.5.0 Here is an example bash command using the Internet.bs provider: ```bash INTERNET_BS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \ INTERNET_BS_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \ lego --dns internetbs -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `INTERNET_BS_API_KEY` | API key | | `INTERNET_BS_PASSWORD` | API password | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `INTERNET_BS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `INTERNET_BS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `INTERNET_BS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `INTERNET_BS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://internetbs.net/internet-bs-api.pdf) ================================================ FILE: docs/content/dns/zz_gen_inwx.md ================================================ --- title: "INWX" date: 2019-03-03T16:39:46+01:00 draft: false slug: inwx dnsprovider: since: "v2.0.0" code: "inwx" url: "https://www.inwx.de/en" --- Configuration for [INWX](https://www.inwx.de/en). - Code: `inwx` - Since: v2.0.0 Here is an example bash command using the INWX provider: ```bash INWX_USERNAME=xxxxxxxxxx \ INWX_PASSWORD=yyyyyyyyyy \ lego --dns inwx -d '*.example.com' -d example.com run # 2FA INWX_USERNAME=xxxxxxxxxx \ INWX_PASSWORD=yyyyyyyyyy \ INWX_SHARED_SECRET=zzzzzzzzzz \ lego --dns inwx -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `INWX_PASSWORD` | Password | | `INWX_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `INWX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `INWX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | | `INWX_SANDBOX` | Activate the sandbox (boolean) | | `INWX_SHARED_SECRET` | shared secret related to 2FA | | `INWX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.inwx.de/en/help/apidoc) - [Go client](https://github.com/nrdcg/goinwx) ================================================ FILE: docs/content/dns/zz_gen_ionos.md ================================================ --- title: "Ionos" date: 2019-03-03T16:39:46+01:00 draft: false slug: ionos dnsprovider: since: "v4.2.0" code: "ionos" url: "https://ionos.com" --- Configuration for [Ionos](https://ionos.com). - Code: `ionos` - Since: v4.2.0 Here is an example bash command using the Ionos provider: ```bash IONOS_API_KEY=xxxxxxxx \ lego --dns ionos -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `IONOS_API_KEY` | API key `.` https://developer.hosting.ionos.com/docs/getstarted | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `IONOS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `IONOS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `IONOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) | | `IONOS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developer.hosting.ionos.com/docs/dns) ================================================ FILE: docs/content/dns/zz_gen_ionoscloud.md ================================================ --- title: "Ionos Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: ionoscloud dnsprovider: since: "v4.30.0" code: "ionoscloud" url: "https://cloud.ionos.de/network/cloud-dns" --- Configuration for [Ionos Cloud](https://cloud.ionos.de/network/cloud-dns). - Code: `ionoscloud` - Since: v4.30.0 Here is an example bash command using the Ionos Cloud provider: ```bash IONOSCLOUD_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns ionoscloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `IONOSCLOUD_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `IONOSCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `IONOSCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `IONOSCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `IONOSCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.ionos.com/docs/dns/v1/) ================================================ FILE: docs/content/dns/zz_gen_ipv64.md ================================================ --- title: "IPv64" date: 2019-03-03T16:39:46+01:00 draft: false slug: ipv64 dnsprovider: since: "v4.13.0" code: "ipv64" url: "https://ipv64.net/" --- Configuration for [IPv64](https://ipv64.net/). - Code: `ipv64` - Since: v4.13.0 Here is an example bash command using the IPv64 provider: ```bash IPV64_API_KEY=xxxxxx \ lego --dns ipv64 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `IPV64_API_KEY` | Account API Key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `IPV64_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `IPV64_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `IPV64_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://ipv64.net/dyndns_updater_api) ================================================ FILE: docs/content/dns/zz_gen_ispconfig.md ================================================ --- title: "ISPConfig 3" date: 2019-03-03T16:39:46+01:00 draft: false slug: ispconfig dnsprovider: since: "v4.31.0" code: "ispconfig" url: "https://www.ispconfig.org/" --- Configuration for [ISPConfig 3](https://www.ispconfig.org/). - Code: `ispconfig` - Since: v4.31.0 Here is an example bash command using the ISPConfig 3 provider: ```bash ISPCONFIG_SERVER_URL="https://example.com:8080/remote/json.php" \ ISPCONFIG_USERNAME="xxx" \ ISPCONFIG_PASSWORD="yyy" \ lego --dns ispconfig -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ISPCONFIG_PASSWORD` | Password | | `ISPCONFIG_SERVER_URL` | Server URL | | `ISPCONFIG_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ISPCONFIG_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ISPCONFIG_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate | | `ISPCONFIG_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ISPCONFIG_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `ISPCONFIG_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/index.html) ================================================ FILE: docs/content/dns/zz_gen_ispconfigddns.md ================================================ --- title: "ISPConfig 3 - Dynamic DNS (DDNS) Module" date: 2019-03-03T16:39:46+01:00 draft: false slug: ispconfigddns dnsprovider: since: "v4.31.0" code: "ispconfigddns" url: "https://www.ispconfig.org/" --- Configuration for [ISPConfig 3 - Dynamic DNS (DDNS) Module](https://www.ispconfig.org/). - Code: `ispconfigddns` - Since: v4.31.0 Here is an example bash command using the ISPConfig 3 - Dynamic DNS (DDNS) Module provider: ```bash ISPCONFIG_DDNS_SERVER_URL="https://panel.example.com:8080" \ ISPCONFIG_DDNS_TOKEN=xxxxxx \ lego --dns ispconfigddns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ISPCONFIG_DDNS_SERVER_URL` | API server URL (ex: https://panel.example.com:8080) | | `ISPCONFIG_DDNS_TOKEN` | DDNS API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ISPCONFIG_DDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ISPCONFIG_DDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ISPCONFIG_DDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `ISPCONFIG_DDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ISPConfig DNS provider supports leveraging the [ISPConfig 3 Dynamic DNS (DDNS) Module](https://github.com/mhofer117/ispconfig-ddns-module). Requires the DDNS module described at https://www.ispconfig.org/ispconfig/download/ See https://www.howtoforge.com/community/threads/ispconfig-3-danymic-dns-ddns-module.87967/ for additional details. ## More information - [API documentation](https://github.com/mhofer117/ispconfig-ddns-module/tree/master/lib/updater) ================================================ FILE: docs/content/dns/zz_gen_iwantmyname.md ================================================ --- title: "iwantmyname (Deprecated)" date: 2019-03-03T16:39:46+01:00 draft: false slug: iwantmyname dnsprovider: since: "v4.7.0" code: "iwantmyname" url: "https://iwantmyname.com" --- The iwantmyname API has shut down. https://github.com/go-acme/lego/issues/2563 - Code: `iwantmyname` - Since: v4.7.0 Here is an example bash command using the iwantmyname (Deprecated) provider: ```bash IWANTMYNAME_USERNAME=xxxxxxxx \ IWANTMYNAME_PASSWORD=xxxxxxxx \ lego --dns iwantmyname -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `IWANTMYNAME_PASSWORD` | API password | | `IWANTMYNAME_USERNAME` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `IWANTMYNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `IWANTMYNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `IWANTMYNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `IWANTMYNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://iwantmyname.com/developer/domain-dns-api) ================================================ FILE: docs/content/dns/zz_gen_jdcloud.md ================================================ --- title: "JD Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: jdcloud dnsprovider: since: "v4.31.0" code: "jdcloud" url: "https://www.jdcloud.com/" --- Configuration for [JD Cloud](https://www.jdcloud.com/). - Code: `jdcloud` - Since: v4.31.0 Here is an example bash command using the JD Cloud provider: ```bash JDCLOUD_ACCESS_KEY_ID="xxx" \ JDCLOUD_ACCESS_KEY_SECRET="yyy" \ lego --dns jdcloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `JDCLOUD_ACCESS_KEY_ID` | Access key ID | | `JDCLOUD_ACCESS_KEY_SECRET` | Access key secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `JDCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `JDCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `JDCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `JDCLOUD_REGION_ID` | Region ID (Default: cn-north-1) | | `JDCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.jdcloud.com/cn/jd-cloud-dns/api/overview) - [Go client](https://github.com/jdcloud-api/jdcloud-sdk-go) ================================================ FILE: docs/content/dns/zz_gen_joker.md ================================================ --- title: "Joker" date: 2019-03-03T16:39:46+01:00 draft: false slug: joker dnsprovider: since: "v2.6.0" code: "joker" url: "https://joker.com" --- Configuration for [Joker](https://joker.com). - Code: `joker` - Since: v2.6.0 Here is an example bash command using the Joker provider: ```bash # SVC JOKER_API_MODE=SVC \ JOKER_USERNAME= \ JOKER_PASSWORD= \ lego --dns joker -d '*.example.com' -d example.com run # DMAPI JOKER_API_MODE=DMAPI \ JOKER_USERNAME= \ JOKER_PASSWORD= \ lego --dns joker -d '*.example.com' -d example.com run ## or JOKER_API_MODE=DMAPI \ JOKER_API_KEY= \ lego --dns joker -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `JOKER_API_KEY` | API key (only with DMAPI mode) | | `JOKER_API_MODE` | 'DMAPI' or 'SVC'. DMAPI is for resellers accounts. (Default: DMAPI) | | `JOKER_PASSWORD` | Joker.com password | | `JOKER_USERNAME` | Joker.com username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `JOKER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | | `JOKER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `JOKER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `JOKER_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60), only with 'SVC' mode | | `JOKER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## SVC mode In the SVC mode, username and passsword are not your email and account passwords, but those displayed in Joker.com domain dashboard when enabling Dynamic DNS. As per [Joker.com documentation](https://joker.com/faq/content/6/496/en/let_s-encrypt-support.html): > 1. please log in at Joker.com, visit 'My Domains', > find the domain you want to add Let's Encrypt certificate for, and chose "DNS" in the menu > > 2. on the top right, you will find the setting for 'Dynamic DNS'. > If not already active, please activate it. > It will not affect any other already existing DNS records of this domain. > > 3. please take a note of the credentials which are now shown as 'Dynamic DNS Authentication', consisting of a 'username' and a 'password'. > > 4. this is all you have to do here - and only once per domain. ## More information - [API documentation](https://joker.com/faq/category/39/22-dmapi.html) ================================================ FILE: docs/content/dns/zz_gen_keyhelp.md ================================================ --- title: "KeyHelp" date: 2019-03-03T16:39:46+01:00 draft: false slug: keyhelp dnsprovider: since: "v4.26.0" code: "keyhelp" url: "https://www.keyweb.de/en/keyhelp/keyhelp/" --- Configuration for [KeyHelp](https://www.keyweb.de/en/keyhelp/keyhelp/). - Code: `keyhelp` - Since: v4.26.0 Here is an example bash command using the KeyHelp provider: ```bash KEYHELP_BASE_URL="https://keyhelp.example.com" \ KEYHELP_API_KEY="xxx" \ lego --dns keyhelp -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `KEYHELP_API_KEY` | API key | | `KEYHELP_BASE_URL` | Server URL | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `KEYHELP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `KEYHELP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `KEYHELP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `KEYHELP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://app.swaggerhub.com/apis-docs/keyhelp/api/) ================================================ FILE: docs/content/dns/zz_gen_leaseweb.md ================================================ --- title: "Leaseweb" date: 2019-03-03T16:39:46+01:00 draft: false slug: leaseweb dnsprovider: since: "v4.32.0" code: "leaseweb" url: "https://www.leaseweb.com/en/" --- Configuration for [Leaseweb](https://www.leaseweb.com/en/). - Code: `leaseweb` - Since: v4.32.0 Here is an example bash command using the Leaseweb provider: ```bash LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns leaseweb -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `LEASEWEB_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `LEASEWEB_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `LEASEWEB_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `LEASEWEB_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `LEASEWEB_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developer.leaseweb.com/docs/#tag/DNS) ================================================ FILE: docs/content/dns/zz_gen_liara.md ================================================ --- title: "Liara" date: 2019-03-03T16:39:46+01:00 draft: false slug: liara dnsprovider: since: "v4.10.0" code: "liara" url: "https://liara.ir" --- Configuration for [Liara](https://liara.ir). - Code: `liara` - Since: v4.10.0 Here is an example bash command using the Liara provider: ```bash LIARA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns liara -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `LIARA_API_KEY` | The API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `LIARA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `LIARA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `LIARA_TEAM_ID` | The team ID to access services in a team | | `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://openapi.liara.ir/?urls.primaryName=DNS) ================================================ FILE: docs/content/dns/zz_gen_lightsail.md ================================================ --- title: "Amazon Lightsail" date: 2019-03-03T16:39:46+01:00 draft: false slug: lightsail dnsprovider: since: "v0.5.0" code: "lightsail" url: "https://aws.amazon.com/lightsail/" --- Configuration for [Amazon Lightsail](https://aws.amazon.com/lightsail/). - Code: `lightsail` - Since: v0.5.0 {{% notice note %}} _Please contribute by adding a CLI example._ {{% /notice %}} ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AWS_ACCESS_KEY_ID` | Managed by the AWS client. Access key ID (`AWS_ACCESS_KEY_ID_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead) | | `AWS_SECRET_ACCESS_KEY` | Managed by the AWS client. Secret access key (`AWS_SECRET_ACCESS_KEY_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead) | | `DNS_ZONE` | Domain name of the DNS zone | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AWS_SHARED_CREDENTIALS_FILE` | Managed by the AWS client. Shared credentials file. | | `LIGHTSAIL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `LIGHTSAIL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description AWS Credentials are automatically detected in the following locations and prioritized in the following order: 1. Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, [`AWS_SESSION_TOKEN`] 2. Shared credentials file (defaults to `~/.aws/credentials`, profiles can be specified using `AWS_PROFILE`) 3. Amazon EC2 IAM role AWS region is not required to set as the Lightsail DNS zone is in global (us-east-1) region. ## Policy The following AWS IAM policy document describes the minimum permissions required for lego to complete the DNS challenge. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "lightsail:DeleteDomainEntry", "lightsail:CreateDomainEntry" ], "Resource": "" } ] } ``` Replace the `Resource` value with your Lightsail DNS zone ARN. You can retrieve the ARN using aws cli by running `aws lightsail get-domains --region us-east-1` (Lightsail web console does not show the ARN, unfortunately). It should be in the format of `arn:aws:lightsail:global::Domain/`. You also need to replace the region in the ARN to `us-east-1` (instead of `global`). Alternatively, you can also set the `Resource` to `*` (wildcard), which allow to access all domain, but this is not recommended. ## More information - [Go client](https://github.com/aws/aws-sdk-go-v2) ================================================ FILE: docs/content/dns/zz_gen_limacity.md ================================================ --- title: "Lima-City" date: 2019-03-03T16:39:46+01:00 draft: false slug: limacity dnsprovider: since: "v4.18.0" code: "limacity" url: "https://www.lima-city.de" --- Configuration for [Lima-City](https://www.lima-city.de). - Code: `limacity` - Since: v4.18.0 Here is an example bash command using the Lima-City provider: ```bash LIMACITY_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns limacity -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `LIMACITY_API_KEY` | The API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `LIMACITY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `LIMACITY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 80) | | `LIMACITY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 480) | | `LIMACITY_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 90) | | `LIMACITY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.lima-city.de/hilfe/lima-city-api) ================================================ FILE: docs/content/dns/zz_gen_linode.md ================================================ --- title: "Linode (v4)" date: 2019-03-03T16:39:46+01:00 draft: false slug: linode dnsprovider: since: "v1.1.0" code: "linode" url: "https://www.linode.com/" --- Configuration for [Linode (v4)](https://www.linode.com/). - Code: `linode` - Since: v1.1.0 Here is an example bash command using the Linode (v4) provider: ```bash LINODE_TOKEN=xxxxx \ lego --dns linode -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `LINODE_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `LINODE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `LINODE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) | | `LINODE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `LINODE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.linode.com/api/v4) - [Go client](https://github.com/linode/linodego) ================================================ FILE: docs/content/dns/zz_gen_liquidweb.md ================================================ --- title: "Liquid Web" date: 2019-03-03T16:39:46+01:00 draft: false slug: liquidweb dnsprovider: since: "v3.1.0" code: "liquidweb" url: "https://liquidweb.com" --- Configuration for [Liquid Web](https://liquidweb.com). - Code: `liquidweb` - Since: v3.1.0 Here is an example bash command using the Liquid Web provider: ```bash LWAPI_USERNAME=someuser \ LWAPI_PASSWORD=somepass \ lego --dns liquidweb -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `LWAPI_PASSWORD` | Liquid Web API Password | | `LWAPI_USERNAME` | Liquid Web API Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `LWAPI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | | `LWAPI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `LWAPI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `LWAPI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | | `LWAPI_URL` | Liquid Web API endpoint | | `LWAPI_ZONE` | DNS Zone | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.liquidweb.com/docs/) - [Go client](https://github.com/liquidweb/liquidweb-go) ================================================ FILE: docs/content/dns/zz_gen_loopia.md ================================================ --- title: "Loopia" date: 2019-03-03T16:39:46+01:00 draft: false slug: loopia dnsprovider: since: "v4.2.0" code: "loopia" url: "https://loopia.com" --- Configuration for [Loopia](https://loopia.com). - Code: `loopia` - Since: v4.2.0 Here is an example bash command using the Loopia provider: ```bash LOOPIA_API_USER=xxxxxxxx \ LOOPIA_API_PASSWORD=yyyyyyyy \ lego --dns loopia -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `LOOPIA_API_PASSWORD` | API password | | `LOOPIA_API_USER` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `LOOPIA_API_URL` | API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV | | `LOOPIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | | `LOOPIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2400) | | `LOOPIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `LOOPIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ### API user You can [generate a new API user](https://customerzone.loopia.com/api/) from your account page. It needs to have the following permissions: * addZoneRecord * getZoneRecords * removeZoneRecord * removeSubdomain ## More information - [API documentation](https://www.loopia.com/api) ================================================ FILE: docs/content/dns/zz_gen_luadns.md ================================================ --- title: "LuaDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: luadns dnsprovider: since: "v3.7.0" code: "luadns" url: "https://luadns.com" --- Configuration for [LuaDNS](https://luadns.com). - Code: `luadns` - Since: v3.7.0 Here is an example bash command using the LuaDNS provider: ```bash LUADNS_API_USERNAME=youremail \ LUADNS_API_TOKEN=xxxxxxxx \ lego --dns luadns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `LUADNS_API_TOKEN` | API token | | `LUADNS_API_USERNAME` | Username (your email) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `LUADNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `LUADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `LUADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `LUADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://luadns.com/api.html) ================================================ FILE: docs/content/dns/zz_gen_mailinabox.md ================================================ --- title: "Mail-in-a-Box" date: 2019-03-03T16:39:46+01:00 draft: false slug: mailinabox dnsprovider: since: "v4.16.0" code: "mailinabox" url: "https://mailinabox.email" --- Configuration for [Mail-in-a-Box](https://mailinabox.email). - Code: `mailinabox` - Since: v4.16.0 Here is an example bash command using the Mail-in-a-Box provider: ```bash MAILINABOX_EMAIL=user@example.com \ MAILINABOX_PASSWORD=yyyy \ MAILINABOX_BASE_URL=https://box.example.com \ lego --dns mailinabox -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `MAILINABOX_BASE_URL` | Base API URL (ex: https://box.example.com) | | `MAILINABOX_EMAIL` | User email | | `MAILINABOX_PASSWORD` | User password | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `MAILINABOX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `MAILINABOX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | | `MAILINABOX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://mailinabox.email/api-docs.html) ================================================ FILE: docs/content/dns/zz_gen_manageengine.md ================================================ --- title: "ManageEngine CloudDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: manageengine dnsprovider: since: "v4.21.0" code: "manageengine" url: "https://clouddns.manageengine.com" --- Configuration for [ManageEngine CloudDNS](https://clouddns.manageengine.com). - Code: `manageengine` - Since: v4.21.0 Here is an example bash command using the ManageEngine CloudDNS provider: ```bash MANAGEENGINE_CLIENT_ID="xxx" \ MANAGEENGINE_CLIENT_SECRET="yyy" \ lego --dns manageengine -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `MANAGEENGINE_CLIENT_ID` | Client ID | | `MANAGEENGINE_CLIENT_SECRET` | Client Secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `MANAGEENGINE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `MANAGEENGINE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `MANAGEENGINE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation) ================================================ FILE: docs/content/dns/zz_gen_manual.md ================================================ --- title: "Manual" date: 2019-03-03T16:39:46+01:00 draft: false slug: manual dnsprovider: since: "v0.3.0" code: "manual" url: "" --- Solving the DNS-01 challenge using CLI prompt. - Code: `manual` - Since: v0.3.0 Here is an example bash command using the Manual provider: ```bash lego --dns manual -d '*.example.com' -d example.com run ``` ## Example To start using the CLI prompt "provider", start lego with `--dns manual`: ```console $ lego --dns manual -d example.com run ``` What follows are a few log print-outs, interspersed with some prompts, asking for you to do perform some actions: ```txt No key found for account you@example.com. Generating a P256 key. Saved key to ./.lego/accounts/acme-v02.api.letsencrypt.org/you@example.com/keys/you@example.com.key Please review the TOS at https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf Do you accept the TOS? Y/n ``` If you accept the linked Terms of Service, hit `Enter`. ```txt [INFO] acme: Registering account for you@example.com !!!! HEADS UP !!!! Your account credentials have been saved in your configuration directory at "./.lego/accounts". You should make a secure backup of this folder now. This configuration directory will also contain private keys generated by lego and certificates obtained from the ACME server. Making regular backups of this folder is ideal. [INFO] [example.com] acme: Obtaining bundled SAN certificate [INFO] [example.com] AuthURL: https://acme-v02.api.letsencrypt.org/acme/authz-v3/2345678901 [INFO] [example.com] acme: Could not find solver for: tls-alpn-01 [INFO] [example.com] acme: Could not find solver for: http-01 [INFO] [example.com] acme: use dns-01 solver [INFO] [example.com] acme: Preparing to solve DNS-01 lego: Please create the following TXT record in your example.com. zone: _acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5q2JQ" lego: Press 'Enter' when you are done ``` Do as instructed, and create the TXT records, and hit `Enter`. ```txt [INFO] [example.com] acme: Trying to solve DNS-01 [INFO] [example.com] acme: Checking DNS record propagation using [192.168.8.1:53] [INFO] Wait for propagation [timeout: 1m0s, interval: 2s] [INFO] [example.com] acme: Waiting for DNS record propagation. [INFO] [example.com] The server validated our request [INFO] [example.com] acme: Cleaning DNS-01 challenge lego: You can now remove this TXT record from your example.com. zone: _acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5q2JQ" [INFO] [example.com] acme: Validations succeeded; requesting certificates [INFO] [example.com] Server responded with a certificate. ``` As mentioned, you can now remove the TXT record again. ================================================ FILE: docs/content/dns/zz_gen_metaname.md ================================================ --- title: "Metaname" date: 2019-03-03T16:39:46+01:00 draft: false slug: metaname dnsprovider: since: "v4.13.0" code: "metaname" url: "https://metaname.net" --- Configuration for [Metaname](https://metaname.net). - Code: `metaname` - Since: v4.13.0 Here is an example bash command using the Metaname provider: ```bash METANAME_ACCOUNT_REFERENCE=xxxx \ METANAME_API_KEY=yyyyyyy \ lego --dns metaname -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `METANAME_ACCOUNT_REFERENCE` | The four-digit reference of a Metaname account | | `METANAME_API_KEY` | API Key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `METANAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `METANAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `METANAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://metaname.net/api/1.1/doc) - [Go client](https://github.com/nzdjb/go-metaname) ================================================ FILE: docs/content/dns/zz_gen_metaregistrar.md ================================================ --- title: "Metaregistrar" date: 2019-03-03T16:39:46+01:00 draft: false slug: metaregistrar dnsprovider: since: "v4.23.0" code: "metaregistrar" url: "https://metaregistrar.com/" --- Configuration for [Metaregistrar](https://metaregistrar.com/). - Code: `metaregistrar` - Since: v4.23.0 Here is an example bash command using the Metaregistrar provider: ```bash METAREGISTRAR_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns metaregistrar -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `METAREGISTRAR_API_TOKEN` | The API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `METAREGISTRAR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `METAREGISTRAR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `METAREGISTRAR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `METAREGISTRAR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://metaregistrar.dev/docu/metaapi/) ================================================ FILE: docs/content/dns/zz_gen_mijnhost.md ================================================ --- title: "mijn.host" date: 2019-03-03T16:39:46+01:00 draft: false slug: mijnhost dnsprovider: since: "v4.18.0" code: "mijnhost" url: "https://mijn.host/" --- Configuration for [mijn.host](https://mijn.host/). - Code: `mijnhost` - Since: v4.18.0 Here is an example bash command using the mijn.host provider: ```bash MIJNHOST_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns mijnhost -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `MIJNHOST_API_KEY` | The API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `MIJNHOST_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `MIJNHOST_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `MIJNHOST_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `MIJNHOST_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `MIJNHOST_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://mijn.host/api/doc/) ================================================ FILE: docs/content/dns/zz_gen_mittwald.md ================================================ --- title: "Mittwald" date: 2019-03-03T16:39:46+01:00 draft: false slug: mittwald dnsprovider: since: "v1.48.0" code: "mittwald" url: "https://www.mittwald.de/" --- Configuration for [Mittwald](https://www.mittwald.de/). - Code: `mittwald` - Since: v1.48.0 Here is an example bash command using the Mittwald provider: ```bash MITTWALD_TOKEN=my-token \ lego --dns mittwald -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `MITTWALD_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `MITTWALD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `MITTWALD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `MITTWALD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `MITTWALD_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 120) | | `MITTWALD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.mittwald.de/v2/docs/) ================================================ FILE: docs/content/dns/zz_gen_myaddr.md ================================================ --- title: "myaddr.{tools,dev,io}" date: 2019-03-03T16:39:46+01:00 draft: false slug: myaddr dnsprovider: since: "v4.22.0" code: "myaddr" url: "https://myaddr.tools/" --- Configuration for [myaddr.{tools,dev,io}](https://myaddr.tools/). - Code: `myaddr` - Since: v4.22.0 Here is an example bash command using the myaddr.{tools,dev,io} provider: ```bash MYADDR_PRIVATE_KEYS_MAPPING="example:123,test:456" \ lego --dns myaddr -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `MYADDR_PRIVATE_KEYS_MAPPING` | Mapping between subdomains and private keys. The format is: `:,:,:` | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `MYADDR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `MYADDR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `MYADDR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `MYADDR_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 2) | | `MYADDR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://myaddr.tools/) ================================================ FILE: docs/content/dns/zz_gen_mydnsjp.md ================================================ --- title: "MyDNS.jp" date: 2019-03-03T16:39:46+01:00 draft: false slug: mydnsjp dnsprovider: since: "v1.2.0" code: "mydnsjp" url: "https://www.mydns.jp" --- Configuration for [MyDNS.jp](https://www.mydns.jp). - Code: `mydnsjp` - Since: v1.2.0 Here is an example bash command using the MyDNS.jp provider: ```bash MYDNSJP_MASTER_ID=xxxxx \ MYDNSJP_PASSWORD=xxxxx \ lego --dns mydnsjp -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `MYDNSJP_MASTER_ID` | Master ID | | `MYDNSJP_PASSWORD` | Password | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `MYDNSJP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `MYDNSJP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `MYDNSJP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.mydns.jp/?MENU=030) ================================================ FILE: docs/content/dns/zz_gen_mythicbeasts.md ================================================ --- title: "MythicBeasts" date: 2019-03-03T16:39:46+01:00 draft: false slug: mythicbeasts dnsprovider: since: "v0.3.7" code: "mythicbeasts" url: "https://www.mythic-beasts.com/" --- Configuration for [MythicBeasts](https://www.mythic-beasts.com/). - Code: `mythicbeasts` - Since: v0.3.7 Here is an example bash command using the MythicBeasts provider: ```bash MYTHICBEASTS_USERNAME=myuser \ MYTHICBEASTS_PASSWORD=mypass \ lego --dns mythicbeasts -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `MYTHICBEASTS_PASSWORD` | Password | | `MYTHICBEASTS_USERNAME` | User name | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `MYTHICBEASTS_API_ENDPOINT` | The endpoint for the API (must implement v2) | | `MYTHICBEASTS_AUTH_API_ENDPOINT` | The endpoint for Mythic Beasts' Authentication | | `MYTHICBEASTS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `MYTHICBEASTS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `MYTHICBEASTS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `MYTHICBEASTS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). If you are using specific API keys, then the username is the API ID for your API key, and the password is the API secret. Your API key name is not needed to operate lego. ## More information - [API documentation](https://www.mythic-beasts.com/support/api/dnsv2) ================================================ FILE: docs/content/dns/zz_gen_namecheap.md ================================================ --- title: "Namecheap" date: 2019-03-03T16:39:46+01:00 draft: false slug: namecheap dnsprovider: since: "v0.3.0" code: "namecheap" url: "https://www.namecheap.com" --- Configuration for [Namecheap](https://www.namecheap.com). **To enable API access on the Namecheap production environment, some opaque requirements must be met.** More information in the section [Enabling API Access](https://www.namecheap.com/support/api/intro/) of the Namecheap documentation. (2020-08: Account balance of $50+, 20+ domains in your account, or purchases totaling $50+ within the last 2 years.) - Code: `namecheap` - Since: v0.3.0 Here is an example bash command using the Namecheap provider: ```bash NAMECHEAP_API_USER=user \ NAMECHEAP_API_KEY=key \ lego --dns namecheap -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NAMECHEAP_API_KEY` | API key | | `NAMECHEAP_API_USER` | API user | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NAMECHEAP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | | `NAMECHEAP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 15) | | `NAMECHEAP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 3600) | | `NAMECHEAP_SANDBOX` | Activate the sandbox (boolean) | | `NAMECHEAP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.namecheap.com/support/api/methods.aspx) ================================================ FILE: docs/content/dns/zz_gen_namedotcom.md ================================================ --- title: "Name.com" date: 2019-03-03T16:39:46+01:00 draft: false slug: namedotcom dnsprovider: since: "v0.5.0" code: "namedotcom" url: "https://www.name.com" --- Configuration for [Name.com](https://www.name.com). - Code: `namedotcom` - Since: v0.5.0 Here is an example bash command using the Name.com provider: ```bash NAMECOM_USERNAME=foo.bar \ NAMECOM_API_TOKEN=a379a6f6eeafb9a55e378c118034e2751e682fab \ lego --dns namedotcom -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NAMECOM_API_TOKEN` | API token | | `NAMECOM_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NAMECOM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `NAMECOM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 20) | | `NAMECOM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) | | `NAMECOM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.name.com/api-docs/DNS) - [Go client](https://github.com/namedotcom/go) ================================================ FILE: docs/content/dns/zz_gen_namesilo.md ================================================ --- title: "Namesilo" date: 2019-03-03T16:39:46+01:00 draft: false slug: namesilo dnsprovider: since: "v2.7.0" code: "namesilo" url: "https://www.namesilo.com/" --- Configuration for [Namesilo](https://www.namesilo.com/). - Code: `namesilo` - Since: v2.7.0 Here is an example bash command using the Namesilo provider: ```bash NAMESILO_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ lego --dns namesilo -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NAMESILO_API_KEY` | Client ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NAMESILO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NAMESILO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes | | `NAMESILO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000] | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.namesilo.com/api_reference.php) - [Go client](https://github.com/nrdcg/namesilo) ================================================ FILE: docs/content/dns/zz_gen_namesurfer.md ================================================ --- title: "FusionLayer NameSurfer" date: 2019-03-03T16:39:46+01:00 draft: false slug: namesurfer dnsprovider: since: "v4.32.0" code: "namesurfer" url: "https://www.fusionlayer.com/" --- Configuration for [FusionLayer NameSurfer](https://www.fusionlayer.com/). - Code: `namesurfer` - Since: v4.32.0 Here is an example bash command using the FusionLayer NameSurfer provider: ```bash NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \ NAMESURFER_API_KEY=xxx \ NAMESURFER_API_SECRET=yyy \ lego --dns namesurfer -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NAMESURFER_API_KEY` | API key name | | `NAMESURFER_API_SECRET` | API secret | | `NAMESURFER_BASE_URL` | The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NAMESURFER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `NAMESURFER_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate | | `NAMESURFER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NAMESURFER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `NAMESURFER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | | `NAMESURFER_VIEW` | DNS view name (optional, default: empty string) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10) ================================================ FILE: docs/content/dns/zz_gen_nearlyfreespeech.md ================================================ --- title: "NearlyFreeSpeech.NET" date: 2019-03-03T16:39:46+01:00 draft: false slug: nearlyfreespeech dnsprovider: since: "v4.8.0" code: "nearlyfreespeech" url: "https://nearlyfreespeech.net/" --- Configuration for [NearlyFreeSpeech.NET](https://nearlyfreespeech.net/). - Code: `nearlyfreespeech` - Since: v4.8.0 Here is an example bash command using the NearlyFreeSpeech.NET provider: ```bash NEARLYFREESPEECH_API_KEY=xxxxxx \ NEARLYFREESPEECH_LOGIN=xxxx \ lego --dns nearlyfreespeech -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NEARLYFREESPEECH_API_KEY` | API Key for API requests | | `NEARLYFREESPEECH_LOGIN` | Username for API requests | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NEARLYFREESPEECH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `NEARLYFREESPEECH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NEARLYFREESPEECH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `NEARLYFREESPEECH_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `NEARLYFREESPEECH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://members.nearlyfreespeech.net/wiki/API/Reference) ================================================ FILE: docs/content/dns/zz_gen_neodigit.md ================================================ --- title: "Neodigit" date: 2019-03-03T16:39:46+01:00 draft: false slug: neodigit dnsprovider: since: "v4.30.0" code: "neodigit" url: "https://www.neodigit.net" --- Configuration for [Neodigit](https://www.neodigit.net). - Code: `neodigit` - Since: v4.30.0 Here is an example bash command using the Neodigit provider: ```bash NEODIGIT_TOKEN=xxxxxx \ lego --dns neodigit -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NEODIGIT_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NEODIGIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `NEODIGIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `NEODIGIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `NEODIGIT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.neodigit.net/#dns) ================================================ FILE: docs/content/dns/zz_gen_netcup.md ================================================ --- title: "Netcup" date: 2019-03-03T16:39:46+01:00 draft: false slug: netcup dnsprovider: since: "v1.1.0" code: "netcup" url: "https://www.netcup.eu/" --- Configuration for [Netcup](https://www.netcup.eu/). - Code: `netcup` - Since: v1.1.0 Here is an example bash command using the Netcup provider: ```bash NETCUP_CUSTOMER_NUMBER=xxxx \ NETCUP_API_KEY=yyyy \ NETCUP_API_PASSWORD=zzzz \ lego --dns netcup -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NETCUP_API_KEY` | API key | | `NETCUP_API_PASSWORD` | API password | | `NETCUP_CUSTOMER_NUMBER` | Customer number | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NETCUP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `NETCUP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | | `NETCUP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.netcup-wiki.de/wiki/DNS_API) ================================================ FILE: docs/content/dns/zz_gen_netlify.md ================================================ --- title: "Netlify" date: 2019-03-03T16:39:46+01:00 draft: false slug: netlify dnsprovider: since: "v3.7.0" code: "netlify" url: "https://www.netlify.com" --- Configuration for [Netlify](https://www.netlify.com). - Code: `netlify` - Since: v3.7.0 Here is an example bash command using the Netlify provider: ```bash NETLIFY_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns netlify -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NETLIFY_TOKEN` | Token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NETLIFY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `NETLIFY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NETLIFY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `NETLIFY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://open-api.netlify.com/) ================================================ FILE: docs/content/dns/zz_gen_nicmanager.md ================================================ --- title: "Nicmanager" date: 2019-03-03T16:39:46+01:00 draft: false slug: nicmanager dnsprovider: since: "v4.5.0" code: "nicmanager" url: "https://www.nicmanager.com/" --- Configuration for [Nicmanager](https://www.nicmanager.com/). - Code: `nicmanager` - Since: v4.5.0 Here is an example bash command using the Nicmanager provider: ```bash ## Login using email NICMANAGER_API_EMAIL = "you@example.com" \ NICMANAGER_API_PASSWORD = "password" \ # Optionally, if your account has TOTP enabled, set the secret here NICMANAGER_API_OTP = "long-secret" \ lego --dns nicmanager -d '*.example.com' -d example.com run ## Login using account name + username NICMANAGER_API_LOGIN = "myaccount" \ NICMANAGER_API_USERNAME = "myuser" \ NICMANAGER_API_PASSWORD = "password" \ # Optionally, if your account has TOTP enabled, set the secret here NICMANAGER_API_OTP = "long-secret" \ lego --dns nicmanager -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NICMANAGER_API_EMAIL` | Email-based login | | `NICMANAGER_API_LOGIN` | Login, used for Username-based login | | `NICMANAGER_API_PASSWORD` | Password, always required | | `NICMANAGER_API_USERNAME` | Username, used for Username-based login | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NICMANAGER_API_MODE` | mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast') | | `NICMANAGER_API_OTP` | TOTP Secret (optional) | | `NICMANAGER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `NICMANAGER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NICMANAGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `NICMANAGER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 900) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description You can log in using your account name + username or using your email address. Optionally, if TOTP is configured for your account, set `NICMANAGER_API_OTP`. ## More information - [API documentation](https://api.nicmanager.com/docs/v1/) ================================================ FILE: docs/content/dns/zz_gen_nicru.md ================================================ --- title: "RU CENTER" date: 2019-03-03T16:39:46+01:00 draft: false slug: nicru dnsprovider: since: "v4.24.0" code: "nicru" url: "https://nic.ru/" --- Configuration for [RU CENTER](https://nic.ru/). - Code: `nicru` - Since: v4.24.0 Here is an example bash command using the RU CENTER provider: ```bash NICRU_USER="" \ NICRU_PASSWORD="" \ NICRU_SERVICE_ID="" \ NICRU_SECRET="" \ lego --dns nicru -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NICRU_PASSWORD` | Password for an account in RU CENTER | | `NICRU_SECRET` | Secret for application in DNS-hosting RU CENTER | | `NICRU_SERVICE_ID` | Service ID for application in DNS-hosting RU CENTER | | `NICRU_SERVICE_NAME` | Service Name for DNS-hosting RU CENTER | | `NICRU_USER` | Agreement for an account in RU CENTER | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NICRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | | `NICRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | | `NICRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Credential information You can find information about service ID and secret https://www.nic.ru/manager/oauth.cgi?step=oauth.app_list | ENV Variable | Parameter from page | Example | |---------------------|--------------------------------|-------------------| | NICRU_USER | Username (Number of agreement) | NNNNNNN/NIC-D | | NICRU_PASSWORD | Password account | | | NICRU_SERVICE_ID | Application ID | hex-based, len 32 | | NICRU_SECRET | Identity endpoint | string len 91 | ## More information - [API documentation](https://www.nic.ru/help/api-dns-hostinga_3643.html) ================================================ FILE: docs/content/dns/zz_gen_nifcloud.md ================================================ --- title: "NIFCloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: nifcloud dnsprovider: since: "v1.1.0" code: "nifcloud" url: "https://www.nifcloud.com/" --- Configuration for [NIFCloud](https://www.nifcloud.com/). - Code: `nifcloud` - Since: v1.1.0 Here is an example bash command using the NIFCloud provider: ```bash NIFCLOUD_ACCESS_KEY_ID=xxxx \ NIFCLOUD_SECRET_ACCESS_KEY=yyyy \ lego --dns nifcloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NIFCLOUD_ACCESS_KEY_ID` | Access key | | `NIFCLOUD_SECRET_ACCESS_KEY` | Secret access key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NIFCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `NIFCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NIFCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `NIFCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://mbaas.nifcloud.com/doc/current/rest/common/format.html) ================================================ FILE: docs/content/dns/zz_gen_njalla.md ================================================ --- title: "Njalla" date: 2019-03-03T16:39:46+01:00 draft: false slug: njalla dnsprovider: since: "v4.3.0" code: "njalla" url: "https://njal.la" --- Configuration for [Njalla](https://njal.la). - Code: `njalla` - Since: v4.3.0 Here is an example bash command using the Njalla provider: ```bash NJALLA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns njalla -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NJALLA_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NJALLA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `NJALLA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NJALLA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `NJALLA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://njal.la/api/) ================================================ FILE: docs/content/dns/zz_gen_nodion.md ================================================ --- title: "Nodion" date: 2019-03-03T16:39:46+01:00 draft: false slug: nodion dnsprovider: since: "v4.11.0" code: "nodion" url: "https://www.nodion.com" --- Configuration for [Nodion](https://www.nodion.com). - Code: `nodion` - Since: v4.11.0 Here is an example bash command using the Nodion provider: ```bash NODION_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns nodion -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NODION_API_TOKEN` | The API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NODION_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `NODION_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NODION_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `NODION_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.nodion.com/en/docs/dns/api/) ================================================ FILE: docs/content/dns/zz_gen_ns1.md ================================================ --- title: "NS1" date: 2019-03-03T16:39:46+01:00 draft: false slug: ns1 dnsprovider: since: "v0.4.0" code: "ns1" url: "https://ns1.com" --- Configuration for [NS1](https://ns1.com). - Code: `ns1` - Since: v0.4.0 Here is an example bash command using the NS1 provider: ```bash NS1_API_KEY=xxxx \ lego --dns ns1 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `NS1_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `NS1_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `NS1_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `NS1_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `NS1_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://ns1.com/api) - [Go client](https://github.com/ns1/ns1-go) ================================================ FILE: docs/content/dns/zz_gen_octenium.md ================================================ --- title: "Octenium" date: 2019-03-03T16:39:46+01:00 draft: false slug: octenium dnsprovider: since: "v4.27.0" code: "octenium" url: "https://octenium.com/" --- Configuration for [Octenium](https://octenium.com/). - Code: `octenium` - Since: v4.27.0 Here is an example bash command using the Octenium provider: ```bash OCTENIUM_API_KEY="xxx" \ lego --dns octenium -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `OCTENIUM_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `OCTENIUM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `OCTENIUM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `OCTENIUM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `OCTENIUM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://octenium.com/api#tag/Domains-DNS) ================================================ FILE: docs/content/dns/zz_gen_oraclecloud.md ================================================ --- title: "Oracle Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: oraclecloud dnsprovider: since: "v2.3.0" code: "oraclecloud" url: "https://cloud.oracle.com/home" --- Configuration for [Oracle Cloud](https://cloud.oracle.com/home). - Code: `oraclecloud` - Since: v2.3.0 Here is an example bash command using the Oracle Cloud provider: ```bash # Using API Key authentication: OCI_PRIVATE_KEY_PATH="~/.oci/oci_api_key.pem" \ OCI_PRIVATE_KEY_PASSWORD="secret" \ OCI_TENANCY_OCID="ocid1.tenancy.oc1..secret" \ OCI_USER_OCID="ocid1.user.oc1..secret" \ OCI_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \ OCI_REGION="us-phoenix-1" \ OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \ lego --dns oraclecloud -d '*.example.com' -d example.com run # Using Instance Principal authentication (when running on OCI compute instances): # https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm OCI_AUTH_TYPE="instance_principal" \ OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \ lego --dns oraclecloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `OCI_COMPARTMENT_OCID` | Compartment OCID | | `OCI_FINGERPRINT` | Public key fingerprint (ignored if `OCI_AUTH_TYPE=instance_principal`) | | `OCI_PRIVATE_KEY_PASSWORD` | Private key password (ignored if `OCI_AUTH_TYPE=instance_principal`) | | `OCI_PRIVATE_KEY_PATH` | Private key file (ignored if `OCI_AUTH_TYPE=instance_principal`) | | `OCI_REGION` | Region (it can be empty if `OCI_AUTH_TYPE=instance_principal`). | | `OCI_TENANCY_OCID` | Tenancy OCID (ignored if `OCI_AUTH_TYPE=instance_principal`) | | `OCI_USER_OCID` | User OCID (ignored if `OCI_AUTH_TYPE=instance_principal`) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `OCI_AUTH_TYPE` | Authorization type. Possible values: 'instance_principal', '' (Default: '') | | `OCI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 60) | | `OCI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `OCI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `OCI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | | `TF_VAR_fingerprint` | Alias on `OCI_FINGERPRINT` | | `TF_VAR_private_key_path` | Alias on `OCI_PRIVATE_KEY_PATH` | | `TF_VAR_region` | Alias on `OCI_REGION` | | `TF_VAR_tenancy_ocid` | Alias on `OCI_TENANCY_OCID` | | `TF_VAR_user_ocid` | Alias on `OCI_USER_OCID` | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm) - [Go client](https://github.com/oracle/oci-go-sdk) ================================================ FILE: docs/content/dns/zz_gen_otc.md ================================================ --- title: "Open Telekom Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: otc dnsprovider: since: "v0.4.1" code: "otc" url: "https://cloud.telekom.de/en" --- Configuration for [Open Telekom Cloud](https://cloud.telekom.de/en). - Code: `otc` - Since: v0.4.1 Here is an example bash command using the Open Telekom Cloud provider: ```bash OTC_DOMAIN_NAME=domain_name \ OTC_USER_NAME=user_name \ OTC_PASSWORD=password \ OTC_PROJECT_NAME=project_name \ lego --dns otc -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `OTC_DOMAIN_NAME` | Domain name | | `OTC_PASSWORD` | Password | | `OTC_PROJECT_NAME` | Project name | | `OTC_USER_NAME` | User name | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `OTC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `OTC_IDENTITY_ENDPOINT` | Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens) | | `OTC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `OTC_PRIVATE_ZONE` | Set to true to use private zones only (default: use public zones only) | | `OTC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `OTC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `OTC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.otc.t-systems.com/domain-name-service/api-ref/index.html) ================================================ FILE: docs/content/dns/zz_gen_ovh.md ================================================ --- title: "OVH" date: 2019-03-03T16:39:46+01:00 draft: false slug: ovh dnsprovider: since: "v0.4.0" code: "ovh" url: "https://www.ovh.com/" --- Configuration for [OVH](https://www.ovh.com/). - Code: `ovh` - Since: v0.4.0 Here is an example bash command using the OVH provider: ```bash # Application Key authentication: OVH_APPLICATION_KEY=1234567898765432 \ OVH_APPLICATION_SECRET=b9841238feb177a84330febba8a832089 \ OVH_CONSUMER_KEY=256vfsd347245sdfg \ OVH_ENDPOINT=ovh-eu \ lego --dns ovh -d '*.example.com' -d example.com run # Or Access Token: OVH_ACCESS_TOKEN=xxx \ OVH_ENDPOINT=ovh-eu \ lego --dns ovh -d '*.example.com' -d example.com run # Or OAuth2: OVH_CLIENT_ID=yyy \ OVH_CLIENT_SECRET=xxx \ OVH_ENDPOINT=ovh-eu \ lego --dns ovh -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `OVH_ACCESS_TOKEN` | Access token | | `OVH_APPLICATION_KEY` | Application key (Application Key authentication) | | `OVH_APPLICATION_SECRET` | Application secret (Application Key authentication) | | `OVH_CLIENT_ID` | Client ID (OAuth2) | | `OVH_CLIENT_SECRET` | Client secret (OAuth2) | | `OVH_CONSUMER_KEY` | Consumer key (Application Key authentication) | | `OVH_ENDPOINT` | Endpoint URL (ovh-eu or ovh-ca) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `OVH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 180) | | `OVH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `OVH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `OVH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Application Key and Secret Application key and secret can be created by following the [OVH guide](https://docs.ovh.com/gb/en/customer/first-steps-with-ovh-api/). When requesting the consumer key, the following configuration can be used to define access rights: ```json { "accessRules": [ { "method": "POST", "path": "/domain/zone/*" }, { "method": "DELETE", "path": "/domain/zone/*" } ] } ``` ## OAuth2 Client Credentials Another method for authentication is by using OAuth2 client credentials. An IAM policy and service account can be created by following the [OVH guide](https://help.ovhcloud.com/csm/en-manage-service-account?id=kb_article_view&sysparm_article=KB0059343). Following IAM policies need to be authorized for the affected domain: * dnsZone:apiovh:record/create * dnsZone:apiovh:record/delete * dnsZone:apiovh:refresh ## Important Note Both authentication methods cannot be used at the same time. ## More information - [API documentation](https://eu.api.ovh.com/) - [Go client](https://github.com/ovh/go-ovh) ================================================ FILE: docs/content/dns/zz_gen_pdns.md ================================================ --- title: "PowerDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: pdns dnsprovider: since: "v0.4.0" code: "pdns" url: "https://www.powerdns.com/" --- Configuration for [PowerDNS](https://www.powerdns.com/). - Code: `pdns` - Since: v0.4.0 Here is an example bash command using the PowerDNS provider: ```bash PDNS_API_URL=http://pdns-server:80/ \ PDNS_API_KEY=xxxx \ lego --dns pdns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `PDNS_API_KEY` | API key | | `PDNS_API_URL` | API URL | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `PDNS_API_VERSION` | Skip API version autodetection and use the provided version number. | | `PDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `PDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `PDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `PDNS_SERVER_NAME` | Name of the server in the URL, 'localhost' by default | | `PDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Information Tested and confirmed to work with PowerDNS authoritative server 3.4.8 and 4.0.1. Refer to [PowerDNS documentation](https://doc.powerdns.com/md/httpapi/README/) instructions on how to enable the built-in API interface. PowerDNS Notes: - PowerDNS API does not currently support SSL, therefore you should take care to ensure that traffic between lego and the PowerDNS API is over a trusted network, VPN etc. - In order to have the SOA serial automatically increment each time the `_acme-challenge` record is added/modified via the API, set `SOA-EDIT-API` to `INCEPTION-INCREMENT` for the zone in the `domainmetadata` table - Some PowerDNS servers doesn't have root API endpoints enabled and API version autodetection will not work. In that case version number can be defined using `PDNS_API_VERSION`. ## More information - [API documentation](https://doc.powerdns.com/md/httpapi/README/) ================================================ FILE: docs/content/dns/zz_gen_plesk.md ================================================ --- title: "plesk.com" date: 2019-03-03T16:39:46+01:00 draft: false slug: plesk dnsprovider: since: "v4.11.0" code: "plesk" url: "https://www.plesk.com/" --- Configuration for [plesk.com](https://www.plesk.com/). - Code: `plesk` - Since: v4.11.0 Here is an example bash command using the plesk.com provider: ```bash PLESK_SERVER_BASE_URL="https://plesk.myserver.com:8443" \ PLESK_USERNAME=xxxxxx \ PLESK_PASSWORD=yyyyyy \ lego --dns plesk -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `PLESK_PASSWORD` | API password | | `PLESK_SERVER_BASE_URL` | Base URL of the server (ex: https://plesk.myserver.com:8443) | | `PLESK_USERNAME` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `PLESK_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `PLESK_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `PLESK_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `PLESK_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference.28784/) ================================================ FILE: docs/content/dns/zz_gen_porkbun.md ================================================ --- title: "Porkbun" date: 2019-03-03T16:39:46+01:00 draft: false slug: porkbun dnsprovider: since: "v4.4.0" code: "porkbun" url: "https://porkbun.com/" --- Configuration for [Porkbun](https://porkbun.com/). - Code: `porkbun` - Since: v4.4.0 Here is an example bash command using the Porkbun provider: ```bash PORKBUN_SECRET_API_KEY=xxxxxx \ PORKBUN_API_KEY=yyyyyy \ lego --dns porkbun -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `PORKBUN_API_KEY` | API key | | `PORKBUN_SECRET_API_KEY` | secret API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `PORKBUN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `PORKBUN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `PORKBUN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | | `PORKBUN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://porkbun.com/api/json/v3/documentation) ================================================ FILE: docs/content/dns/zz_gen_rackspace.md ================================================ --- title: "Rackspace" date: 2019-03-03T16:39:46+01:00 draft: false slug: rackspace dnsprovider: since: "v0.4.0" code: "rackspace" url: "https://www.rackspace.com/" --- Configuration for [Rackspace](https://www.rackspace.com/). - Code: `rackspace` - Since: v0.4.0 Here is an example bash command using the Rackspace provider: ```bash RACKSPACE_USER=xxxx \ RACKSPACE_API_KEY=yyyy \ lego --dns rackspace -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `RACKSPACE_API_KEY` | API key | | `RACKSPACE_USER` | API user | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `RACKSPACE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `RACKSPACE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3) | | `RACKSPACE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `RACKSPACE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developer.rackspace.com/docs/cloud-dns/v1/) ================================================ FILE: docs/content/dns/zz_gen_rainyun.md ================================================ --- title: "Rain Yun/雨云" date: 2019-03-03T16:39:46+01:00 draft: false slug: rainyun dnsprovider: since: "v4.21.0" code: "rainyun" url: "https://www.rainyun.com" --- Configuration for [Rain Yun/雨云](https://www.rainyun.com). - Code: `rainyun` - Since: v4.21.0 Here is an example bash command using the Rain Yun/雨云 provider: ```bash RAINYUN_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns rainyun -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `RAINYUN_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `RAINYUN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `RAINYUN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `RAINYUN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `RAINYUN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.apifox.cn/apidoc/shared-a4595cc8-44c5-4678-a2a3-eed7738dab03/api-151416609) ================================================ FILE: docs/content/dns/zz_gen_rcodezero.md ================================================ --- title: "RcodeZero" date: 2019-03-03T16:39:46+01:00 draft: false slug: rcodezero dnsprovider: since: "v4.13" code: "rcodezero" url: "https://www.rcodezero.at/" --- Configuration for [RcodeZero](https://www.rcodezero.at/). - Code: `rcodezero` - Since: v4.13 Here is an example bash command using the RcodeZero provider: ```bash RCODEZERO_API_TOKEN= \ lego --dns rcodezero -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `RCODEZERO_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `RCODEZERO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `RCODEZERO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `RCODEZERO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) | | `RCODEZERO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description Generate your API Token via https://my.rcodezero.at with the `ACME` permissions. These are special tokens with limited access for ACME requests only. RcodeZero is an Anycast Network so the distribution of the DNS01-Challenge can take up to 2 minutes. ## More information - [API documentation](https://my.rcodezero.at/openapi) ================================================ FILE: docs/content/dns/zz_gen_regfish.md ================================================ --- title: "Regfish" date: 2019-03-03T16:39:46+01:00 draft: false slug: regfish dnsprovider: since: "v4.20.0" code: "regfish" url: "https://regfish.de/" --- Configuration for [Regfish](https://regfish.de/). - Code: `regfish` - Since: v4.20.0 Here is an example bash command using the Regfish provider: ```bash REGFISH_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns regfish -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `REGFISH_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `REGFISH_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `REGFISH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `REGFISH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `REGFISH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://regfish.readme.io/) - [Go client](https://github.com/regfish/regfish-dnsapi-go) ================================================ FILE: docs/content/dns/zz_gen_regru.md ================================================ --- title: "reg.ru" date: 2019-03-03T16:39:46+01:00 draft: false slug: regru dnsprovider: since: "v3.5.0" code: "regru" url: "https://www.reg.ru/" --- Configuration for [reg.ru](https://www.reg.ru/). - Code: `regru` - Since: v3.5.0 Here is an example bash command using the reg.ru provider: ```bash REGRU_USERNAME=xxxxxx \ REGRU_PASSWORD=yyyyyy \ lego --dns regru -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `REGRU_PASSWORD` | API password | | `REGRU_USERNAME` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `REGRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `REGRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `REGRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `REGRU_TLS_CERT` | authentication certificate | | `REGRU_TLS_KEY` | authentication private key | | `REGRU_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.reg.ru/support/help/api2) ================================================ FILE: docs/content/dns/zz_gen_rfc2136.md ================================================ --- title: "RFC2136" date: 2019-03-03T16:39:46+01:00 draft: false slug: rfc2136 dnsprovider: since: "v0.3.0" code: "rfc2136" url: "https://www.rfc-editor.org/rfc/rfc2136.html" --- Configuration for [RFC2136](https://www.rfc-editor.org/rfc/rfc2136.html). - Code: `rfc2136` - Since: v0.3.0 Here is an example bash command using the RFC2136 provider: ```bash RFC2136_NAMESERVER=127.0.0.1 \ RFC2136_TSIG_KEY=example.com \ RFC2136_TSIG_ALGORITHM=hmac-sha256. \ RFC2136_TSIG_SECRET=YWJjZGVmZGdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNDU= \ lego --dns rfc2136 -d '*.example.com' -d example.com run ## --- keyname=example.com; keyfile=example.com.key; tsig-keygen $keyname > $keyfile RFC2136_NAMESERVER=127.0.0.1 \ RFC2136_TSIG_FILE="$keyfile" \ lego --dns rfc2136 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `RFC2136_NAMESERVER` | Network address in the form "host" or "host:port" | | `RFC2136_TSIG_ALGORITHM` | TSIG algorithm. See [miekg/dns#tsig.go](https://github.com/miekg/dns/blob/master/tsig.go) for supported values. To disable TSIG authentication, leave the `RFC2136_TSIG_KEY` or `RFC2136_TSIG_SECRET` variables unset. | | `RFC2136_TSIG_KEY` | Name of the secret key as defined in DNS server configuration. To disable TSIG authentication, leave the `RFC2136_TSIG_KEY` variable unset. | | `RFC2136_TSIG_SECRET` | Secret key payload. To disable TSIG authentication, leave the `RFC2136_TSIG_SECRET` variable unset. | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `RFC2136_DNS_TIMEOUT` | API request timeout in seconds (Default: 10) | | `RFC2136_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `RFC2136_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `RFC2136_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `RFC2136_TSIG_FILE` | Path to a key file generated by tsig-keygen | | `RFC2136_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.rfc-editor.org/rfc/rfc2136.html) ================================================ FILE: docs/content/dns/zz_gen_rimuhosting.md ================================================ --- title: "RimuHosting" date: 2019-03-03T16:39:46+01:00 draft: false slug: rimuhosting dnsprovider: since: "v0.3.5" code: "rimuhosting" url: "https://rimuhosting.com" --- Configuration for [RimuHosting](https://rimuhosting.com). - Code: `rimuhosting` - Since: v0.3.5 Here is an example bash command using the RimuHosting provider: ```bash RIMUHOSTING_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns rimuhosting -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `RIMUHOSTING_API_KEY` | User API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `RIMUHOSTING_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `RIMUHOSTING_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `RIMUHOSTING_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `RIMUHOSTING_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://rimuhosting.com/dns/dyndns.jsp) ================================================ FILE: docs/content/dns/zz_gen_route53.md ================================================ --- title: "Amazon Route 53" date: 2019-03-03T16:39:46+01:00 draft: false slug: route53 dnsprovider: since: "v0.3.0" code: "route53" url: "https://aws.amazon.com/route53/" --- Configuration for [Amazon Route 53](https://aws.amazon.com/route53/). - Code: `route53` - Since: v0.3.0 Here is an example bash command using the Amazon Route 53 provider: ```bash AWS_ACCESS_KEY_ID=your_key_id \ AWS_SECRET_ACCESS_KEY=your_secret_access_key \ AWS_REGION=aws-region \ AWS_HOSTED_ZONE_ID=your_hosted_zone_id \ lego --dns route53 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `AWS_ACCESS_KEY_ID` | Managed by the AWS client. Access key ID (`AWS_ACCESS_KEY_ID_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead) | | `AWS_ASSUME_ROLE_ARN` | Managed by the AWS Role ARN (`AWS_ASSUME_ROLE_ARN_FILE` is not supported) | | `AWS_EXTERNAL_ID` | Managed by STS AssumeRole API operation (`AWS_EXTERNAL_ID_FILE` is not supported) | | `AWS_HOSTED_ZONE_ID` | Override the hosted zone ID. | | `AWS_PROFILE` | Managed by the AWS client (`AWS_PROFILE_FILE` is not supported) | | `AWS_REGION` | Managed by the AWS client (`AWS_REGION_FILE` is not supported) | | `AWS_SDK_LOAD_CONFIG` | Managed by the AWS client. Retrieve the region from the CLI config file (`AWS_SDK_LOAD_CONFIG_FILE` is not supported) | | `AWS_SECRET_ACCESS_KEY` | Managed by the AWS client. Secret access key (`AWS_SECRET_ACCESS_KEY_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead) | | `AWS_WAIT_FOR_RECORD_SETS_CHANGED` | Wait for changes to be INSYNC (it can be unstable) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `AWS_MAX_RETRIES` | The number of maximum returns the service will use to make an individual API request | | `AWS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | | `AWS_PRIVATE_ZONE` | Set to true to use private zones only (default: use public zones only) | | `AWS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `AWS_SHARED_CREDENTIALS_FILE` | Managed by the AWS client. Shared credentials file. | | `AWS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Description AWS Credentials are automatically detected in the following locations and prioritized in the following order: 1. Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, [`AWS_SESSION_TOKEN`] 2. Shared credentials file (defaults to `~/.aws/credentials`, profiles can be specified using `AWS_PROFILE`) 3. Amazon EC2 IAM role The AWS Region is automatically detected in the following locations and prioritized in the following order: 1. Environment variables: `AWS_REGION` 2. Shared configuration file if `AWS_SDK_LOAD_CONFIG` is set (defaults to `~/.aws/config`, profiles can be specified using `AWS_PROFILE`) If `AWS_HOSTED_ZONE_ID` is not set, Lego tries to determine the correct public hosted zone via the FQDN. See also: - [sessions](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/sessions.html) - [Setting AWS Credentials](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-credentials) - [Setting AWS Region](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-the-region) ## IAM Policy Examples ### Broad privileges for testing purposes The following [IAM policy](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html) document grants access to the required APIs needed by lego to complete the DNS challenge. A word of caution: These permissions grant write access to any DNS record in any hosted zone, so it is recommended to narrow them down as much as possible if you are using this policy in production. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "route53:GetChange", "route53:ChangeResourceRecordSets", "route53:ListResourceRecordSets" ], "Resource": [ "arn:aws:route53:::hostedzone/*", "arn:aws:route53:::change/*" ] }, { "Effect": "Allow", "Action": "route53:ListHostedZonesByName", "Resource": "*" } ] } ``` ### Least privilege policy for production purposes The following AWS IAM policy document describes the least privilege permissions required for lego to complete the DNS challenge. Write access is limited to a specified hosted zone's DNS TXT records with a key of `_acme-challenge.example.com`. Replace `Z11111112222222333333` with your hosted zone ID and `example.com` with your domain name to use this policy. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "route53:GetChange", "Resource": "arn:aws:route53:::change/*" }, { "Effect": "Allow", "Action": "route53:ListHostedZonesByName", "Resource": "*" }, { "Effect": "Allow", "Action": [ "route53:ListResourceRecordSets" ], "Resource": [ "arn:aws:route53:::hostedzone/Z11111112222222333333" ] }, { "Effect": "Allow", "Action": [ "route53:ChangeResourceRecordSets" ], "Resource": [ "arn:aws:route53:::hostedzone/Z11111112222222333333" ], "Condition": { "ForAllValues:StringEquals": { "route53:ChangeResourceRecordSetsNormalizedRecordNames": [ "_acme-challenge.example.com" ], "route53:ChangeResourceRecordSetsRecordTypes": [ "TXT" ] } } } ] } ``` ## More information - [API documentation](https://docs.aws.amazon.com/Route53/latest/APIReference/API_Operations_Amazon_Route_53.html) - [Go client](https://github.com/aws/aws-sdk-go-v2) ================================================ FILE: docs/content/dns/zz_gen_safedns.md ================================================ --- title: "ANS SafeDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: safedns dnsprovider: since: "v4.6.0" code: "safedns" url: "https://www.ans.co.uk/" --- Configuration for [ANS SafeDNS](https://www.ans.co.uk/). - Code: `safedns` - Since: v4.6.0 Here is an example bash command using the ANS SafeDNS provider: ```bash SAFEDNS_AUTH_TOKEN=xxxxxx \ lego --dns safedns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SAFEDNS_AUTH_TOKEN` | Authentication token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SAFEDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SAFEDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `SAFEDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `SAFEDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.ukfast.io/documentation/safedns) ================================================ FILE: docs/content/dns/zz_gen_sakuracloud.md ================================================ --- title: "Sakura Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: sakuracloud dnsprovider: since: "v1.1.0" code: "sakuracloud" url: "https://cloud.sakura.ad.jp/" --- Configuration for [Sakura Cloud](https://cloud.sakura.ad.jp/). - Code: `sakuracloud` - Since: v1.1.0 Here is an example bash command using the Sakura Cloud provider: ```bash SAKURACLOUD_ACCESS_TOKEN=xxxxx \ SAKURACLOUD_ACCESS_TOKEN_SECRET=yyyyy \ lego --dns sakuracloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SAKURACLOUD_ACCESS_TOKEN` | Access token | | `SAKURACLOUD_ACCESS_TOKEN_SECRET` | Access token secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SAKURACLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `SAKURACLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `SAKURACLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `SAKURACLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developer.sakura.ad.jp/cloud/api/1.1/) - [Go client](https://github.com/sacloud/iaas-api-go) ================================================ FILE: docs/content/dns/zz_gen_scaleway.md ================================================ --- title: "Scaleway" date: 2019-03-03T16:39:46+01:00 draft: false slug: scaleway dnsprovider: since: "v3.4.0" code: "scaleway" url: "https://developers.scaleway.com/" --- Configuration for [Scaleway](https://developers.scaleway.com/). - Code: `scaleway` - Since: v3.4.0 Here is an example bash command using the Scaleway provider: ```bash SCW_SECRET_KEY=xxxxxxx-xxxxx-xxxx-xxx-xxxxxx \ lego --dns scaleway -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SCW_PROJECT_ID` | Project to use (optional) | | `SCW_SECRET_KEY` | Secret key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SCW_ACCESS_KEY` | Access key | | `SCW_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SCW_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `SCW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `SCW_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.scaleway.com/en/products/domain/dns/api/) ================================================ FILE: docs/content/dns/zz_gen_selectel.md ================================================ --- title: "Selectel" date: 2019-03-03T16:39:46+01:00 draft: false slug: selectel dnsprovider: since: "v1.2.0" code: "selectel" url: "https://kb.selectel.com/" --- Configuration for [Selectel](https://kb.selectel.com/). - Code: `selectel` - Since: v1.2.0 Here is an example bash command using the Selectel provider: ```bash SELECTEL_API_TOKEN=xxxxx \ lego --dns selectel -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SELECTEL_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SELECTEL_BASE_URL` | API endpoint URL | | `SELECTEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SELECTEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `SELECTEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `SELECTEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://kb.selectel.com/23136054.html) ================================================ FILE: docs/content/dns/zz_gen_selectelv2.md ================================================ --- title: "Selectel v2" date: 2019-03-03T16:39:46+01:00 draft: false slug: selectelv2 dnsprovider: since: "v4.17.0" code: "selectelv2" url: "https://selectel.ru" --- Configuration for [Selectel v2](https://selectel.ru). - Code: `selectelv2` - Since: v4.17.0 Here is an example bash command using the Selectel v2 provider: ```bash SELECTELV2_USERNAME=trex \ SELECTELV2_PASSWORD=xxxxx \ SELECTELV2_ACCOUNT_ID=1234567 \ SELECTELV2_PROJECT_ID=111a11111aaa11aa1a11aaa11111aa1a \ lego --dns selectelv2 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SELECTELV2_ACCOUNT_ID` | Selectel account ID (INT) | | `SELECTELV2_PASSWORD` | Openstack username's password | | `SELECTELV2_PROJECT_ID` | Cloud project ID (UUID) | | `SELECTELV2_USERNAME` | Openstack username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SELECTELV2_AUTH_REGION` | Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1') | | `SELECTELV2_AUTH_URL` | Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/') | | `SELECTELV2_BASE_URL` | API endpoint URL | | `SELECTELV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SELECTELV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `SELECTELV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `SELECTELV2_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | | `SELECTELV2_USER_DOMAIN_NAME` | To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.selectel.ru/docs/cloud-services/dns_api/dns_api_actual/) - [Go client](https://github.com/selectel/domains-go) ================================================ FILE: docs/content/dns/zz_gen_selfhostde.md ================================================ --- title: "SelfHost.(de|eu)" date: 2019-03-03T16:39:46+01:00 draft: false slug: selfhostde dnsprovider: since: "v4.19.0" code: "selfhostde" url: "https://www.selfhost.de" --- Configuration for [SelfHost.(de|eu)](https://www.selfhost.de). - Code: `selfhostde` - Since: v4.19.0 Here is an example bash command using the SelfHost.(de|eu) provider: ```bash SELFHOSTDE_USERNAME=xxx \ SELFHOSTDE_PASSWORD=yyy \ SELFHOSTDE_RECORDS_MAPPING=my.example.com:123 \ lego --dns selfhostde -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SELFHOSTDE_PASSWORD` | Password | | `SELFHOSTDE_RECORDS_MAPPING` | Record IDs mapping with domains (ex: example.com:123:456,example.org:789,foo.example.com:147) | | `SELFHOSTDE_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SELFHOSTDE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SELFHOSTDE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) | | `SELFHOSTDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) | | `SELFHOSTDE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. So, before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), you must create: - one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. - two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. After that you must edit the TXT record(s) to get the ID(s). You then must prepare the `SELFHOSTDE_RECORDS_MAPPING` environment variable with the following format: ``` ::,::,:: ``` where each group of domain + record ID(s) is separated with a comma (`,`), and the domain and record ID(s) are separated with a colon (`:`). For example, if you want to create or renew a certificate for `my.example.org`, `*.my.example.org`, and `other.example.org`, you would need: - two separate records for `_acme-challenge.my.example.org` - and another separate record for `_acme-challenge.other.example.org` The resulting environment variable would then be: `SELFHOSTDE_RECORDS_MAPPING=my.example.com:123:456,other.example.com:789` ================================================ FILE: docs/content/dns/zz_gen_servercow.md ================================================ --- title: "Servercow" date: 2019-03-03T16:39:46+01:00 draft: false slug: servercow dnsprovider: since: "v3.4.0" code: "servercow" url: "https://servercow.de/" --- Configuration for [Servercow](https://servercow.de/). - Code: `servercow` - Since: v3.4.0 Here is an example bash command using the Servercow provider: ```bash SERVERCOW_USERNAME=xxxxxxxx \ SERVERCOW_PASSWORD=xxxxxxxx \ lego --dns servercow -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SERVERCOW_PASSWORD` | API password | | `SERVERCOW_USERNAME` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SERVERCOW_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SERVERCOW_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `SERVERCOW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `SERVERCOW_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://wiki.servercow.de/en/domains/dns_api/api-syntax/) ================================================ FILE: docs/content/dns/zz_gen_shellrent.md ================================================ --- title: "Shellrent" date: 2019-03-03T16:39:46+01:00 draft: false slug: shellrent dnsprovider: since: "v4.16.0" code: "shellrent" url: "https://www.shellrent.com/" --- Configuration for [Shellrent](https://www.shellrent.com/). - Code: `shellrent` - Since: v4.16.0 Here is an example bash command using the Shellrent provider: ```bash SHELLRENT_USERNAME=xxxx \ SHELLRENT_TOKEN=yyyy \ lego --dns shellrent -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SHELLRENT_TOKEN` | Token | | `SHELLRENT_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SHELLRENT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SHELLRENT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `SHELLRENT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `SHELLRENT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.shellrent.com/section/api2) ================================================ FILE: docs/content/dns/zz_gen_simply.md ================================================ --- title: "Simply.com" date: 2019-03-03T16:39:46+01:00 draft: false slug: simply dnsprovider: since: "v4.4.0" code: "simply" url: "https://www.simply.com/en/domains/" --- Configuration for [Simply.com](https://www.simply.com/en/domains/). - Code: `simply` - Since: v4.4.0 Here is an example bash command using the Simply.com provider: ```bash SIMPLY_ACCOUNT_NAME=xxxxxx \ SIMPLY_API_KEY=yyyyyy \ lego --dns simply -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SIMPLY_ACCOUNT_NAME` | Account name | | `SIMPLY_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SIMPLY_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SIMPLY_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `SIMPLY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `SIMPLY_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.simply.com/en/docs/api/) ================================================ FILE: docs/content/dns/zz_gen_sonic.md ================================================ --- title: "Sonic" date: 2019-03-03T16:39:46+01:00 draft: false slug: sonic dnsprovider: since: "v4.4.0" code: "sonic" url: "https://www.sonic.com/" --- Configuration for [Sonic](https://www.sonic.com/). - Code: `sonic` - Since: v4.4.0 Here is an example bash command using the Sonic provider: ```bash SONIC_USER_ID=12345 \ SONIC_API_KEY=4d6fbf2f9ab0fa11697470918d37625851fc0c51 \ lego --dns sonic -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SONIC_API_KEY` | API Key | | `SONIC_USER_ID` | User ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SONIC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `SONIC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `SONIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `SONIC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `SONIC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## API keys The API keys must be generated by calling the `dyndns/api_key` endpoint. Example: ```bash $ curl -X POST -H "Content-Type: application/json" --data '{"username":"notarealuser","password":"notarealpassword","hostname":"example.com"}' https://public-api.sonic.net/dyndns/api_key {"userid":"12345","apikey":"4d6fbf2f9ab0fa11697470918d37625851fc0c51","result":200,"message":"OK"} ``` See https://public-api.sonic.net/dyndns/#requesting_an_api_key for additional details. This `userid` and `apikey` combo allow modifications to any DNS entries connected to the managed domain (hostname). Hostname should be the toplevel domain managed e.g. `example.com` not `www.example.com`. ## More information - [API documentation](https://public-api.sonic.net/dyndns/) ================================================ FILE: docs/content/dns/zz_gen_spaceship.md ================================================ --- title: "Spaceship" date: 2019-03-03T16:39:46+01:00 draft: false slug: spaceship dnsprovider: since: "v4.22.0" code: "spaceship" url: "https://www.spaceship.com/" --- Configuration for [Spaceship](https://www.spaceship.com/). - Code: `spaceship` - Since: v4.22.0 Here is an example bash command using the Spaceship provider: ```bash SPACESHIP_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ SPACESHIP_API_SECRET="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns spaceship -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SPACESHIP_API_KEY` | API key | | `SPACESHIP_API_SECRET` | API secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SPACESHIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SPACESHIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `SPACESHIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `SPACESHIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://docs.spaceship.dev/#tag/DNS-records) ================================================ FILE: docs/content/dns/zz_gen_stackpath.md ================================================ --- title: "Stackpath" date: 2019-03-03T16:39:46+01:00 draft: false slug: stackpath dnsprovider: since: "v1.1.0" code: "stackpath" url: "https://www.stackpath.com/" --- Configuration for [Stackpath](https://www.stackpath.com/). - Code: `stackpath` - Since: v1.1.0 Here is an example bash command using the Stackpath provider: ```bash STACKPATH_CLIENT_ID=xxxxx \ STACKPATH_CLIENT_SECRET=yyyyy \ STACKPATH_STACK_ID=zzzzz \ lego --dns stackpath -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `STACKPATH_CLIENT_ID` | Client ID | | `STACKPATH_CLIENT_SECRET` | Client secret | | `STACKPATH_STACK_ID` | Stack ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `STACKPATH_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `STACKPATH_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `STACKPATH_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developer.stackpath.com/en/api/dns/#tag/Zone) ================================================ FILE: docs/content/dns/zz_gen_syse.md ================================================ --- title: "Syse" date: 2019-03-03T16:39:46+01:00 draft: false slug: syse dnsprovider: since: "v4.30.0" code: "syse" url: "https://www.syse.no/" --- Configuration for [Syse](https://www.syse.no/). - Code: `syse` - Since: v4.30.0 Here is an example bash command using the Syse provider: ```bash SYSE_CREDENTIALS=example.com:password \ lego --dns syse -d '*.example.com' -d example.com run SYSE_CREDENTIALS=example.org:password1,example.com:password2 \ lego --dns syse -d '*.example.org' -d example.org -d '*.example.com' -d example.com ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SYSE_CREDENTIALS` | Comma-separated list of `zone:password` credential pairs | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `SYSE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `SYSE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `SYSE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 1200) | | `SYSE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.syse.no/api/dns) ================================================ FILE: docs/content/dns/zz_gen_technitium.md ================================================ --- title: "Technitium" date: 2019-03-03T16:39:46+01:00 draft: false slug: technitium dnsprovider: since: "v4.20.0" code: "technitium" url: "https://technitium.com/" --- Configuration for [Technitium](https://technitium.com/). - Code: `technitium` - Since: v4.20.0 Here is an example bash command using the Technitium provider: ```bash TECHNITIUM_SERVER_BASE_URL="https://localhost:5380" \ TECHNITIUM_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns technitium -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `TECHNITIUM_API_TOKEN` | API token | | `TECHNITIUM_SERVER_BASE_URL` | Server base URL | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `TECHNITIUM_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `TECHNITIUM_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `TECHNITIUM_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `TECHNITIUM_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). Technitium DNS Server supports Dynamic Updates (RFC2136) for primary zones, so you can also use the [RFC2136 provider](https://go-acme.github.io/lego/dns/rfc2136/index.html). [RFC2136 provider](https://go-acme.github.io/lego/dns/rfc2136/index.html) is much better compared to the HTTP API option from security perspective. Technitium recommends to use it in production over the HTTP API. ## More information - [API documentation](https://github.com/TechnitiumSoftware/DnsServer/blob/0f83d23e605956b66ac76921199e241d9cc061bd/APIDOCS.md) ================================================ FILE: docs/content/dns/zz_gen_tencentcloud.md ================================================ --- title: "Tencent Cloud DNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: tencentcloud dnsprovider: since: "v4.6.0" code: "tencentcloud" url: "https://cloud.tencent.com/product/dns" --- Configuration for [Tencent Cloud DNS](https://cloud.tencent.com/product/dns). - Code: `tencentcloud` - Since: v4.6.0 Here is an example bash command using the Tencent Cloud DNS provider: ```bash TENCENTCLOUD_SECRET_ID=abcdefghijklmnopqrstuvwx \ TENCENTCLOUD_SECRET_KEY=your-secret-key \ lego --dns tencentcloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `TENCENTCLOUD_SECRET_ID` | Access key ID | | `TENCENTCLOUD_SECRET_KEY` | Access Key secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `TENCENTCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `TENCENTCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `TENCENTCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `TENCENTCLOUD_REGION` | Region | | `TENCENTCLOUD_SESSION_TOKEN` | Access Key token | | `TENCENTCLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://cloud.tencent.com/document/product/1427/56153) - [Go client](https://github.com/tencentcloud/tencentcloud-sdk-go) ================================================ FILE: docs/content/dns/zz_gen_timewebcloud.md ================================================ --- title: "Timeweb Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: timewebcloud dnsprovider: since: "v4.20.0" code: "timewebcloud" url: "https://timeweb.cloud/" --- Configuration for [Timeweb Cloud](https://timeweb.cloud/). - Code: `timewebcloud` - Since: v4.20.0 Here is an example bash command using the Timeweb Cloud provider: ```bash TIMEWEBCLOUD_AUTH_TOKEN=xxxxxx \ lego --dns timewebcloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `TIMEWEBCLOUD_AUTH_TOKEN` | Authentication token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `TIMEWEBCLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | | `TIMEWEBCLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `TIMEWEBCLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://timeweb.cloud/api-docs) ================================================ FILE: docs/content/dns/zz_gen_todaynic.md ================================================ --- title: "TodayNIC/时代互联" date: 2019-03-03T16:39:46+01:00 draft: false slug: todaynic dnsprovider: since: "v4.32.0" code: "todaynic" url: "https://www.todaynic.com/" --- Configuration for [TodayNIC/时代互联](https://www.todaynic.com/). - Code: `todaynic` - Since: v4.32.0 Here is an example bash command using the TodayNIC/时代互联 provider: ```bash TODAYNIC_AUTH_USER_ID="xxx" \ TODAYNIC_API_KEY="yyy" \ lego --dns todaynic -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `TODAYNIC_API_KEY` | API key | | `TODAYNIC_AUTH_USER_ID` | account ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `TODAYNIC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `TODAYNIC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `TODAYNIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `TODAYNIC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.todaynic.com/partner/mode_Http_Api_detail.php) ================================================ FILE: docs/content/dns/zz_gen_transip.md ================================================ --- title: "TransIP" date: 2019-03-03T16:39:46+01:00 draft: false slug: transip dnsprovider: since: "v2.0.0" code: "transip" url: "https://www.transip.nl/" --- Configuration for [TransIP](https://www.transip.nl/). - Code: `transip` - Since: v2.0.0 Here is an example bash command using the TransIP provider: ```bash TRANSIP_ACCOUNT_NAME = "Account name" \ TRANSIP_PRIVATE_KEY_PATH = "transip.key" \ lego --dns transip -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `TRANSIP_ACCOUNT_NAME` | Account name | | `TRANSIP_PRIVATE_KEY_PATH` | Private key path | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `TRANSIP_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `TRANSIP_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `TRANSIP_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | | `TRANSIP_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.transip.eu/rest/docs.html) - [Go client](https://github.com/transip/gotransip) ================================================ FILE: docs/content/dns/zz_gen_ultradns.md ================================================ --- title: "Ultradns" date: 2019-03-03T16:39:46+01:00 draft: false slug: ultradns dnsprovider: since: "v4.10.0" code: "ultradns" url: "https://vercara.com/authoritative-dns" --- Configuration for [Ultradns](https://vercara.com/authoritative-dns). - Code: `ultradns` - Since: v4.10.0 Here is an example bash command using the Ultradns provider: ```bash ULTRADNS_USERNAME=username \ ULTRADNS_PASSWORD=password \ lego --dns ultradns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ULTRADNS_PASSWORD` | API Password | | `ULTRADNS_USERNAME` | API Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ULTRADNS_ENDPOINT` | API endpoint URL, defaults to https://api.ultradns.com/ | | `ULTRADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | | `ULTRADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `ULTRADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://ultra-portalstatic.ultradns.com/static/docs/REST-API_User_Guide.pdf) - [Go client](https://github.com/ultradns/ultradns-go-sdk) ================================================ FILE: docs/content/dns/zz_gen_uniteddomains.md ================================================ --- title: "United-Domains" date: 2019-03-03T16:39:46+01:00 draft: false slug: uniteddomains dnsprovider: since: "v4.29.0" code: "uniteddomains" url: "https://www.united-domains.de/" --- Configuration for [United-Domains](https://www.united-domains.de/). - Code: `uniteddomains` - Since: v4.29.0 Here is an example bash command using the United-Domains provider: ```bash UNITEDDOMAINS_API_KEY=xxxxxxxx \ lego --dns uniteddomains -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `UNITEDDOMAINS_API_KEY` | API key `.` https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/ | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `UNITEDDOMAINS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `UNITEDDOMAINS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `UNITEDDOMAINS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 900) | | `UNITEDDOMAINS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.united-domains.de/dns-apidoc/) ================================================ FILE: docs/content/dns/zz_gen_variomedia.md ================================================ --- title: "Variomedia" date: 2019-03-03T16:39:46+01:00 draft: false slug: variomedia dnsprovider: since: "v4.8.0" code: "variomedia" url: "https://www.variomedia.de/" --- Configuration for [Variomedia](https://www.variomedia.de/). - Code: `variomedia` - Since: v4.8.0 Here is an example bash command using the Variomedia provider: ```bash VARIOMEDIA_API_TOKEN=xxxx \ lego --dns variomedia -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VARIOMEDIA_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VARIOMEDIA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `VARIOMEDIA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `VARIOMEDIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `VARIOMEDIA_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `VARIOMEDIA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.variomedia.de/docs/dns-records.html) ================================================ FILE: docs/content/dns/zz_gen_vegadns.md ================================================ --- title: "VegaDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: vegadns dnsprovider: since: "v1.1.0" code: "vegadns" url: "https://github.com/shupp/VegaDNS-API" --- Configuration for [VegaDNS](https://github.com/shupp/VegaDNS-API). - Code: `vegadns` - Since: v1.1.0 {{% notice note %}} _Please contribute by adding a CLI example._ {{% /notice %}} ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `SECRET_VEGADNS_KEY` | API key | | `SECRET_VEGADNS_SECRET` | API secret | | `VEGADNS_URL` | API endpoint URL | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VEGADNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 60) | | `VEGADNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 720) | | `VEGADNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 10) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://github.com/shupp/VegaDNS-API) - [Go client](https://github.com/OpenDNS/vegadns2client) ================================================ FILE: docs/content/dns/zz_gen_vercel.md ================================================ --- title: "Vercel" date: 2019-03-03T16:39:46+01:00 draft: false slug: vercel dnsprovider: since: "v4.7.0" code: "vercel" url: "https://vercel.com" --- Configuration for [Vercel](https://vercel.com). - Code: `vercel` - Since: v4.7.0 Here is an example bash command using the Vercel provider: ```bash VERCEL_API_TOKEN=xxxxxx \ lego --dns vercel -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VERCEL_API_TOKEN` | Authentication token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VERCEL_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `VERCEL_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `VERCEL_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `VERCEL_TEAM_ID` | Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx) | | `VERCEL_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://vercel.com/docs/rest-api#endpoints/dns) ================================================ FILE: docs/content/dns/zz_gen_versio.md ================================================ --- title: "Versio.[nl|eu|uk]" date: 2019-03-03T16:39:46+01:00 draft: false slug: versio dnsprovider: since: "v2.7.0" code: "versio" url: "https://www.versio.nl/domeinnamen" --- Configuration for [Versio.[nl|eu|uk]](https://www.versio.nl/domeinnamen). - Code: `versio` - Since: v2.7.0 Here is an example bash command using the Versio.[nl|eu|uk] provider: ```bash VERSIO_USERNAME= \ VERSIO_PASSWORD= \ lego --dns versio -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VERSIO_PASSWORD` | Basic authentication password | | `VERSIO_USERNAME` | Basic authentication username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VERSIO_ENDPOINT` | The endpoint URL of the API Server | | `VERSIO_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `VERSIO_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `VERSIO_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `VERSIO_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `VERSIO_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). To test with the sandbox environment set ```VERSIO_ENDPOINT=https://www.versio.nl/testapi/v1/``` ## More information - [API documentation](https://www.versio.nl/RESTapidoc/) ================================================ FILE: docs/content/dns/zz_gen_vinyldns.md ================================================ --- title: "VinylDNS" date: 2019-03-03T16:39:46+01:00 draft: false slug: vinyldns dnsprovider: since: "v4.4.0" code: "vinyldns" url: "https://www.vinyldns.io" --- Configuration for [VinylDNS](https://www.vinyldns.io). - Code: `vinyldns` - Since: v4.4.0 Here is an example bash command using the VinylDNS provider: ```bash VINYLDNS_ACCESS_KEY=xxxxxx \ VINYLDNS_SECRET_KEY=yyyyy \ VINYLDNS_HOST=https://api.vinyldns.example.org:9443 \ lego --dns vinyldns -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VINYLDNS_ACCESS_KEY` | The VinylDNS API key | | `VINYLDNS_HOST` | The VinylDNS API URL | | `VINYLDNS_SECRET_KEY` | The VinylDNS API Secret key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VINYLDNS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `VINYLDNS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 4) | | `VINYLDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `VINYLDNS_QUOTE_VALUE` | Adds quotes around the TXT record value (Default: false) | | `VINYLDNS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 30) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). The vinyldns integration makes use of dotted hostnames to ease permission management. Users are required to have DELETE ACL level or zone admin permissions on the VinylDNS zone containing the target host. ## More information - [API documentation](https://www.vinyldns.io/api/) - [Go client](https://github.com/vinyldns/go-vinyldns) ================================================ FILE: docs/content/dns/zz_gen_virtualname.md ================================================ --- title: "Virtualname" date: 2019-03-03T16:39:46+01:00 draft: false slug: virtualname dnsprovider: since: "v4.30.0" code: "virtualname" url: "https://www.virtualname.es/" --- Configuration for [Virtualname](https://www.virtualname.es/). - Code: `virtualname` - Since: v4.30.0 Here is an example bash command using the Virtualname provider: ```bash VIRTUALNAME_TOKEN=xxxxxx \ lego --dns virtualname -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VIRTUALNAME_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VIRTUALNAME_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `VIRTUALNAME_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `VIRTUALNAME_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | | `VIRTUALNAME_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.virtualname.net/#dns) ================================================ FILE: docs/content/dns/zz_gen_vkcloud.md ================================================ --- title: "VK Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: vkcloud dnsprovider: since: "v4.9.0" code: "vkcloud" url: "https://mcs.mail.ru/" --- Configuration for [VK Cloud](https://mcs.mail.ru/). - Code: `vkcloud` - Since: v4.9.0 Here is an example bash command using the VK Cloud provider: ```bash VK_CLOUD_PROJECT_ID="" \ VK_CLOUD_USERNAME="" \ VK_CLOUD_PASSWORD="" \ lego --dns vkcloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VK_CLOUD_PASSWORD` | Password for VK Cloud account | | `VK_CLOUD_PROJECT_ID` | String ID of project in VK Cloud | | `VK_CLOUD_USERNAME` | Email of VK Cloud account | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VK_CLOUD_DNS_ENDPOINT` | URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds | | `VK_CLOUD_DOMAIN_NAME` | Openstack users domain name. Defaults to `users` but can be changed for usage with private clouds | | `VK_CLOUD_IDENTITY_ENDPOINT` | URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds | | `VK_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `VK_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `VK_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Credential information You can find all required and additional information on ["Project/Keys" page](https://mcs.mail.ru/app/en/project/keys) of your cloud. | ENV Variable | Parameter from page | |----------------------------|---------------------| | VK_CLOUD_PROJECT_ID | Project ID | | VK_CLOUD_USERNAME | Username | | VK_CLOUD_DOMAIN_NAME | User Domain Name | | VK_CLOUD_IDENTITY_ENDPOINT | Identity endpoint | ## More information - [API documentation](https://mcs.mail.ru/docs/networks/vnet/networks/publicdns/api) ================================================ FILE: docs/content/dns/zz_gen_volcengine.md ================================================ --- title: "Volcano Engine/火山引擎" date: 2019-03-03T16:39:46+01:00 draft: false slug: volcengine dnsprovider: since: "v4.19.0" code: "volcengine" url: "https://www.volcengine.com/" --- Configuration for [Volcano Engine/火山引擎](https://www.volcengine.com/). - Code: `volcengine` - Since: v4.19.0 Here is an example bash command using the Volcano Engine/火山引擎 provider: ```bash VOLC_ACCESSKEY=xxx \ VOLC_SECRETKEY=yyy \ lego --dns volcengine -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VOLC_ACCESSKEY` | Access Key ID (AK) | | `VOLC_SECRETKEY` | Secret Access Key (SK) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VOLC_HOST` | API host | | `VOLC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 15) | | `VOLC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `VOLC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 240) | | `VOLC_REGION` | Region | | `VOLC_SCHEME` | API scheme | | `VOLC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.volcengine.com/docs/6758/155086) - [Go client](https://github.com/volcengine/volc-sdk-golang) ================================================ FILE: docs/content/dns/zz_gen_vscale.md ================================================ --- title: "Vscale" date: 2019-03-03T16:39:46+01:00 draft: false slug: vscale dnsprovider: since: "v2.0.0" code: "vscale" url: "https://vscale.io/" --- Configuration for [Vscale](https://vscale.io/). - Code: `vscale` - Since: v2.0.0 Here is an example bash command using the Vscale provider: ```bash VSCALE_API_TOKEN=xxxxx \ lego --dns vscale -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VSCALE_API_TOKEN` | API token | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VSCALE_BASE_URL` | API endpoint URL | | `VSCALE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `VSCALE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `VSCALE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `VSCALE_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://developers.vscale.io/documentation/api/v1/#api-Domains_Records) ================================================ FILE: docs/content/dns/zz_gen_vultr.md ================================================ --- title: "Vultr" date: 2019-03-03T16:39:46+01:00 draft: false slug: vultr dnsprovider: since: "v0.3.1" code: "vultr" url: "https://www.vultr.com/" --- Configuration for [Vultr](https://www.vultr.com/). - Code: `vultr` - Since: v0.3.1 Here is an example bash command using the Vultr provider: ```bash VULTR_API_KEY=xxxxx \ lego --dns vultr -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `VULTR_API_KEY` | API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `VULTR_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `VULTR_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `VULTR_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `VULTR_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.vultr.com/api/#dns) - [Go client](https://github.com/vultr/govultr) ================================================ FILE: docs/content/dns/zz_gen_webnames.md ================================================ --- title: "webnames.ru" date: 2019-03-03T16:39:46+01:00 draft: false slug: webnames dnsprovider: since: "v4.15.0" code: "webnames" url: "https://www.webnames.ru/" --- Configuration for [webnames.ru](https://www.webnames.ru/). - Code: `webnames` - Since: v4.15.0 Here is an example bash command using the webnames.ru provider: ```bash WEBNAMESRU_API_KEY=xxxxxx \ lego --dns webnamesru -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `WEBNAMESRU_API_KEY` | Domain API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `WEBNAMESRU_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `WEBNAMESRU_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `WEBNAMESRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## API Key To obtain the key, you need to change the DNS server to `*.nameself.com`: Personal account / My domains and services / Select the required domain / DNS servers The API key can be found: Personal account / My domains and services / Select the required domain / Zone management / acme.sh or certbot settings ## More information - [API documentation](https://github.com/regtime-ltd/certbot-dns-webnames) ================================================ FILE: docs/content/dns/zz_gen_webnamesca.md ================================================ --- title: "webnames.ca" date: 2019-03-03T16:39:46+01:00 draft: false slug: webnamesca dnsprovider: since: "v4.28.0" code: "webnamesca" url: "https://www.webnames.ca/" --- Configuration for [webnames.ca](https://www.webnames.ca/). - Code: `webnamesca` - Since: v4.28.0 Here is an example bash command using the webnames.ca provider: ```bash WEBNAMESCA_API_USER="xxx" \ WEBNAMESCA_API_KEY="yyy" \ lego --dns webnamesca -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `WEBNAMESCA_API_KEY` | API key | | `WEBNAMESCA_API_USER` | API username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `WEBNAMESCA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `WEBNAMESCA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `WEBNAMESCA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `WEBNAMESCA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.webnames.ca/_/swagger/index.html) ================================================ FILE: docs/content/dns/zz_gen_websupport.md ================================================ --- title: "Websupport" date: 2019-03-03T16:39:46+01:00 draft: false slug: websupport dnsprovider: since: "v4.10.0" code: "websupport" url: "https://websupport.sk" --- Configuration for [Websupport](https://websupport.sk). - Code: `websupport` - Since: v4.10.0 Here is an example bash command using the Websupport provider: ```bash WEBSUPPORT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ WEBSUPPORT_SECRET="yyyyyyyyyyyyyyyyyyyyy" \ lego --dns websupport -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `WEBSUPPORT_API_KEY` | API key | | `WEBSUPPORT_SECRET` | API secret | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `WEBSUPPORT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `WEBSUPPORT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `WEBSUPPORT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `WEBSUPPORT_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60) | | `WEBSUPPORT_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://rest.websupport.sk/v2/docs) ================================================ FILE: docs/content/dns/zz_gen_wedos.md ================================================ --- title: "WEDOS" date: 2019-03-03T16:39:46+01:00 draft: false slug: wedos dnsprovider: since: "v4.4.0" code: "wedos" url: "https://www.wedos.com" --- Configuration for [WEDOS](https://www.wedos.com). - Code: `wedos` - Since: v4.4.0 Here is an example bash command using the WEDOS provider: ```bash WEDOS_USERNAME=xxxxxxxx \ WEDOS_WAPI_PASSWORD=xxxxxxxx \ lego --dns wedos -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `WEDOS_USERNAME` | Username is the same as for the admin account | | `WEDOS_WAPI_PASSWORD` | Password needs to be generated and IP allowed in the admin interface | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `WEDOS_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `WEDOS_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `WEDOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 600) | | `WEDOS_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://kb.wedos.com/en/kategorie/wapi-api-interface/wdns-en/) ================================================ FILE: docs/content/dns/zz_gen_westcn.md ================================================ --- title: "West.cn/西部数码" date: 2019-03-03T16:39:46+01:00 draft: false slug: westcn dnsprovider: since: "v4.21.0" code: "westcn" url: "https://www.west.cn" --- Configuration for [West.cn/西部数码](https://www.west.cn). - Code: `westcn` - Since: v4.21.0 Here is an example bash command using the West.cn/西部数码 provider: ```bash WESTCN_USERNAME="xxx" \ WESTCN_PASSWORD="yyy" \ lego --dns westcn -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `WESTCN_PASSWORD` | API password | | `WESTCN_USERNAME` | Username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `WESTCN_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `WESTCN_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 10) | | `WESTCN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | | `WESTCN_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://www.west.cn/CustomerCenter/doc/domain_v2.html) ================================================ FILE: docs/content/dns/zz_gen_yandex.md ================================================ --- title: "Yandex PDD" date: 2019-03-03T16:39:46+01:00 draft: false slug: yandex dnsprovider: since: "v3.7.0" code: "yandex" url: "https://pdd.yandex.com" --- Configuration for [Yandex PDD](https://pdd.yandex.com). - Code: `yandex` - Since: v3.7.0 Here is an example bash command using the Yandex PDD provider: ```bash YANDEX_PDD_TOKEN= \ lego --dns yandex -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `YANDEX_PDD_TOKEN` | Basic authentication username | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `YANDEX_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `YANDEX_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `YANDEX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `YANDEX_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://yandex.com/dev/domain/doc/concepts/api-dns.html) ================================================ FILE: docs/content/dns/zz_gen_yandex360.md ================================================ --- title: "Yandex 360" date: 2019-03-03T16:39:46+01:00 draft: false slug: yandex360 dnsprovider: since: "v4.14.0" code: "yandex360" url: "https://360.yandex.ru" --- Configuration for [Yandex 360](https://360.yandex.ru). - Code: `yandex360` - Since: v4.14.0 Here is an example bash command using the Yandex 360 provider: ```bash YANDEX360_OAUTH_TOKEN= \ YANDEX360_ORG_ID= \ lego --dns yandex360 -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `YANDEX360_OAUTH_TOKEN` | The OAuth Token | | `YANDEX360_ORG_ID` | The organization ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `YANDEX360_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `YANDEX360_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `YANDEX360_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `YANDEX360_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://yandex.ru/dev/api360/doc/ref/DomainDNSService.html) ================================================ FILE: docs/content/dns/zz_gen_yandexcloud.md ================================================ --- title: "Yandex Cloud" date: 2019-03-03T16:39:46+01:00 draft: false slug: yandexcloud dnsprovider: since: "v4.9.0" code: "yandexcloud" url: "https://cloud.yandex.com" --- Configuration for [Yandex Cloud](https://cloud.yandex.com). - Code: `yandexcloud` - Since: v4.9.0 Here is an example bash command using the Yandex Cloud provider: ```bash YANDEX_CLOUD_IAM_TOKEN= \ YANDEX_CLOUD_FOLDER_ID= \ lego --dns yandexcloud -d '*.example.com' -d example.com run # --- YANDEX_CLOUD_IAM_TOKEN=$(echo '{ \ "id": "", \ "service_account_id": "", \ "created_at": "", \ "key_algorithm": "RSA_2048", \ "public_key": "-----BEGIN PUBLIC KEY----------END PUBLIC KEY-----", \ "private_key": "-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----" \ }' | base64) \ YANDEX_CLOUD_FOLDER_ID= \ lego --dns yandexcloud -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `YANDEX_CLOUD_FOLDER_ID` | The string id of folder (aka project) in Yandex Cloud | | `YANDEX_CLOUD_IAM_TOKEN` | The base64 encoded json which contains information about iam token of service account with `dns.admin` permissions | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `YANDEX_CLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `YANDEX_CLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `YANDEX_CLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## IAM Token The simplest way to retrieve IAM access token is usage of yc-cli, follow [docs](https://cloud.yandex.ru/docs/iam/operations/iam-token/create-for-sa) to get it ```bash yc iam key create --service-account-name my-robot --output key.json cat key.json | base64 ``` ## More information - [API documentation](https://cloud.yandex.com/en/docs/dns/quickstart) ================================================ FILE: docs/content/dns/zz_gen_zoneedit.md ================================================ --- title: "ZoneEdit" date: 2019-03-03T16:39:46+01:00 draft: false slug: zoneedit dnsprovider: since: "v4.25.0" code: "zoneedit" url: "https://www.zoneedit.com" --- Configuration for [ZoneEdit](https://www.zoneedit.com). - Code: `zoneedit` - Since: v4.25.0 Here is an example bash command using the ZoneEdit provider: ```bash ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \ ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns zoneedit -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ZONEEDIT_AUTH_TOKEN` | Authentication token | | `ZONEEDIT_USER` | User ID | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ZONEEDIT_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ZONEEDIT_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ZONEEDIT_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns) ================================================ FILE: docs/content/dns/zz_gen_zoneee.md ================================================ --- title: "Zone.ee" date: 2019-03-03T16:39:46+01:00 draft: false slug: zoneee dnsprovider: since: "v2.1.0" code: "zoneee" url: "https://www.zone.ee/" --- Configuration for [Zone.ee](https://www.zone.ee/). - Code: `zoneee` - Since: v2.1.0 Here is an example bash command using the Zone.ee provider: ```bash ZONEEE_API_USER=xxxxx \ ZONEEE_API_KEY=yyyyy \ lego --dns zoneee -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ZONEEE_API_KEY` | API key | | `ZONEEE_API_USER` | API user | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ZONEEE_ENDPOINT` | API endpoint URL | | `ZONEEE_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ZONEEE_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 5) | | `ZONEEE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://api.zone.eu/v2) ================================================ FILE: docs/content/dns/zz_gen_zonomi.md ================================================ --- title: "Zonomi" date: 2019-03-03T16:39:46+01:00 draft: false slug: zonomi dnsprovider: since: "v3.5.0" code: "zonomi" url: "https://zonomi.com" --- Configuration for [Zonomi](https://zonomi.com). - Code: `zonomi` - Since: v3.5.0 Here is an example bash command using the Zonomi provider: ```bash ZONOMI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns zonomi -d '*.example.com' -d example.com run ``` ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| | `ZONOMI_API_KEY` | User API key | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| | `ZONOMI_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `ZONOMI_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ZONOMI_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | | `ZONOMI_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://zonomi.com/app/dns/dyndns.jsp) ================================================ FILE: docs/content/installation/_index.md ================================================ --- title: "Installation" date: 2019-03-03T16:39:46+01:00 weight: 1 draft: false --- ## Binaries To get the binary just download the latest release for your OS/Arch from [the release page](https://github.com/go-acme/lego/releases) and put the binary somewhere convenient. lego does not assume anything about the location you run it from. ## From Docker ```bash docker run goacme/lego -h ``` ## From package managers - [ArchLinux](https://archlinux.org/packages/extra/x86_64/lego/) (official): ```bash pacman -S lego ``` - [ArchLinux (AUR)](https://aur.archlinux.org/packages/lego-bin) (official): ```bash yay -S lego-bin ``` - [Snap](https://snapcraft.io/lego) (official): ```bash sudo snap install lego ``` Note: The snap can only write to the `/var/snap/lego/common/.lego` directory. - [FreeBSD (Ports)](https://www.freshports.org/security/lego) (unofficial): ```bash pkg install lego ``` - [Gentoo](https://gitweb.gentoo.org/repo/proj/guru.git/tree/app-crypt/lego) (unofficial): You can [enable GURU](https://wiki.gentoo.org/wiki/Project:GURU/Information_for_End_Users) repository and then: ```bash emerge app-crypt/lego ``` - [Homebrew](https://formulae.brew.sh/formula/lego) (unofficial): ```bash brew install lego ``` or ```bash pkg install lego ``` - [OpenBSD (Ports)](https://openports.pl/path/security/lego) (unofficial): ```bash pkg_add lego ``` ## From sources Requirements: - go1.22+. - environment variable: `GO111MODULE=on` To install the latest version from sources, just run: ```bash go install github.com/go-acme/lego/v4/cmd/lego@latest ``` or ```bash git clone git@github.com:go-acme/lego.git cd lego make # tests + doc + build make build # only build ``` ================================================ FILE: docs/content/usage/_index.md ================================================ --- title: "Usage" date: 2019-03-03T16:39:46+01:00 draft: false weight: 2 --- {{% children style="h2" description="true" %}} ================================================ FILE: docs/content/usage/cli/General-Instructions.md ================================================ --- title: General Instructions date: 2019-03-03T16:39:46+01:00 draft: false summary: Read this first to clarify some assumptions made by the following guides. weight: 1 --- These examples assume you have [lego installed]({{% ref "installation" %}}). You can get a pre-built binary from the [releases](https://github.com/go-acme/lego/releases) page. The web server examples require that the `lego` binary has permission to bind to ports 80 and 443. If your environment does not allow you to bind to these ports, please read [Running without root privileges]({{% ref "usage/cli/Options#running-without-root-privileges" %}}) and [Port Usage]({{% ref "usage/cli/Options#port-usage" %}}). Unless otherwise instructed with the `--path` command line flag, lego will look for a directory named `.lego` in the *current working directory*. If you run `cd /dir/a && lego ... run`, lego will create a directory `/dir/a/.lego` where it will save account registration and certificate files into. If you later try to renew a certificate with `cd /dir/b && lego ... renew`, lego will likely produce an error. ================================================ FILE: docs/content/usage/cli/Obtain-a-Certificate.md ================================================ --- title: Obtain a Certificate date: 2019-03-03T16:39:46+01:00 draft: false weight: 2 --- This guide explains various ways to obtain a new certificate. ## Using the built-in web server Open a terminal, and execute the following command (insert your own email address and domain): ```bash lego --email="you@example.com" --domains="example.com" --http run ``` You will find your certificate in the `.lego` folder of the current working directory: ```console $ ls -1 ./.lego/certificates example.com.crt example.com.issuer.crt example.com.json example.com.key [maybe more files for different domains...] ``` where - `example.com.crt` is the server certificate (including the CA certificate), - `example.com.key` is the private key needed for the server certificate, - `example.com.issuer.crt` is the CA certificate, and - `example.com.json` contains some JSON encoded meta information. For each domain, you will have a set of these four files. For wildcard certificates (`*.example.com`), the filenames will look like `_.example.com.crt`. The `.crt` and `.key` files are PEM-encoded x509 certificates and private keys. If you're looking for a `cert.pem` and `privkey.pem`, you can just use `example.com.crt` and `example.com.key`. ## Using a DNS provider If you can't or don't want to start a web server, you need to use a DNS provider. lego comes with [support for many]({{% ref "dns#dns-providers" %}}) providers, and you need to pick the one where your domain's DNS settings are set up. Typically, this is the registrar where you bought the domain, but in some cases this can be another third-party provider. For this example, let's assume you have set up Gandi for your domain. Execute this command: ```bash GANDI_API_KEY=xxx \ lego --email "you@example.com" --dns gandi --domains "example.org" --domains "*.example.org" run ``` {{% notice title="For a zone that has multiple SOAs" icon="info-circle" %}} This can often be found where your DNS provider has a zone entry for an internal network (i.e. a corporate network, or home LAN) as well as the public internet. In this case, point lego at an external authoritative server for the zone using the additional parameter `--dns.resolvers`. ```bash GANDI_API_KEY=xxx \ lego --email "you@example.com" --dns gandi --dns.resolvers 9.9.9.9:53 --domains "example.org" --domains "*.example.org" run ``` [More information about resolvers.]({{% ref "options#dns-resolvers-and-challenge-verification" %}}) {{% /notice %}} ## Using a custom certificate signing request (CSR) The first step in the process of obtaining certificates involves creating a signing request. This CSR bundles various information, including the domain name(s) and a public key. By default, lego will hide this step from you, but if you already have a CSR, you can easily reuse it: ```bash lego --email="you@example.com" --http --csr="/path/to/csr.pem" run ``` lego will infer the domains to be validated based on the contents of the CSR, so make sure the CSR's Common Name and optional SubjectAltNames are set correctly. ## Using an existing, running web server If you have an existing server running on port 80, the `--http` option also requires the `--http.webroot` option. This just writes the http-01 challenge token to the given directory in the folder `.well-known/acme-challenge` and does not start a server. The given directory **should** be publicly served as `/` on the domain(s) for the validation to complete. If the given directory is not publicly served you will have to support rewriting the request to the directory; You could also implement a rewrite to rewrite `.well-known/acme-challenge` to the given directory `.well-known/acme-challenge`. You should be able to run an existing webserver on port 80 and have lego write the token file with the HTTP-01 challenge key authorization to `/.well-known/acme-challenge/` by running something like: ```bash lego --accept-tos --email you@example.com --http --http.webroot /path/to/webroot --domains example.com run ``` ## Running a script afterward You can easily hook into the certificate-obtaining process by providing the path to a script: ```bash lego --email="you@example.com" --domains="example.com" --http run --run-hook="./myscript.sh" ``` Some information is provided through environment variables: - `LEGO_ACCOUNT_EMAIL`: the email of the account. - `LEGO_CERT_DOMAIN`: the main domain of the certificate. - `LEGO_CERT_PATH`: the path of the certificate. - `LEGO_CERT_KEY_PATH`: the path of the certificate key. - `LEGO_CERT_PEM_PATH`: (only with `--pem`) the path to the PEM certificate. - `LEGO_CERT_PFX_PATH`: (only with `--pfx`) the path to the PFX certificate. ### Use case A typical use case is distribute the certificate for other services and reload them if necessary. Since PEM-formatted TLS certificates are understood by many programs, it is relatively simple to use certificates for more than a web server. This example script installs the new certificate for a mail server, and reloads it. Beware: this is just a starting point, error checking is omitted for brevity. ```bash #!/bin/bash # copy certificates to a directory controlled by Postfix postfix_cert_dir="/etc/postfix/certificates" # our Postfix server only handles mail for @example.com domain if [ "$LEGO_CERT_DOMAIN" = "example.com" ]; then install -u postfix -g postfix -m 0644 "$LEGO_CERT_PATH" "$postfix_cert_dir" install -u postfix -g postfix -m 0640 "$LEGO_CERT_KEY_PATH" "$postfix_cert_dir" systemctl reload postfix@-service fi ``` ================================================ FILE: docs/content/usage/cli/Options.md ================================================ --- title: "Options" date: 2019-03-03T16:39:46+01:00 draft: false summary: This page describes various command line options. weight: 4 --- ## Usage {{< clihelp >}} When using the standard `--path` option, all certificates and account configurations are saved to a folder `.lego` in the current working directory. ## Let's Encrypt ACME server lego defaults to communicating with the production Let's Encrypt ACME server. If you'd like to test something without issuing real certificates, consider using the staging endpoint instead: ```bash lego --server=https://acme-staging-v02.api.letsencrypt.org/directory … ``` ## Running without root privileges The CLI does not require root permissions but needs to bind to port 80 and 443 for certain challenges. To run the CLI without `sudo`, you have four options: - Use `setcap 'cap_net_bind_service=+ep' /path/to/lego` (Linux only) - Pass the `--http.port` or/and the `--tls.port` option and specify a custom port to bind to. In this case you have to forward port 80/443 to these custom ports (see [Port Usage](#port-usage)). - Pass the `--http.webroot` option and specify the path to your webroot folder. In this case the challenge will be written in a file in `.well-known/acme-challenge/` inside your webroot. - Pass the `--dns` option and specify a DNS provider. ## Port Usage By default, lego assumes it is able to bind to ports 80 and 443 to solve challenges. If this is not possible in your environment, you can use the `--http.port` and `--tls.port` options to instruct lego to listen on that interface:port for any incoming challenges. If you are using either of these options, make sure you setup a proxy to redirect traffic to the chosen ports. **HTTP Port:** All plaintext HTTP requests to port **80** which begin with a request path of `/.well-known/acme-challenge/` for the HTTP challenge[^header]. **TLS Port:** All TLS handshakes on port **443** for the TLS-ALPN challenge. This traffic redirection is only needed as long as lego solves challenges. As soon as you have received your certificates you can deactivate the forwarding. [^header]: You must ensure that incoming validation requests contains the correct value for the HTTP `Host` header. If you operate lego behind a non-transparent reverse proxy (such as Apache or NGINX), you might need to alter the header field using `--http.proxy-header X-Forwarded-Host`. ## DNS Resolvers and Challenge Verification When using a DNS challenge provider (via `--dns `), Lego tries to ensure the ACME challenge token is properly setup before instructing the ACME provider to perform the validation. This involves a few DNS queries to different servers: 1. Determining the DNS zone and resolving CNAMEs. The DNS zone for a given domain is determined by the SOA record, which contains the authoritative name server for the domain and all its subdomains. For simple domains like `example.com`, this is usually `example.com` itself. For other domains (like `fra.eu.cdn.example.com`), this can get complicated, as `cdn.example.com` may be delegated to the CDN provider, which means for `cdn.example.com` must exist a different SOA record. To find the correct zone, Lego requests the SOA record for each DNS label (starting on the leaf domain, i.e. the left-most DNS label). If there is no SOA record, Lego requests the SOA record of the parent label, then for its parent, etc., until it reaches the apex domain[^apex]. Should any DNS label on the way be a CNAME, it is resolved as per usual. In the default configuration, Lego uses the system name servers for this, and falls back to Google's DNS servers, should they be absent. 2. Verifying the challenge token. The `_acme-challenge.` TXT record must be correctly installed. Lego verifies this by directly querying the authoritative name server for this record (as detected in the previous step). Strictly speaking, this verification step is not necessary, but helps to protect your ACME account. Remember that some ACME providers impose a rate limit on certain actions (at the time of writing, Let's Encrypt allows 300 new certificate orders per account per 3 hours). There are also situations, where this verification step doesn't work as expected: - A "split DNS" setup gives different answers to clients on the internal network (Lego) vs. on the public internet (Let's Encrypt). - With "hidden master" setups, Lego may be able to directly talk to the primary DNS server, while the `_acme-challenge` record might not have fully propagated to the (public) secondary servers, yet. The effect is the same: Lego determined the challenge token to be installed correctly, while Let's Encrypt has a different view, and rejects the certificate order. In these cases, you can instruct Lego to use a different DNS resolver, using the `--dns.resolvers` flag. You should prefer one on the public internet, otherwise you might be susceptible to the same problem. [^apex]: The apex domain is the domain you have registered with your domain registrar. For gTLDs (`.com`, `.fyi`) this is the 2nd level domain, but for ccTLDs, this can either be the 2nd level (`.de`) or 3rd level domain (`.co.uk`). ## Other options ### LEGO_CA_CERTIFICATES The environment variable `LEGO_CA_CERTIFICATES` allows to specify the path to PEM-encoded CA certificates that can be used to authenticate an ACME server with an HTTPS certificate not issued by a CA in the system-wide trusted root list. Multiple file paths can be added by using `:` (unix) or `;` (Windows) as a separator. Example: ```bash # On Unix system LEGO_CA_CERTIFICATES=/foo/cert1.pem:/foo/cert2.pem ``` ### LEGO_CA_SYSTEM_CERT_POOL The environment variable `LEGO_CA_SYSTEM_CERT_POOL` can be used to define if the certificates pool must use a copy of the system cert pool. Example: ```bash LEGO_CA_SYSTEM_CERT_POOL=true ``` ### LEGO_CA_SERVER_NAME The environment variable `LEGO_CA_SERVER_NAME` allows to specify the CA server name used to authenticate an ACME server with an HTTPS certificate not issued by a CA in the system-wide trusted root list. Example: ```bash LEGO_CA_SERVER_NAME=foo ``` ### LEGO_DISABLE_CNAME_SUPPORT By default, lego follows CNAME, the environment variable `LEGO_DISABLE_CNAME_SUPPORT` allows to disable this support. Example: ```bash LEGO_DISABLE_CNAME_SUPPORT=false ``` ### LEGO_DEBUG_CLIENT_VERBOSE_ERROR The environment variable `LEGO_DEBUG_CLIENT_VERBOSE_ERROR` allows to enrich error messages from some of the DNS clients. Example: ```bash LEGO_DEBUG_CLIENT_VERBOSE_ERROR=true ``` ### LEGO_DEBUG_DNS_API_HTTP_CLIENT > **⚠️ WARNING: This will expose credentials in the log output! ⚠️** > > Do not run this in production environments, or if you can't be sure that logs aren't accessed by third parties or tools (like log collectors). > > You have been warned. Here be dragons. The environment variable `LEGO_DEBUG_DNS_API_HTTP_CLIENT` allows debugging the DNS API interaction. It will dump the full request and response to the log output. Some DNS providers don't support this option. Example: ```bash LEGO_DEBUG_DNS_API_HTTP_CLIENT=true ``` ### LEGO_DEBUG_ACME_HTTP_CLIENT The environment variable `LEGO_DEBUG_ACME_HTTP_CLIENT` allows debug the calls to the ACME server. Example: ```bash LEGO_DEBUG_ACME_HTTP_CLIENT=true ``` ================================================ FILE: docs/content/usage/cli/Renew-a-Certificate.md ================================================ --- title: Renew a Certificate date: 2019-03-03T16:39:46+01:00 draft: false weight: 3 --- This guide describes how to renew existing certificates. Certificates issues by Let's Encrypt are valid for a period of 90 days. To avoid certificate errors, you need to ensure that you renew your certificate *before* it expires. In order to renew a certificate, follow the general instructions laid out under [Obtain a Certificate]({{% ref "usage/cli/Obtain-a-Certificate" %}}), and replace `lego ... run` with `lego ... renew`. Note that the `renew` sub-command supports a slightly different set of some command line flags. ## Using the built-in web server By default, and following best practices, a certificate is only renewed if its expiry date is less than 30 days in the future. ```bash lego --email="you@example.com" --domains="example.com" --http renew ``` If the certificate needs to renewed earlier, you can specify the number of remaining days: ```bash lego --email="you@example.com" --domains="example.com" --http renew --days 45 ``` ## Using a DNS provider If you can't or don't want to start a web server, you need to use a DNS provider. lego comes with [support for many]({{% ref "dns#dns-providers" %}}) providers, and you need to pick the one where your domain's DNS settings are set up. Typically, this is the registrar where you bought the domain, but in some cases this can be another third-party provider. For this example, let's assume you have set up CloudFlare for your domain. Execute this command: ```bash CLOUDFLARE_EMAIL="you@example.com" \ CLOUDFLARE_API_KEY="yourprivatecloudflareapikey" \ lego --email "you@example.com" --dns cloudflare --domains "example.org" renew ``` ## Running a script afterward You can easily hook into the certificate-obtaining process by providing the path to a script. The hook is executed only when the certificates are effectively renewed. ```bash lego --email="you@example.com" --domains="example.com" --http renew --renew-hook="./myscript.sh" ``` Some information is provided through environment variables: - `LEGO_ACCOUNT_EMAIL`: the email of the account. - `LEGO_CERT_DOMAIN`: the main domain of the certificate. - `LEGO_CERT_PATH`: the path of the certificate. - `LEGO_CERT_KEY_PATH`: the path of the certificate key. - `LEGO_CERT_PEM_PATH`: (only with `--pem`) the path to the PEM certificate. - `LEGO_CERT_PFX_PATH`: (only with `--pfx`) the path to the PFX certificate. See [Obtain a Certificate → Use case]({{% ref "usage/cli/Obtain-a-Certificate#use-case" %}}) for an example script. ## Automatic renewal It is tempting to create a cron job (or systemd timer) to automatically renew all you certificates. When doing so, please note that some cron defaults will cause measurable load on the ACME provider's infrastructure. Notably `@daily` jobs run at midnight. To both counteract load spikes (caused by all lego users) and reduce subsequent renewal failures, we were asked to implement a small random delay for non-interactive renewals.[^loadspikes] Since v4.8.0, lego will pause for up to 8 minutes to help spread the load. You can help further, by adjusting your crontab entry, like so: ```ruby # avoid: #@daily /usr/bin/lego ... renew #@midnight /usr/bin/lego ... renew #0 0 * * * /usr/bin/lego ... renew # instead, use a randomly chosen time: 35 3 * * * /usr/bin/lego ... renew ``` If you use systemd timers, consider doing something similar, and/or introduce a `RandomizedDelaySec`: ```ini [Unit] Description=Renew certificates [Timer] Persistent=true # avoid: #OnCalendar=*-*-* 00:00:00 #OnCalendar=daily # instead, use a randomly chosen time: OnCalendar=*-*-* 3:35 # add extra delay, here up to 1 hour: RandomizedDelaySec=1h [Install] WantedBy=timers.target ``` [^loadspikes]: See [GitHub issue #1656](https://github.com/go-acme/lego/issues/1656) for an excellent problem description. ================================================ FILE: docs/content/usage/cli/_index.md ================================================ --- title: "CLI" date: 2019-03-03T16:39:46+01:00 draft: false --- Lego can be use as a CLI. {{% children style="h2" description="true" %}} ================================================ FILE: docs/content/usage/cli/examples.md ================================================ --- title: Examples date: 2019-03-03T16:39:46+01:00 draft: false hidden: true --- {{% notice note %}} **Heads up!** We've restructured the content a bit. {{% /notice %}} You'll find the content now at one of these pages: - Guide: [**How to obtain a certificate**]({{% ref "usage/cli/Obtain-a-Certificate" %}}) - Using the built-in web server - Using a DNS provider - Using a custom certificate signing request (CSR) - Using an existing, running web server - Running a script afterward - Use case - Guide: [**How to renew a certificate**]({{% ref "usage/cli/Renew-a-Certificate" %}}) - Using the built-in web server - Using a DNS provider - Running a script afterward - Automatic renewal - Reference: [**Command line options**]({{% ref "usage/cli/Options" %}}) - Usage - Let's Encrypt ACME server - Running without root privileges - Port Usage ================================================ FILE: docs/content/usage/library/Writing-a-Challenge-Solver.md ================================================ --- title: "Writing a Challenge Solver" date: 2019-03-03T16:39:46+01:00 draft: false --- Lego can solve multiple ACME challenge types out of the box, but sometimes you have custom requirements. For example, you may want to write a solver for the DNS-01 challenge that works with a different DNS provider (lego already supports CloudFlare, AWS, DigitalOcean, and others). The DNS-01 challenge is advantageous when other challenge types are impossible. For example, the HTTP-01 challenge doesn't work well behind a load balancer or CDN and the TLS-ALPN-01 challenge breaks behind TLS termination. But even if using HTTP-01 or TLS-ALPN-01 challenges, you may have specific needs that lego does not consider by default. You can write something called a `challenge.Provider` that implements [this interface](https://pkg.go.dev/github.com/go-acme/lego/v4/challenge#Provider): ```go type Provider interface { Present(domain, token, keyAuth string) error CleanUp(domain, token, keyAuth string) error } ``` This provides the means to solve a challenge. First you present a token to the ACME server in a way defined by the challenge type you're solving for, then you "clean up" after the challenge finishes. ## Writing a challenge.Provider Pretend we want to write our own DNS-01 challenge provider (other challenge types have different requirements but the same principles apply). This will let us prove ownership of domain names parked at a new, imaginary DNS service called BestDNS without having to start our own HTTP server. BestDNS has an API that, given an authentication token, allows us to manipulate DNS records. This simplistic example has only one field to store the auth token, but in reality you may need to keep more state. ```go type DNSProviderBestDNS struct { apiAuthToken string } ``` We should provide a constructor that returns a *pointer* to the `struct`. This is important in case we need to maintain state in the `struct`. ```go func NewDNSProviderBestDNS(apiAuthToken string) (*DNSProviderBestDNS, error) { return &DNSProviderBestDNS{apiAuthToken: apiAuthToken}, nil } ``` Now we need to implement the interface. We'll start with the `Present` method. You'll be passed the `domain` name for which you're proving ownership, a `token`, and a `keyAuth` string. How your provider uses `token` and `keyAuth`, or if you even use them at all, depends on the challenge type. For DNS-01, we'll just use `domain` and `keyAuth`. ```go func (d *DNSProviderBestDNS) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // make API request to set a TXT record on fqdn with value and TTL return nil } ``` After calling `dns01.GetChallengeInfo(domain, keyAuth)`, we now have the information we need to make our API request and set the TXT record: - `FQDN` is the fully qualified domain name on which to set the TXT record. - `EffectiveFQDN` is the fully qualified domain name after the CNAMEs resolutions on which to set the TXT record. - `Value` is the record's value to set on the record. So then you make an API request to the DNS service according to their docs. Once the TXT record is set on the domain, you may return and the challenge will proceed. The ACME server will then verify that you did what it required you to do, and once it is finished, lego will call your `CleanUp` method. In our case, we want to remove the TXT record we just created. ```go func (d *DNSProviderBestDNS) CleanUp(domain, token, keyAuth string) error { // clean up any state you created in Present, like removing the TXT record } ``` In our case, we'd just make another API request to have the DNS record deleted; no need to keep it and clutter the zone file. ## Using your new challenge.Provider To use your new challenge provider, call [`client.Challenge.SetDNS01Provider`](https://pkg.go.dev/github.com/go-acme/lego/v4/challenge/resolver#SolverManager.SetDNS01Provider) to tell lego, "For this challenge, use this provider". In our case: ```go bestDNS, err := NewDNSProviderBestDNS("my-auth-token") if err != nil { return err } client.Challenge.SetDNS01Provider(bestDNS) ``` Then, when this client tries to solve the DNS-01 challenge, it will use our new provider, which sets TXT records on a domain name hosted by BestDNS. That's really all there is to it. Go make awesome things! ================================================ FILE: docs/content/usage/library/_index.md ================================================ --- title: "Library" date: 2019-03-03T16:39:46+01:00 draft: false --- Lego can be used as a Go Library. ## GoDoc The GoDoc can be found here: [Go Reference](https://pkg.go.dev/github.com/go-acme/lego/v4). ## Usage A valid, but bare-bones example use of the acme package: ```go package main import ( "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "fmt" "log" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/challenge/http01" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/registration" ) // You'll need a user or account type that implements acme.User type MyUser struct { Email string Registration *registration.Resource key crypto.PrivateKey } func (u *MyUser) GetEmail() string { return u.Email } func (u MyUser) GetRegistration() *registration.Resource { return u.Registration } func (u *MyUser) GetPrivateKey() crypto.PrivateKey { return u.key } func main() { // Create a user. New accounts need an email and private key to start. privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { log.Fatal(err) } myUser := MyUser{ Email: "you@yours.com", key: privateKey, } config := lego.NewConfig(&myUser) // This CA URL is configured for a local dev instance of Boulder running in Docker in a VM. config.CADirURL = "http://192.168.99.100:4000/directory" config.Certificate.KeyType = certcrypto.RSA2048 // A client facilitates communication with the CA server. client, err := lego.NewClient(config) if err != nil { log.Fatal(err) } // We specify an HTTP port of 5002 and an TLS port of 5001 on all interfaces // because we aren't running as root and can't bind a listener to port 80 and 443 // (used later when we attempt to pass challenges). Keep in mind that you still // need to proxy challenge traffic to port 5002 and 5001. err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002")) if err != nil { log.Fatal(err) } err = client.Challenge.SetTLSALPN01Provider(tlsalpn01.NewProviderServer("", "5001")) if err != nil { log.Fatal(err) } // New users will need to register reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) if err != nil { log.Fatal(err) } myUser.Registration = reg request := certificate.ObtainRequest{ Domains: []string{"mydomain.com"}, Bundle: true, } certificates, err := client.Certificate.Obtain(request) if err != nil { log.Fatal(err) } // Each certificate comes back with the cert bytes, the bytes of the client's // private key, and a certificate URL. SAVE THESE TO DISK. fmt.Printf("%#v\n", certificates) // ... all done. } ``` ================================================ FILE: docs/data/zz_cli_help.toml ================================================ # THIS FILE IS AUTO-GENERATED. PLEASE DO NOT EDIT. [[command]] title = "lego help" content = """ NAME: lego - Let's Encrypt client written in Go USAGE: lego [global options] command [command options] COMMANDS: run Register an account, then create and install a certificate revoke Revoke a certificate renew Renew a certificate dnshelp Shows additional help for the '--dns' global option list Display certificates and accounts information. help, h Shows a list of commands or help for one command GLOBAL OPTIONS: --domains value, -d value [ --domains value, -d value ] Add a domain to the process. Can be specified multiple times. --server value, -s value CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-v02.api.letsencrypt.org/directory") [$LEGO_SERVER] --accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. (default: false) --email value, -m value Email used for registration and recovery contact. [$LEGO_EMAIL] --disable-cn Disable the use of the common name in the CSR. (default: false) --csr value, -c value Certificate signing request filename, if an external CSR is to be used. --eab Use External Account Binding for account registration. Requires --kid and --hmac. (default: false) [$LEGO_EAB] --kid value Key identifier from External CA. Used for External Account Binding. [$LEGO_EAB_KID] --hmac value MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding. [$LEGO_EAB_HMAC] --key-type value, -k value Key type to use for private keys. Supported: rsa2048, rsa3072, rsa4096, rsa8192, ec256, ec384. (default: "ec256") --filename value (deprecated) Filename of the generated certificate. --path value Directory to use for storing the data. (default: "./.lego") [$LEGO_PATH] --http Use the HTTP-01 challenge to solve challenges. Can be mixed with other types of challenges. (default: false) --http.port value Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port. (default: ":80") --http.delay value Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge. (default: 0s) --http.proxy-header value Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy. (default: "Host") --http.webroot value Set the webroot folder to use for HTTP-01 based challenges to write directly to the .well-known/acme-challenge file. This disables the built-in server and expects the given directory to be publicly served with access to .well-known/acme-challenge --http.memcached-host value [ --http.memcached-host value ] Set the memcached host(s) to use for HTTP-01 based challenges. Challenges will be written to all specified hosts. --http.s3-bucket value Set the S3 bucket name to use for HTTP-01 based challenges. Challenges will be written to the S3 bucket. --tls Use the TLS-ALPN-01 challenge to solve challenges. Can be mixed with other types of challenges. (default: false) --tls.port value Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port. (default: ":443") --tls.delay value Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge. (default: 0s) --dns value Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage. --dns.disable-cp (deprecated) use dns.propagation-disable-ans instead. (default: false) --dns.propagation-disable-ans By setting this flag to true, disables the need to await propagation of the TXT record to all authoritative name servers. (default: false) --dns.propagation-rns By setting this flag to true, use all the recursive nameservers to check the propagation of the TXT record. (default: false) --dns.propagation-wait value By setting this flag, disables all the propagation checks of the TXT record and uses a wait duration instead. (default: 0s) --dns.resolvers value [ --dns.resolvers value ] Set the resolvers to use for performing (recursive) CNAME resolving and apex domain determination. For DNS-01 challenge verification, the authoritative DNS server is queried directly. Supported: host:port. The default is to use the system resolvers, or Google's DNS resolvers if the system's cannot be determined. --http-timeout value Set the HTTP timeout value to a specific value in seconds. (default: 0) --tls-skip-verify Skip the TLS verification of the ACME server. (default: false) --dns-timeout value Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name server queries. (default: 10) --pem Generate an additional .pem (base64) file by concatenating the .key and .crt files together. (default: false) --pfx Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together. (default: false) [$LEGO_PFX] --pfx.pass value The password used to encrypt the .pfx (PCKS#12) file. (default: "changeit") [$LEGO_PFX_PASSWORD] --pfx.format value The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256. (default: "RC2") [$LEGO_PFX_FORMAT] --cert.timeout value Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates. (default: 30) --overall-request-limit value ACME overall requests limit. (default: 18) --user-agent value Add to the user-agent sent to the CA to identify an application embedding lego-cli --help, -h show help """ [[command]] title = "lego help run" content = """ NAME: lego run - Register an account, then create and install a certificate USAGE: lego run [command options] OPTIONS: --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. (default: false) --must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. (default: false) --not-before value Set the notBefore field in the certificate (RFC3339 format) --not-after value Set the notAfter field in the certificate (RFC3339 format) --private-key value Path to private key (in PEM encoding) for the certificate. By default, the private key is generated. --preferred-chain value If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used. --profile value If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one. --always-deactivate-authorizations value Force the authorizations to be relinquished even if the certificate request was successful. --run-hook value Define a hook. The hook is executed when the certificates are effectively created. --run-hook-timeout value Define the timeout for the hook execution. (default: 2m0s) --help, -h show help """ [[command]] title = "lego help renew" content = """ NAME: lego renew - Renew a certificate USAGE: lego renew [command options] OPTIONS: --days value The number of days left on a certificate to renew it. (default: 30) --dynamic Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5. (default: false) --ari-disable Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed. (default: false) --ari-wait-to-renew-duration value The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint. (default: 0s) --reuse-key Used to indicate you want to reuse your current private key for the new certificate. (default: false) --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. (default: false) --must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. (default: false) --not-before value Set the notBefore field in the certificate (RFC3339 format) --not-after value Set the notAfter field in the certificate (RFC3339 format) --preferred-chain value If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used. --profile value If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one. --always-deactivate-authorizations value Force the authorizations to be relinquished even if the certificate request was successful. --renew-hook value Define a hook. The hook is executed only when the certificates are effectively renewed. --renew-hook-timeout value Define the timeout for the hook execution. (default: 2m0s) --no-random-sleep Do not add a random sleep before the renewal. We do not recommend using this flag if you are doing your renewals in an automated way. (default: false) --force-cert-domains Check and ensure that the cert's domain list matches those passed in the domains argument. (default: false) --help, -h show help """ [[command]] title = "lego help revoke" content = """ NAME: lego revoke - Revoke a certificate USAGE: lego revoke [command options] OPTIONS: --keep, -k Keep the certificates after the revocation instead of archiving them. (default: false) --reason value Identifies the reason for the certificate revocation. See https://www.rfc-editor.org/rfc/rfc5280.html#section-5.3.1. Valid values are: 0 (unspecified), 1 (keyCompromise), 2 (cACompromise), 3 (affiliationChanged), 4 (superseded), 5 (cessationOfOperation), 6 (certificateHold), 8 (removeFromCRL), 9 (privilegeWithdrawn), or 10 (aACompromise). (default: 0) --help, -h show help """ [[command]] title = "lego help list" content = """ NAME: lego list - Display certificates and accounts information. USAGE: lego list [command options] OPTIONS: --accounts, -a Display accounts. (default: false) --names, -n Display certificate common names only. (default: false) --help, -h show help """ [[command]] title = "lego dnshelp" content = """ Credentials for DNS providers must be passed through environment variables. To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, czechia, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, eurodns, excedo, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, leaseweb, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ ================================================ FILE: docs/go.mod ================================================ module github.com/go-acme/lego/docs go 1.20 require github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb ================================================ FILE: docs/go.sum ================================================ github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb h1:iTGWOs8uKUaYmd7+wHRyPGXxt+SS5Bhvx2RRboYRXlI= github.com/McShelby/hugo-theme-relearn v0.0.0-20250707094454-9803d5122ebb/go.mod h1:mKQQdxZNIlLvAj8X3tMq+RzntIJSr9z7XdzuMomt0IM= ================================================ FILE: docs/hugo.toml ================================================ baseURL = "https://go-acme.github.io/lego/" languageCode = "en-us" title = "Lego" [permalinks] dns = "/dns/:slug/" [params] # Description of the site, will be used in meta information # description = "" # Shows a checkmark for visited pages on the menu showVisitedLinks = true # Change default color scheme with a variant one. Can be "red", "blue", "green". themeVariant = "blue" custom_css = ["css/theme-custom.css"] disableLandingPageButton = true hideAuthorEmail = true hideAuthorName = true # Author of the site, will be used in meta information [params.author] name = "Lego Team" [Languages] [Languages.en] title = "Let’s Encrypt client and ACME library written in Go." weight = 1 languageName = "English" [[Languages.en.menu.shortcuts]] name = " GitHub repo" identifier = "ds" url = "https://github.com/go-acme/lego" weight = 10 [[Languages.en.menu.shortcuts]] name = " Issues" url = "https://github.com/go-acme/lego/issues" weight = 11 [[Languages.en.menu.shortcuts]] name = " Discussions" url = "https://github.com/go-acme/lego/discussions" weight = 12 [outputs] home = ['html', 'rss', 'print'] [module] [[module.imports]] path = "github.com/McShelby/hugo-theme-relearn" ================================================ FILE: docs/layouts/partials/logo.html ================================================ ================================================ FILE: docs/layouts/shortcodes/clihelp.html ================================================ {{ $tabs := slice }} {{ $commands := index $.Site.Data.zz_cli_help "command" }} {{ range $idx, $tab := $commands }} {{ $content := (print "```\n" $tab.content "\n```") }} {{ $tabs = $tabs | append (dict "title" $tab.title "content" ($content | page.RenderString) "icon" "terminal") }} {{ end }} {{ partial "shortcodes/tabs.html" (dict "page" page "content" $tabs ) }} ================================================ FILE: docs/layouts/shortcodes/tableofdnsproviders.html ================================================ {{ $_hugo_config := `{ "version": 1 }` }} {{- range .Site.AllPages.ByWeight -}} {{- if .Params.dnsprovider -}} {{- $params := .Params.dnsprovider -}} {{ end }} {{ end }}
Provider name CLI flag name Required lego version
{{ .Title }} {{- if $params.url -}} Website {{- end -}} {{ $params.code }} {{ $params.since }}
================================================ FILE: docs/static/.nojekyll ================================================ ================================================ FILE: docs/static/css/theme-custom.css ================================================ #top-bar-sticky-wrapper, #top-bar, #body-inner { max-width: 72em; margin: 0 auto; } ================================================ FILE: e2e/challenges_test.go ================================================ package e2e import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "fmt" "os" "path/filepath" "testing" "time" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/challenge/http01" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/go-acme/lego/v4/e2e/loader" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/registration" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testDomain1 = "acme.localhost" testDomain2 = "lego.localhost" testDomain3 = "acme.lego.localhost" testDomain4 = "légô.localhost" ) const ( testEmail1 = "lego@example.com" testEmail2 = "acme@example.com" ) var load = loader.EnvLoader{ PebbleOptions: &loader.CmdOption{ HealthCheckURL: "https://localhost:14000/dir", Args: []string{"-strict", "-config", "fixtures/pebble-config.json"}, Env: []string{"PEBBLE_VA_NOSLEEP=1", "PEBBLE_WFE_NONCEREJECT=20"}, }, LegoOptions: []string{ "LEGO_CA_CERTIFICATES=./fixtures/certs/pebble.minica.pem", "LEGO_DEBUG_ACME_HTTP_CLIENT=1", }, } func TestMain(m *testing.M) { os.Exit(load.MainTest(m)) } func TestHelp(t *testing.T) { output, err := load.RunLegoCombinedOutput("-h") if err != nil { fmt.Fprintf(os.Stderr, "%s\n", output) t.Fatal(err) } fmt.Fprintf(os.Stdout, "%s\n", output) } func TestChallengeHTTP_Run(t *testing.T) { loader.CleanLegoFiles() err := load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain1, "--http", "--http.port", ":5002", "run") if err != nil { t.Fatal(err) } } func TestChallengeTLS_Run_Domains(t *testing.T) { loader.CleanLegoFiles() err := load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain1, "--tls", "--tls.port", ":5001", "run") if err != nil { t.Fatal(err) } } func TestChallengeTLS_Run_IP(t *testing.T) { loader.CleanLegoFiles() err := load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", "127.0.0.1", "--tls", "--tls.port", ":5001", "run") if err != nil { t.Fatal(err) } } func TestChallengeTLS_Run_CSR(t *testing.T) { loader.CleanLegoFiles() csrPath := createTestCSRFile(t, true) err := load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-csr", csrPath, "--tls", "--tls.port", ":5001", "run") if err != nil { t.Fatal(err) } } func TestChallengeTLS_Run_CSR_PEM(t *testing.T) { loader.CleanLegoFiles() csrPath := createTestCSRFile(t, false) err := load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-csr", csrPath, "--tls", "--tls.port", ":5001", "run") if err != nil { t.Fatal(err) } } func TestChallengeTLS_Run_Revoke(t *testing.T) { loader.CleanLegoFiles() err := load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain2, "-d", testDomain3, "--tls", "--tls.port", ":5001", "run") if err != nil { t.Fatal(err) } err = load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain2, "--tls", "--tls.port", ":5001", "revoke") if err != nil { t.Fatal(err) } } func TestChallengeTLS_Run_Revoke_Non_ASCII(t *testing.T) { loader.CleanLegoFiles() err := load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain4, "--tls", "--tls.port", ":5001", "run") if err != nil { t.Fatal(err) } err = load.RunLego( "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain4, "--tls", "--tls.port", ":5001", "revoke") if err != nil { t.Fatal(err) } } func TestChallengeHTTP_Client_Obtain(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002")) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg request := certificate.ObtainRequest{ Domains: []string{testDomain1}, Bundle: true, } resource, err := client.Certificate.Obtain(request) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.Empty(t, resource.CSR) } func TestChallengeHTTP_Client_Obtain_profile(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002")) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg request := certificate.ObtainRequest{ Domains: []string{testDomain1}, Bundle: true, Profile: "shortlived", } resource, err := client.Certificate.Obtain(request) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.Empty(t, resource.CSR) } func TestChallengeHTTP_Client_Obtain_emails_csr(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002")) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg request := certificate.ObtainRequest{ Domains: []string{testDomain1}, Bundle: true, EmailAddresses: []string{testEmail1}, } resource, err := client.Certificate.Obtain(request) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.Empty(t, resource.CSR) } func TestChallengeHTTP_Client_Obtain_notBefore_notAfter(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002")) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg now := time.Now().UTC() request := certificate.ObtainRequest{ Domains: []string{testDomain1}, NotBefore: now.Add(1 * time.Hour), NotAfter: now.Add(2 * time.Hour), Bundle: true, } resource, err := client.Certificate.Obtain(request) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.Empty(t, resource.CSR) cert, err := certcrypto.ParsePEMCertificate(resource.Certificate) require.NoError(t, err) assert.WithinDuration(t, now.Add(1*time.Hour), cert.NotBefore, 1*time.Second) assert.WithinDuration(t, now.Add(2*time.Hour), cert.NotAfter, 1*time.Second) } func TestChallengeHTTP_Client_Registration_QueryRegistration(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002")) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg resource, err := client.Registration.QueryRegistration() require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, "valid", resource.Body.Status) assert.Regexp(t, `https://localhost:14000/list-orderz/[\w\d]+`, resource.Body.Orders) assert.Regexp(t, `https://localhost:14000/my-account/[\w\d]+`, resource.URI) } func TestChallengeTLS_Client_Obtain(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) err = client.Challenge.SetTLSALPN01Provider(tlsalpn01.NewProviderServer("", "5001")) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg // https://github.com/letsencrypt/pebble/issues/285 privateKeyCSR, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") request := certificate.ObtainRequest{ Domains: []string{testDomain1}, Bundle: true, PrivateKey: privateKeyCSR, } resource, err := client.Certificate.Obtain(request) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.Empty(t, resource.CSR) } func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) err = client.Challenge.SetTLSALPN01Provider(tlsalpn01.NewProviderServer("", "5001")) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg csr, err := x509.ParseCertificateRequest(createTestCSR(t)) require.NoError(t, err) resource, err := client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{ CSR: csr, Bundle: true, }) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.NotEmpty(t, resource.CSR) } func TestChallengeTLS_Client_ObtainForCSR_profile(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) err = client.Challenge.SetTLSALPN01Provider(tlsalpn01.NewProviderServer("", "5001")) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg csr, err := x509.ParseCertificateRequest(createTestCSR(t)) require.NoError(t, err) resource, err := client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{ CSR: csr, Bundle: true, Profile: "shortlived", }) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, testDomain1, resource.Domain) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:14000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.NotEmpty(t, resource.CSR) } func TestRegistrar_UpdateAccount(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "./fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{ privateKey: privateKey, email: testEmail1, } config := lego.NewConfig(user) config.CADirURL = load.PebbleOptions.HealthCheckURL client, err := lego.NewClient(config) require.NoError(t, err) regOptions := registration.RegisterOptions{TermsOfServiceAgreed: true} reg, err := client.Registration.Register(regOptions) require.NoError(t, err) require.Equal(t, []string{"mailto:" + testEmail1}, reg.Body.Contact) user.registration = reg user.email = testEmail2 resource, err := client.Registration.UpdateRegistration(regOptions) require.NoError(t, err) require.Equal(t, []string{"mailto:" + testEmail2}, resource.Body.Contact) require.Equal(t, reg.URI, resource.URI) } type fakeUser struct { email string privateKey crypto.PrivateKey registration *registration.Resource } func (f *fakeUser) GetEmail() string { return f.email } func (f *fakeUser) GetRegistration() *registration.Resource { return f.registration } func (f *fakeUser) GetPrivateKey() crypto.PrivateKey { return f.privateKey } func createTestCSRFile(t *testing.T, raw bool) string { t.Helper() csr := createTestCSR(t) if raw { filename := filepath.Join(t.TempDir(), "csr.raw") fileRaw, err := os.Create(filename) require.NoError(t, err) defer fileRaw.Close() _, err = fileRaw.Write(csr) require.NoError(t, err) return filename } filename := filepath.Join(t.TempDir(), "csr.cert") file, err := os.Create(filename) require.NoError(t, err) defer file.Close() _, err = file.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr})) require.NoError(t, err) return filename } func createTestCSR(t *testing.T) []byte { t.Helper() privateKey, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) csr, err := certcrypto.CreateCSR(privateKey, certcrypto.CSROptions{ Domain: testDomain1, SAN: []string{ testDomain1, testDomain2, }, }) require.NoError(t, err) return csr } ================================================ FILE: e2e/dnschallenge/dns_challenges_test.go ================================================ package dnschallenge import ( "crypto" "crypto/rand" "crypto/rsa" "fmt" "os" "testing" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/e2e/loader" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/providers/dns" "github.com/go-acme/lego/v4/registration" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testDomain1 = "légo.localhost" testDomain2 = "*.légo.localhost" ) var load = loader.EnvLoader{ PebbleOptions: &loader.CmdOption{ HealthCheckURL: "https://localhost:15000/dir", Args: []string{"-strict", "-config", "fixtures/pebble-config-dns.json", "-dnsserver", "localhost:8053"}, Env: []string{"PEBBLE_VA_NOSLEEP=1", "PEBBLE_WFE_NONCEREJECT=20"}, Dir: "../", }, LegoOptions: []string{ "LEGO_CA_CERTIFICATES=../fixtures/certs/pebble.minica.pem", "EXEC_PATH=../fixtures/update-dns.sh", "LEGO_DEBUG_ACME_HTTP_CLIENT=1", }, ChallSrv: &loader.CmdOption{ Args: []string{"-http01", ":5012", "-tlsalpn01", ":5011"}, }, } func TestMain(m *testing.M) { os.Exit(load.MainTest(m)) } func TestDNSHelp(t *testing.T) { output, err := load.RunLegoCombinedOutput("dnshelp") if err != nil { fmt.Fprintf(os.Stderr, "%s\n", output) t.Fatal(err) } fmt.Fprintf(os.Stdout, "%s\n", output) } func TestChallengeDNS_Run(t *testing.T) { loader.CleanLegoFiles() err := load.RunLego( "--accept-tos", "--dns", "exec", "--dns.resolvers", ":8053", "--dns.disable-cp", "-s", "https://localhost:15000/dir", "-d", testDomain2, "-d", testDomain1, "run") if err != nil { t.Fatal(err) } } func TestChallengeDNS_Client_Obtain(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() err = os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh") require.NoError(t, err) defer func() { _ = os.Unsetenv("EXEC_PATH") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = "https://localhost:15000/dir" client, err := lego.NewClient(config) require.NoError(t, err) provider, err := dns.NewDNSChallengeProviderByName("exec") require.NoError(t, err) err = client.Challenge.SetDNS01Provider(provider, dns01.AddRecursiveNameservers([]string{":8053"}), dns01.DisableAuthoritativeNssPropagationRequirement()) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg domains := []string{testDomain2, testDomain1} // https://github.com/letsencrypt/pebble/issues/285 privateKeyCSR, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") request := certificate.ObtainRequest{ Domains: domains, Bundle: true, PrivateKey: privateKeyCSR, } resource, err := client.Certificate.Obtain(request) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, "*.xn--lgo-bma.localhost", resource.Domain) assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.Empty(t, resource.CSR) } func TestChallengeDNS_Client_Obtain_profile(t *testing.T) { err := os.Setenv("LEGO_CA_CERTIFICATES", "../fixtures/certs/pebble.minica.pem") require.NoError(t, err) defer func() { _ = os.Unsetenv("LEGO_CA_CERTIFICATES") }() err = os.Setenv("EXEC_PATH", "../fixtures/update-dns.sh") require.NoError(t, err) defer func() { _ = os.Unsetenv("EXEC_PATH") }() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") user := &fakeUser{privateKey: privateKey} config := lego.NewConfig(user) config.CADirURL = "https://localhost:15000/dir" client, err := lego.NewClient(config) require.NoError(t, err) provider, err := dns.NewDNSChallengeProviderByName("exec") require.NoError(t, err) err = client.Challenge.SetDNS01Provider(provider, dns01.AddRecursiveNameservers([]string{":8053"}), dns01.DisableAuthoritativeNssPropagationRequirement()) require.NoError(t, err) reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) require.NoError(t, err) user.registration = reg domains := []string{testDomain2, testDomain1} // https://github.com/letsencrypt/pebble/issues/285 privateKeyCSR, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") request := certificate.ObtainRequest{ Domains: domains, Bundle: true, PrivateKey: privateKeyCSR, Profile: "shortlived", } resource, err := client.Certificate.Obtain(request) require.NoError(t, err) require.NotNil(t, resource) assert.Equal(t, "*.xn--lgo-bma.localhost", resource.Domain) assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertURL) assert.Regexp(t, `https://localhost:15000/certZ/[\w\d]{14,}`, resource.CertStableURL) assert.NotEmpty(t, resource.Certificate) assert.NotEmpty(t, resource.IssuerCertificate) assert.Empty(t, resource.CSR) } type fakeUser struct { email string privateKey crypto.PrivateKey registration *registration.Resource } func (f *fakeUser) GetEmail() string { return f.email } func (f *fakeUser) GetRegistration() *registration.Resource { return f.registration } func (f *fakeUser) GetPrivateKey() crypto.PrivateKey { return f.privateKey } ================================================ FILE: e2e/fixtures/certs/README.md ================================================ # certs/ This directory contains a CA certificate (`pebble.minica.pem`) and a private key (`pebble.minica.key.pem`) that are used to issue an end-entity certificate (See `certs/localhost`) for the Pebble HTTPS server. To get your **testing code** to use Pebble without HTTPS errors you should configure your ACME client to trust the `pebble.minica.pem` CA certificate. Your ACME client should offer a runtime option to specify a list of root CAs that you can configure to include the `pebble.minica.pem` file. **Do not** add this CA certificate to the system trust store or in production code!!! The CA's private key is **public** and anyone can use it to issue certificates that will be trusted by a system with the Pebble CA in the trust store. To re-create all certificates used by Pebble, run: minica -ca-cert pebble.minica.pem \ -ca-key pebble.minica.key.pem \ -domains localhost,pebble \ -ip-addresses 127.0.0.1 From the `test/certs/` directory after [installing MiniCA](https://github.com/jsha/minica#installation) ================================================ FILE: e2e/fixtures/certs/localhost/README.md ================================================ # certs/localhost This directory contains an end-entity (leaf) certificate (`cert.pem`) and a private key (`key.pem`) for the Pebble HTTPS server. It includes `127.0.0.1` as an IP address SAN, and `[localhost, pebble]` as DNS SANs. ================================================ FILE: e2e/fixtures/certs/localhost/cert.pem ================================================ -----BEGIN CERTIFICATE----- MIIDMDCCAhigAwIBAgIILDt8c2fMw2IwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE AxMVbWluaWNhIHJvb3QgY2EgNTM0NWU2MB4XDTI1MDkwMzIzNDAwNVoXDTI3MTAw MzIzNDAwNVowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF AAOCAQ8AMIIBCgKCAQEAmxTFtw113RK70H9pQmdKs9AxhFmnQ6BdDtp3jOZlWlUO 0BltMXOUML5905etgtCbcC6RdKRtgSAiDfgx3VWiFMJH++4gUtnaB9SN8GhNSPBp FfSa2JhWPo9HQNUsAZqlGTV4SzcGRqtWvdZxUiOfQ2TcvyXIqsaD19ivvqI1NhT6 bl3tredTZlzLLM6Wvkw6hfyHrJAPQP8LOlCIeDM4YIce6Gstv6qo9iCD4wJiY4u9 5HVL7RK8t8JpZAb7VR+dPhbHEvVpjwuYd5Q05OZ280gFyrhbrKLbqst104GOQT4k QMJGWxGONyTX6np0Dx6O5jU7dvYvjVVawbJwGuaL6wIDAQABo3oweDAOBgNVHQ8B Af8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNV HSMEGDAWgBSu8RGpErgYUoYnQuwCq+/ggTiEjDAiBgNVHREEGzAZgglsb2NhbGhv c3SCBnBlYmJsZYcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAAB0gkekXCNOwqWmY vQ2lLJ8Zk2WzQ9B+VOC27IgxEEuskZyCpyXAbJB9sCGQWZhAARyaI4SPRGGagcug d1SwDWdPGeSJzF3aDnXDYoP9Zw2KqiqVZTngeoiw8Yn0F8PNriANwRLybouX7mMc 4V7T5+2k4SUs7pFH4KO0a0XBCcjXDjdKuBljftRTXCHzJzfRtmieCCuZlpnp5sHx hKa/uxKGyyZB+4Y3MrzsiQSCBOr9G4TH9RofmNcawl+tsVe08zLV/XVhrbakKEs7 Y7MGHSj3BkPFF32NObc0znqWzTaUD9hU+rXWGANM4sXd4dagdnxfrb7i0WYhcUFj 9Try8Q== -----END CERTIFICATE----- ================================================ FILE: e2e/fixtures/certs/localhost/key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAmxTFtw113RK70H9pQmdKs9AxhFmnQ6BdDtp3jOZlWlUO0Blt MXOUML5905etgtCbcC6RdKRtgSAiDfgx3VWiFMJH++4gUtnaB9SN8GhNSPBpFfSa 2JhWPo9HQNUsAZqlGTV4SzcGRqtWvdZxUiOfQ2TcvyXIqsaD19ivvqI1NhT6bl3t redTZlzLLM6Wvkw6hfyHrJAPQP8LOlCIeDM4YIce6Gstv6qo9iCD4wJiY4u95HVL 7RK8t8JpZAb7VR+dPhbHEvVpjwuYd5Q05OZ280gFyrhbrKLbqst104GOQT4kQMJG WxGONyTX6np0Dx6O5jU7dvYvjVVawbJwGuaL6wIDAQABAoIBAGW9W/S6lO+DIcoo PHL+9sg+tq2gb5ZzN3nOI45BfI6lrMEjXTqLG9ZasovFP2TJ3J/dPTnrwZdr8Et/ 357YViwORVFnKLeSCnMGpFPq6YEHj7mCrq+YSURjlRhYgbVPsi52oMOfhrOIJrEG ZXPAwPRi0Ftqu1omQEqz8qA7JHOkjB2p0i2Xc/uOSJccCmUDMlksRYz8zFe8wHuD XvUL2k23n2pBZ6wiez6Xjr0wUQ4ESI02x7PmYgA3aqF2Q6ECDwHhjVeQmAuypMF6 IaTjIJkWdZCW96pPaK1t+5nTNZ+Mg7tpJ/PRE4BkJvqcfHEOOl6wAE8gSk5uVApY ZRKGmGkCgYEAzF9iRXYo7A/UphL11bR0gqxB6qnQl54iLhqS/E6CVNcmwJ2d9pF8 5HTfSo1/lOXT3hGV8gizN2S5RmWBrc9HBZ+dNrVo7FYeeBiHu+opbX1X/C1HC0m1 wJNsyoXeqD1OFc1WbDpHz5iv4IOXzYdOdKiYEcTv5JkqE7jomqBLQk8CgYEAwkG/ rnwr4ThUo/DG5oH+l0LVnHkrJY+BUSI33g3eQ3eM0MSbfJXGT7snh5puJW0oXP7Z Gw88nK3Vnz2nTPesiwtO2OkUVgrIgWryIvKHaqrYnapZHuM+io30jbZOVaVTMR9c X/7/d5/evwXuP7p2DIdZKQKKFgROm1XnhNqVgaUCgYBD/ogHbCR5RVsOVciMbRlG UGEt3YmUp/vfMuAsKUKbT2mJM+dWHVlb+LZBa4pC06QFgfxNJi/aAhzSGvtmBEww xsXbaceauZwxgJfIIUPfNZCMSdQVIVTi2Smcx6UofBz6i/Jw14MEwlvhamaa7qVf kqflYYwelga1wRNCPopLaQKBgQCWsZqZKQqBNMm0Q9yIhN+TR+2d7QFjqeePoRPl 1qxNejhq25ojE607vNv1ff9kWUGuoqSZMUC76r6FQba/JoNbefI4otd7x/GzM9uS 8MHMJazU4okwROkHYwgLxxkNp6rZuJJYheB4VDTfyyH/ng5lubmY7rdgTQcNyZ5I majRYQKBgAMKJ3RlII0qvAfNFZr4Y2bNIq+60Z+Qu2W5xokIHCFNly3W1XDDKGFe CCPHSvQljinke3P9gPt2HVdXxcnku9VkTti+JygxuLkVg7E0/SWwrWfGsaMJs+84 fK+mTZay2d3v24r9WKEKwLykngYPyZw5+BdWU0E+xx5lGUd3U4gG -----END RSA PRIVATE KEY----- ================================================ FILE: e2e/fixtures/certs/pebble.minica.key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAuVoGTaFSWp3Y+N5JC8lOdL8wmWpaM73UaNzhYiqA7ZqijzVk TTtoQvQFDcUwyXKOdWHONrv1ld3z224Us504jjlbZwI5uoquCOZ2WJbRhmXrRgzk Fq+/MtoFmPkhtO/DLjjtocgyIirVXN8Yl2APvB5brvRfCm6kktYeecsWfW/O3ikf gdM7tmocwQiBypiloHOjdd5e2g8cWNw+rqvILSUVNLaLpsi23cxnLqVb424wz9dZ 5dO0REg1gSxtf4N5LSb6iGuAVoFNhzIeKzQ+svDg9x8tx/DGOghJS/jDgmxSY1qo bTsXhcmWVfat5GJ5PQgLkCSjBBrjeBlOrc4VtQIDAQABAoIBAQCAoRoou6C0ZEDU DScyN8TrvlcS0LzClaWYFFmRT5/jxOG1cr8l3elwNXpgYQ2Hb6mvim2ajHxVQg/e oxlYwO4jvWhSJzg63c0DPjS5LAlCNO6+0Wlk2RheSPGDhLlAoPeZ10YKdS1dis5B Qk4Fl1O0IHlOBCcEzV4GzPOfYDI+X6/f4xY7qz1s+CgoIxjIeiG+1/WpZQpYhobY 7CfSDdYDKtksXi7iQkc5earUAHBqZ1gQTq6e5LVm9AjRzENhMctFgcPs5zOjp2ak PluixrA8LTAfu9wQzvxDkPl0UarZVxCerw6nlAziILpQ+U6PtoPZj49VpntTc+cq 1qjzkbhBAoGBANElJmFWY2X6LgBpszeqt0ZOSbkFg2bC0wHCJrMlRzUMEn83w9e8 Z2Fqml9eCC5qxJcyxWDVQeoAX6090m0qgP8xNmGdafcVic2cUlrqtkqhhst2OHCO MCQEB7cdsjiidNNrOgLbQ3i1bYID8BVLf/TDhEbRgvTewDaz6XPdoSIRAoGBAOLg RuOec5gn50SrVycx8BLFO8AXjXojpZb1Xg26V5miz1IavSfDcgae/699ppSz+UWi jGMFr/PokY2JxDVs3PyQLu7ahMzyFHr16Agvp5g5kq056XV+uI/HhqLHOWSQ09DS 1Vrj7FOYpKRzge3/AC7ty9Vr35uMiebpm4/CLFVlAoGALnsIJZfSbWaFdLgJCXUa WDir77/G7T6dMIXanfPJ+IMfVUCqeLa5bxAHEOzP+qjl2giBjzy18nB00warTnGk y5I/WMBoPW5++sAkGWqSatGtKGi0sGcZUdfHcy3ZXvbT6eyprtrWCuyfUsbXQ5RM 8rPFIQwNA6jBpSak2ohF+FECgYEAn+6IKncNd6pRfnfmdSvf1+uPxkcUJZCxb2xC xByjGhvKWE+fHkPJwt8c0SIbZuJEC5Gds0RUF/XPfV4roZm/Yo9ldl02lp7kTxXA XtzxIP8c5d5YM8qD4l8+Csu0Kq9pkeC+JFddxkRpc8A1TIehInPhZ+6mb6mvoMb3 MW0pAX0CgYATT74RYuIYWZvx0TK4ZXIKTw2i6HObLF63Y6UwyPXXdEVie/ToYRNH JIxE1weVpHvnHZvVD6D3yGk39ZsCIt31VvKpatWXlWBm875MbBc6kuIGsYT+mSSj y9TXaE89E5zfL27nZe15QLJ+Xw8Io6PMLZ/jtC5TYoEixSZ9J8v6HA== -----END RSA PRIVATE KEY----- ================================================ FILE: e2e/fixtures/certs/pebble.minica.pem ================================================ -----BEGIN CERTIFICATE----- MIIDPzCCAiegAwIBAgIIU0Xm9UFdQxUwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE AxMVbWluaWNhIHJvb3QgY2EgNTM0NWU2MCAXDTI1MDkwMzIzNDAwNVoYDzIxMjUw OTAzMjM0MDA1WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSA1MzQ1ZTYwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu 9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0 toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3 Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB AAGjezB5MA4GA1UdDwEB/wQEAwIChDATBgNVHSUEDDAKBggrBgEFBQcDATASBgNV HRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSu8RGpErgYUoYnQuwCq+/ggTiEjDAf BgNVHSMEGDAWgBSu8RGpErgYUoYnQuwCq+/ggTiEjDANBgkqhkiG9w0BAQsFAAOC AQEAXDVYov1+f6EL7S41LhYQkEX/GyNNzsEvqxE9U0+3Iri5JfkcNOiA9O9L6Z+Y bqcsXV93s3vi4r4WSWuc//wHyJYrVe5+tK4nlFpbJOvfBUtnoBDyKNxXzZCxFJVh f9uc8UejRfQMFbDbhWY/x83y9BDufJHHq32OjCIN7gp2UR8rnfYvlz7Zg4qkJBsn DG4dwd+pRTCFWJOVIG0JoNhK3ZmE7oJ1N4H38XkZ31NPcMksKxpsLLIS9+mosZtg 4olL7tMPJklx5ZaeMFaKRDq4Gdxkbw4+O4vRgNm3Z8AXWKknOdfgdpqLUPPhRcP4 v1lhy71EhBuXXwRQJry0lTdF+w== -----END CERTIFICATE----- ================================================ FILE: e2e/fixtures/pebble-config-dns.json ================================================ { "pebble": { "listenAddress": "0.0.0.0:15000", "certificate": "fixtures/certs/localhost/cert.pem", "privateKey": "fixtures/certs/localhost/key.pem", "httpPort": 5004, "tlsPort": 5003, "profiles": { "default": { "description": "The profile you know and love", "validityPeriod": 7776000 }, "shortlived": { "description": "A short-lived cert profile, without actual enforcement", "validityPeriod": 518400 } } } } ================================================ FILE: e2e/fixtures/pebble-config.json ================================================ { "pebble": { "listenAddress": "0.0.0.0:14000", "certificate": "fixtures/certs/localhost/cert.pem", "privateKey": "fixtures/certs/localhost/key.pem", "httpPort": 5002, "tlsPort": 5001, "profiles": { "default": { "description": "The profile you know and love", "validityPeriod": 7776000 }, "shortlived": { "description": "A short-lived cert profile, without actual enforcement", "validityPeriod": 518400 } } } } ================================================ FILE: e2e/fixtures/update-dns.sh ================================================ #!/usr/bin/env bash # Simple DNS challenge exec solver. # Use challtestsrv https://github.com/letsencrypt/boulder/tree/master/test/challtestsrv set -e case "$1" in "present") echo "Present" payload="{\"host\":\"$2\", \"value\":\"$3\"}" echo "payload=${payload}" curl -s -X POST -d "${payload}" localhost:8055/set-txt ;; "cleanup") echo "cleanup" payload="{\"host\":\"$2\"}" echo "payload=${payload}" curl -s -X POST -d "${payload}" localhost:8055/clear-txt ;; *) echo "OOPS" ;; esac ================================================ FILE: e2e/loader/loader.go ================================================ package loader import ( "bufio" "bytes" "context" "crypto/tls" "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/go-acme/lego/v4/platform/wait" "github.com/ldez/grignotin/goenv" ) const ( cmdNamePebble = "pebble" cmdNameChallSrv = "pebble-challtestsrv" ) type CmdOption struct { HealthCheckURL string Args []string Env []string Dir string } type EnvLoader struct { PebbleOptions *CmdOption LegoOptions []string ChallSrv *CmdOption lego string } func (l *EnvLoader) MainTest(m *testing.M) int { if _, e2e := os.LookupEnv("LEGO_E2E_TESTS"); !e2e { fmt.Fprintln(os.Stderr, "skipping test: e2e tests are disabled. (no 'LEGO_E2E_TESTS' env var)") fmt.Println("PASS") return 0 } if _, err := exec.LookPath("git"); err != nil { fmt.Fprintln(os.Stderr, "skipping because git command not found") fmt.Println("PASS") return 0 } if l.PebbleOptions != nil { if _, err := exec.LookPath(cmdNamePebble); err != nil { fmt.Fprintln(os.Stderr, "skipping because pebble binary not found") fmt.Println("PASS") return 0 } } if l.ChallSrv != nil { if _, err := exec.LookPath(cmdNameChallSrv); err != nil { fmt.Fprintln(os.Stderr, "skipping because challtestsrv binary not found") fmt.Println("PASS") return 0 } } pebbleTearDown := l.launchPebble() defer pebbleTearDown() challSrvTearDown := l.launchChallSrv() defer challSrvTearDown() legoBinary, tearDown, err := buildLego() defer tearDown() if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } l.lego = legoBinary if l.PebbleOptions != nil && l.PebbleOptions.HealthCheckURL != "" { pebbleHealthCheck(l.PebbleOptions) } return m.Run() } func (l *EnvLoader) RunLegoCombinedOutput(arg ...string) ([]byte, error) { cmd := exec.Command(l.lego, arg...) cmd.Env = l.LegoOptions fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) return cmd.CombinedOutput() } func (l *EnvLoader) RunLego(arg ...string) error { cmd := exec.Command(l.lego, arg...) cmd.Env = l.LegoOptions fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("create pipe: %w", err) } cmd.Stderr = cmd.Stdout err = cmd.Start() if err != nil { return fmt.Errorf("start command: %w", err) } scanner := bufio.NewScanner(stdout) for scanner.Scan() { println(scanner.Text()) } err = cmd.Wait() if err != nil { return fmt.Errorf("wait command: %w", err) } return nil } func (l *EnvLoader) launchPebble() func() { if l.PebbleOptions == nil { return func() {} } pebble, outPebble := l.cmdPebble() go func() { err := pebble.Run() if err != nil { fmt.Println(err) } }() return func() { err := pebble.Process.Kill() if err != nil { fmt.Println(err) } fmt.Println(outPebble.String()) } } func (l *EnvLoader) cmdPebble() (*exec.Cmd, *bytes.Buffer) { cmd := exec.Command(cmdNamePebble, l.PebbleOptions.Args...) cmd.Env = l.PebbleOptions.Env dir, err := filepath.Abs(l.PebbleOptions.Dir) if err != nil { panic(err) } cmd.Dir = dir fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) var b bytes.Buffer cmd.Stdout = &b cmd.Stderr = &b return cmd, &b } func pebbleHealthCheck(options *CmdOption) { client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} err := wait.For("pebble", 10*time.Second, 500*time.Millisecond, func() (bool, error) { resp, err := client.Get(options.HealthCheckURL) if err != nil { return false, err } if resp.StatusCode != http.StatusOK { return false, nil } return true, nil }) if err != nil { panic(err) } } func (l *EnvLoader) launchChallSrv() func() { if l.ChallSrv == nil { return func() {} } challtestsrv, outChalSrv := l.cmdChallSrv() go func() { err := challtestsrv.Run() if err != nil { fmt.Println(err) } }() return func() { err := challtestsrv.Process.Kill() if err != nil { fmt.Println(err) } fmt.Println(outChalSrv.String()) } } func (l *EnvLoader) cmdChallSrv() (*exec.Cmd, *bytes.Buffer) { cmd := exec.Command(cmdNameChallSrv, l.ChallSrv.Args...) fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) var b bytes.Buffer cmd.Stdout = &b cmd.Stderr = &b return cmd, &b } func buildLego() (string, func(), error) { here, err := os.Getwd() if err != nil { return "", func() {}, err } defer func() { _ = os.Chdir(here) }() buildPath, err := os.MkdirTemp("", "lego_test") if err != nil { return "", func() {}, err } projectRoot, err := getProjectRoot() if err != nil { return "", func() {}, err } mainFolder := filepath.Join(projectRoot, "cmd", "lego") err = os.Chdir(mainFolder) if err != nil { return "", func() {}, err } binary := filepath.Join(buildPath, "lego") err = build(binary) if err != nil { return "", func() {}, err } err = os.Chdir(here) if err != nil { return "", func() {}, err } return binary, func() { _ = os.RemoveAll(buildPath) CleanLegoFiles() }, nil } func getProjectRoot() (string, error) { git := exec.Command("git", "rev-parse", "--show-toplevel") output, err := git.CombinedOutput() if err != nil { fmt.Fprintf(os.Stderr, "%s\n", output) return "", err } return strings.TrimSpace(string(output)), nil } func build(binary string) error { toolPath, err := goToolPath() if err != nil { return err } cmd := exec.Command(toolPath, "build", "-o", binary) output, err := cmd.CombinedOutput() if err != nil { fmt.Fprintf(os.Stderr, "%s\n", output) return err } return nil } func goToolPath() (string, error) { // inspired by go1.11.1/src/internal/testenv/testenv.go if os.Getenv("GO_GCFLAGS") != "" { return "", errors.New("'go build' not compatible with setting $GO_GCFLAGS") } if runtime.GOOS == "darwin" && strings.HasPrefix(runtime.GOARCH, "arm") { return "", fmt.Errorf("skipping test: 'go build' not available on %s/%s", runtime.GOOS, runtime.GOARCH) } return goTool() } func goTool() (string, error) { var exeSuffix string if runtime.GOOS == "windows" { exeSuffix = ".exe" } goRoot, err := goenv.GetOne(context.Background(), goenv.GOROOT) if err != nil { return "", fmt.Errorf("cannot find go root: %w", err) } path := filepath.Join(goRoot, "bin", "go"+exeSuffix) if _, err = os.Stat(path); err == nil { return path, nil } goBin, err := exec.LookPath("go" + exeSuffix) if err != nil { return "", fmt.Errorf("cannot find go tool: %w", err) } return goBin, nil } func CleanLegoFiles() { cmd := exec.Command("rm", "-rf", ".lego") fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) output, err := cmd.CombinedOutput() if err != nil { fmt.Println(string(output)) } } ================================================ FILE: e2e/readme.md ================================================ # E2E tests - Install [Pebble](https://github.com/letsencrypt/pebble): ```bash go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.9.0 go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.9.0 ``` - Launch tests: ```bash make e2e ``` ================================================ FILE: go.mod ================================================ module github.com/go-acme/lego/v4 go 1.24.0 require ( cloud.google.com/go/compute/metadata v0.9.0 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 github.com/Azure/go-autorest/autorest v0.11.30 github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 github.com/Azure/go-autorest/autorest/to v0.4.1 github.com/BurntSushi/toml v1.6.0 github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 github.com/alibabacloud-go/tea v1.4.0 github.com/aliyun/credentials-go v1.4.7 github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.8 github.com/aws/aws-sdk-go-v2/credentials v1.19.8 github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 github.com/aziontech/azionapi-go-sdk v0.144.0 github.com/baidubce/bce-sdk-go v0.9.260 github.com/cenkalti/backoff/v5 v5.0.3 github.com/dnsimple/dnsimple-go/v4 v4.0.0 github.com/exoscale/egoscale/v3 v3.1.33 github.com/go-acme/alidns-20150109/v4 v4.7.0 github.com/go-acme/esa-20240910/v2 v2.48.0 github.com/go-acme/jdcloud-sdk-go v1.64.0 github.com/go-acme/tencentclouddnspod v1.3.24 github.com/go-acme/tencentedgdeone v1.3.38 github.com/go-jose/go-jose/v4 v4.1.3 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/go-cmp v0.7.0 github.com/google/go-querystring v1.2.0 github.com/google/uuid v1.6.0 github.com/gophercloud/gophercloud v1.14.1 github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/go-version v1.8.0 github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df github.com/infobloxopen/infoblox-go-client/v2 v2.10.0 github.com/labbsr0x/bindman-dns-webhook v1.0.2 github.com/ldez/grignotin v0.10.1 github.com/linode/linodego v1.65.0 github.com/liquidweb/liquidweb-go v1.6.4 github.com/mattn/go-isatty v0.0.20 github.com/miekg/dns v1.1.72 github.com/mimuret/golang-iij-dpf v0.9.1 github.com/namedotcom/go/v4 v4.0.2 github.com/nrdcg/auroradns v1.2.0 github.com/nrdcg/bunny-go v0.1.0 github.com/nrdcg/desec v0.11.1 github.com/nrdcg/dnspod-go v0.4.0 github.com/nrdcg/freemyip v0.3.0 github.com/nrdcg/goacmedns v0.2.0 github.com/nrdcg/goinwx v0.12.0 github.com/nrdcg/mailinabox v0.3.0 github.com/nrdcg/namesilo v0.5.0 github.com/nrdcg/nodion v0.1.0 github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 github.com/nrdcg/porkbun v0.4.0 github.com/nrdcg/vegadns v0.3.0 github.com/nzdjb/go-metaname v1.0.0 github.com/ovh/go-ovh v1.9.0 github.com/pquerna/otp v1.5.0 github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 github.com/regfish/regfish-dnsapi-go v0.1.1 github.com/sacloud/api-client-go v0.3.3 github.com/sacloud/iaas-api-go v1.23.1 github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 github.com/selectel/domains-go v1.1.0 github.com/selectel/go-selvpcclient/v4 v4.1.0 github.com/softlayer/softlayer-go v1.2.1 github.com/stretchr/testify v1.11.1 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 github.com/transip/gotransip/v6 v6.26.1 github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 github.com/urfave/cli/v2 v2.27.7 github.com/vinyldns/go-vinyldns v0.9.17 github.com/volcengine/volc-sdk-golang v1.0.237 github.com/vultr/govultr/v3 v3.27.0 github.com/yandex-cloud/go-genproto v0.54.0 github.com/yandex-cloud/go-sdk/services/dns v0.0.36 github.com/yandex-cloud/go-sdk/v2 v2.56.0 golang.org/x/crypto v0.48.0 golang.org/x/net v0.50.0 golang.org/x/oauth2 v0.35.0 golang.org/x/text v0.34.0 golang.org/x/time v0.14.0 google.golang.org/api v0.267.0 gopkg.in/ns1/ns1-go.v2 v2.17.2 gopkg.in/yaml.v2 v2.4.0 software.sslmate.com/src/go-pkcs12 v0.7.0 ) require ( cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect github.com/alibabacloud-go/openapi-util v0.1.1 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-errors/errors v1.0.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/goccy/go-yaml v1.9.8 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labbsr0x/goh v1.0.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/liquidweb/liquidweb-cli v0.6.9 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/peterhellberg/link v1.2.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sacloud/go-http v0.1.9 // indirect github.com/sacloud/packages-go v0.0.12 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/viper v1.18.2 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver v1.13.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/tools v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) retract v4.30.0 // Problem related to misuse of sycalls by aliyun/credentials-go ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYsMyFh9qoE= github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= github.com/Azure/go-autorest/autorest/adal v0.9.22 h1:/GblQdIudfEM3AWWZ0mrYJQSd7JS4S/Mbzh6F0ov0Xc= github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 h1:w77/uPk80ZET2F+AfQExZyEWtn+0Rk/uw17m9fv5Ajc= github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/cvHQkZ1fst0EmZnA5dFtiQdWCNCFYzb+uE2vqVgvx0= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 h1:h/33OxYLqBk0BYmEbSUy7MlvgQR/m1w1/7OJFKoPL1I= github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0/go.mod h1:rvh3imDA6EaQi+oM/GQHkQAOHbXPKJ7EWJvfjuw141Q= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE= github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 h1:Mubp9hXZMTPWZK+WxrR+kKOVFp4Q/PDZrIIM7ByXI9Y= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= github.com/alibabacloud-go/openapi-util v0.1.1 h1:ujGErJjG8ncRW6XtBBMphzHTvCxn4DjrVw4m04HsS28= github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw= github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= github.com/alibabacloud-go/tea v1.4.0 h1:MSKhu/kWLPX7mplWMngki8nNt+CyUZ+kfkzaR5VpMhA= github.com/alibabacloud-go/tea v1.4.0/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= github.com/aliyun/credentials-go v1.4.7 h1:T17dLqEtPUFvjDRRb5giVvLh6dFT8IcNFJJb7MeyCxw= github.com/aliyun/credentials-go v1.4.7/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs= github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 h1:VM5e5M39zRSs+aT0O9SoxHjUXqXxhbw3Yi0FdMQWPIc= github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11/go.mod h1:0jvzYPIQGCpnY/dmdaotTk2JH4QuBlnW0oeyrcGLWJ4= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y= github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aziontech/azionapi-go-sdk v0.144.0 h1:T+/w18o+FCiZsk3Z0ACBVVe7c/5EGLG15S3P8JfuPfo= github.com/aziontech/azionapi-go-sdk v0.144.0/go.mod h1:OKxP/R0iVXnJJakYwMhh2BGAXnud8Ruy55Ak9ANuWoU= github.com/baidubce/bce-sdk-go v0.9.260 h1:1v1+2GTP+NGK3L24rJ+bnoiTaDaIy2YoaUM+ot2GTcw= github.com/baidubce/bce-sdk-go v0.9.260/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw= github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/dnsimple/dnsimple-go/v4 v4.0.0 h1:nUCICZSyZDiiqimAAL+E8XL+0sKGks5VRki5S8XotRo= github.com/dnsimple/dnsimple-go/v4 v4.0.0/go.mod h1:AXT2yfAFOntJx6iMeo1J/zKBw0ggXFYBt4e97dqqPnc= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/exoscale/egoscale/v3 v3.1.33 h1:5Lk/pwZ+K0sjNu9obS0VYPfhZQffRkvvO0BpdPoir4o= github.com/exoscale/egoscale/v3 v3.1.33/go.mod h1:0iY8OxgHJCS5TKqDNhwOW95JBKCnBZl3YGU4Yt+NqkU= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-acme/alidns-20150109/v4 v4.7.0 h1:PqJ/wR0JTpL4v0Owu1uM7bPQ1Yww0eQLAuuSdLjjQaQ= github.com/go-acme/alidns-20150109/v4 v4.7.0/go.mod h1:btQvB6xZoN6ykKB74cPhiR+uvhrEE2AFVXm6RDmCHm0= github.com/go-acme/esa-20240910/v2 v2.48.0 h1:muSDyhjDTejxUGe3FTthCPCqRaEdYY9cG3N/AmU52Lc= github.com/go-acme/esa-20240910/v2 v2.48.0/go.mod h1:shPb6hzc1rJL15IJBY8HQ4GZk4E8RC52+52twutEwIg= github.com/go-acme/jdcloud-sdk-go v1.64.0 h1:AW9j5khk8tRYbpBJPxKmqdwIqgLs2Fz3HUK3hn2YXjs= github.com/go-acme/jdcloud-sdk-go v1.64.0/go.mod h1:qc/m8HNX1Zgd7GAv2DSEinup8fwy3Ted3/VVx7LB5bU= github.com/go-acme/tencentclouddnspod v1.3.24 h1:uCSiOW1EJttcnOON+MVVyVDJguFL/Q4NIGkq1CrT9p8= github.com/go-acme/tencentclouddnspod v1.3.24/go.mod h1:RKcB2wSoZncjBA0OEFj59s1ko1XDy+ZsAtk+9uMxUF0= github.com/go-acme/tencentedgdeone v1.3.38 h1:5YsVl0H4A+cwtiUqR1eZbKFdr4OWfYp2KYJopifzKyQ= github.com/go-acme/tencentedgdeone v1.3.38/go.mod h1:yyjTKVmGpMtFv5HqGODqehHnZJ4KWAbG6dAiwWDgCDY= github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= github.com/goccy/go-yaml v1.9.8 h1:5gMyLUeU1/6zl+WFfR1hN7D2kf+1/eRGa7DFtToiBvQ= github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 h1:sH7xkTfYzxIEgzq1tDHIMKRh1vThOEOGNsettdEeLbE= github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56/go.mod h1:VSalo4adEk+3sNkmVJLnhHoOyOYYS8sTWLG4mv5BKto= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df h1:MZf03xP9WdakyXhOWuAD5uPK3wHh96wCsqe3hCMKh8E= github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/infobloxopen/infoblox-go-client/v2 v2.10.0 h1:AKsihjFT/t6Y0keEv3p59DACcOuh0inWXdUB0ZOzYH0= github.com/infobloxopen/infoblox-go-client/v2 v2.10.0/go.mod h1:NeNJpz09efw/edzqkVivGv1bWqBXTomqYBRFbP+XBqg= github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labbsr0x/bindman-dns-webhook v1.0.2 h1:I7ITbmQPAVwrDdhd6dHKi+MYJTJqPCK0jE6YNBAevnk= github.com/labbsr0x/bindman-dns-webhook v1.0.2/go.mod h1:p6b+VCXIR8NYKpDr8/dg1HKfQoRHCdcsROXKvmoehKA= github.com/labbsr0x/goh v1.0.1 h1:97aBJkDjpyBZGPbQuOK5/gHcSFbcr5aRsq3RSRJFpPk= github.com/labbsr0x/goh v1.0.1/go.mod h1:8K2UhVoaWXcCU7Lxoa2omWnC8gyW8px7/lmO61c027w= github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/linode/linodego v1.65.0 h1:SdsuGD8VSsPWeShXpE7ihl5vec+fD3MgwhnfYC/rj7k= github.com/linode/linodego v1.65.0/go.mod h1:tOFiTErdjkbVnV+4S0+NmIE9dqqZUEM2HsJaGu8wMh8= github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs= github.com/liquidweb/liquidweb-cli v0.6.9 h1:acbIvdRauiwbxIsOCEMXGwF75aSJDbDiyAWPjVnwoYM= github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ= github.com/liquidweb/liquidweb-go v1.6.4 h1:6S0m3hHSpiLqGD7AFSb7lH/W/qr1wx+tKil9fgIbjMc= github.com/liquidweb/liquidweb-go v1.6.4/go.mod h1:B934JPIIcdA+uTq2Nz5PgOtG6CuCaEvQKe/Ge/5GgZ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mimuret/golang-iij-dpf v0.9.1 h1:Gj6EhHJkOhr+q2RnvRPJsPMcjuVnWPSccEHyoEehU34= github.com/mimuret/golang-iij-dpf v0.9.1/go.mod h1:sl9KyOkESib9+KRD3HaGpgi1xk7eoN2+d96LCLsME2M= github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/namedotcom/go/v4 v4.0.2 h1:4gNkPaPRG/2tqFNUUof7jAVsA6vDutFutEOd7ivnDwA= github.com/namedotcom/go/v4 v4.0.2/go.mod h1:J6sVueHMb0qbarPgdhrzEVhEaYp+R1SCaTGl2s6/J1Q= github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q= github.com/nats-io/jwt/v2 v2.0.3/go.mod h1:VRP+deawSXyhNjXmxPCHskrR6Mq50BqpEI5SEcNiGlY= github.com/nats-io/nats-server/v2 v2.5.0/go.mod h1:Kj86UtrXAL6LwYRA6H4RqzkHhK0Vcv2ZnKD5WbQ1t3g= github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w= github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nrdcg/auroradns v1.2.0 h1:Jg407vTdXZvZKsART9CNWMp8rQOyhBk04q0MsOf0YR4= github.com/nrdcg/auroradns v1.2.0/go.mod h1:hnByA4Z7MOmV4EPRw5eOmEaNRFavcCIz6kONpNxp9LI= github.com/nrdcg/bunny-go v0.1.0 h1:GAHTRpHaG/TxfLZlqoJ8OJFzw8rI74+jOTkzxWh0uHA= github.com/nrdcg/bunny-go v0.1.0/go.mod h1:u+C9dgsspgtWVaAz6QkyV17s9fxD8viwwKoxb9XMz1A= github.com/nrdcg/desec v0.11.1 h1:ilpKmCr4gGsLcyq3RHfHNmlRzm9fzT2XbWxoVaUCS0s= github.com/nrdcg/desec v0.11.1/go.mod h1:2LuxHlOcwML/7cntu0eimONmA1U+ZxFDAonoSXr4igQ= github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U= github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= github.com/nrdcg/freemyip v0.3.0 h1:0D2rXgvLwe2RRaVIjyUcQ4S26+cIS2iFwnhzDsEuuwc= github.com/nrdcg/freemyip v0.3.0/go.mod h1:c1PscDvA0ukBF0dwelU/IwOakNKnVxetpAQ863RMJoM= github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0= github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4= github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0= github.com/nrdcg/mailinabox v0.3.0 h1:PHkC1elKXKAjEvdx2HHFMgcEGZFqudAl7aU3L2JDhM4= github.com/nrdcg/mailinabox v0.3.0/go.mod h1:1eFIGcM4lI+AfFOUpbs548SFGz1ZWoMOGbECBmkghw4= github.com/nrdcg/namesilo v0.5.0 h1:6QNxT/XxE+f5B+7QlfWorthNzOzcGlBLRQxqi6YeBrE= github.com/nrdcg/namesilo v0.5.0/go.mod h1:4UkwlwQfDt74kSGmhLaDylnBrD94IfflnpoEaj6T2qw= github.com/nrdcg/nodion v0.1.0 h1:zLKaqTn2X0aDuBHHfyA1zFgeZfiCpmu/O9DM73okavw= github.com/nrdcg/nodion v0.1.0/go.mod h1:inbuh3neCtIWlMPZHtEpe43TmRXxHV6+hk97iCZicms= github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 h1:OWijzl3nHUApvTivl+3+78dbBwmyEHOnb+W9m6ixGbk= github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8= github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 h1:9LsjN/zaIN7H8JE61NHpbWhxF0UGY96+kMlk3g8OvGU= github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2/go.mod h1:32vZH06TuwZSn+IDMO1qcDvC2vHVlzUALCwXGWPA+dc= github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw= github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54= github.com/nrdcg/vegadns v0.3.0 h1:11FQMw7xVIRUWO9o5+Z/5YZhmPWlm4oxUUH3F6EVqQU= github.com/nrdcg/vegadns v0.3.0/go.mod h1:NqSyRKZuJlAsv8VI/7rSubfPXN68NwaJ0aG9KxQVFVo= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nzdjb/go-metaname v1.0.0 h1:sNASlZC1RM3nSudtBTE1a3ZVTDyTpjqI5WXRPrdZ9Hg= github.com/nzdjb/go-metaname v1.0.0/go.mod h1:0GR0LshZax1Lz4VrOrfNSE4dGvTp7HGjiemdczXT2H4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE= github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM= github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 h1:dq90+d51/hQRaHEqRAsQ1rE/pC1GUS4sc2rCbbFsAIY= github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/regfish/regfish-dnsapi-go v0.1.1 h1:TJFtbePHkd47q5GZwYl1h3DIYXmoxdLjW/SBsPtB5IE= github.com/regfish/regfish-dnsapi-go v0.1.1/go.mod h1:ubIgXSfqarSnl3XHSn8hIFwFF3h0yrq0ZiWD93Y2VjY= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sacloud/api-client-go v0.3.3 h1:ZpSAyGpITA8UFO3Hq4qMHZLGuNI1FgxAxo4sqBnCKDs= github.com/sacloud/api-client-go v0.3.3/go.mod h1:0p3ukcWYXRCc2AUWTl1aA+3sXLvurvvDqhRaLZRLBwo= github.com/sacloud/go-http v0.1.9 h1:Xa5PY8/pb7XWhwG9nAeXSrYXPbtfBWqawgzxD5co3VE= github.com/sacloud/go-http v0.1.9/go.mod h1:DpDG+MSyxYaBwPJ7l3aKLMzwYdTVtC5Bo63HActcgoE= github.com/sacloud/iaas-api-go v1.23.1 h1:rjYG0vVoxWyETiwc7R8YdD7CIzs9vVNEOzu7w6dgGzc= github.com/sacloud/iaas-api-go v1.23.1/go.mod h1:EGIHOWRB9azOv7HPCVM8WpOEl28WIV9TNRbnEVg+Q3U= github.com/sacloud/packages-go v0.0.12 h1:MKeZNN3FQn1heqUSRBrbZw89YusZA1n4kammjMFZYvQ= github.com/sacloud/packages-go v0.0.12/go.mod h1:XNF5MCTWcHo9NiqWnYctVbASSSZR3ZOmmQORIzcurJ8= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/selectel/domains-go v1.1.0 h1:futG50J43ALLKQAnZk9H9yOtLGnSUh7c5hSvuC5gSHo= github.com/selectel/domains-go v1.1.0/go.mod h1:SugRKfq4sTpnOHquslCpzda72wV8u0cMBHx0C0l+bzA= github.com/selectel/go-selvpcclient/v4 v4.1.0 h1:22lBp+rzg9g2MP4iiGhpVAcCt0kMv7I7uV1W3taLSvQ= github.com/selectel/go-selvpcclient/v4 v4.1.0/go.mod h1:eFhL1KUW159KOJVeGO7k/Uxl0TYd/sBkWXjuF5WxmYk= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/softlayer/softlayer-go v1.2.1 h1:8ucHxn5laVsVPb0/aMGnr6tOMt1I9BgEtU5mn70OGKw= github.com/softlayer/softlayer-go v1.2.1/go.mod h1:Gz9/ktcmB7Z8EJlu+QEJJpkv8lAmnhYdB9Tc6gedjmo= github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ= github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.4.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.24/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.38/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 h1:bCs+z6dxRaHWm/C1D/XkSOcCZ0+W2+/6HmIXjpAj+fY= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA= github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 h1:/VaznPrb/b68e3iMvkr27fU7JqPKU4j7tIITZnjQX1k= github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419/go.mod h1:QN0/PdenvYWB0GRMz6JJbPeZz2Lph2iys1p8AFVHm2c= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/vinyldns/go-vinyldns v0.9.17 h1:hfPZfCaxcRBX6Gsgl42rLCeoal58/BH8kkvJShzjjdI= github.com/vinyldns/go-vinyldns v0.9.17/go.mod h1:pwWhE9K/leGDOIduVhRGvQ3ecVMHWRfEnKYUTEU3gB4= github.com/volcengine/volc-sdk-golang v1.0.237 h1:hpLKiS2BwDcSBtZWSz034foCbd0h3FrHTKlUMqHIdc4= github.com/volcengine/volc-sdk-golang v1.0.237/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM= github.com/vultr/govultr/v3 v3.27.0 h1:J8etMyu/Jh5+idMsu2YZpOWmDXXHeW4VZnkYXmJYHx8= github.com/vultr/govultr/v3 v3.27.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yandex-cloud/go-genproto v0.54.0 h1:LjEwDPBAtF39HvcPQe8I+ImCnFasCPCOVh2b2Sr2eAg= github.com/yandex-cloud/go-genproto v0.54.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= github.com/yandex-cloud/go-sdk/services/dns v0.0.36 h1:sD622+baDvJ2ujhCfoFsCH0XeNsaZNW6loRqvRavjtE= github.com/yandex-cloud/go-sdk/services/dns v0.0.36/go.mod h1:Hh7IKJxULaRzmyM19lQZw+yUDyMM8M3Qrk1LbWqhCkc= github.com/yandex-cloud/go-sdk/v2 v2.56.0 h1:rihPAZbPbHU/BKTLuT64nU1uhbBrO20HhdlLR3Hyoz0= github.com/yandex-cloud/go-sdk/v2 v2.56.0/go.mod h1:jzVBQgamNHoiDsmjog2dPZHMXuGZqmxf/epH+Qb7Emc= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU= golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201110211018-35f3e6cf4a65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs= gopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0= software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= ================================================ FILE: internal/clihelp/generator.go ================================================ package main //go:generate go run . import ( "bytes" "fmt" "log" "os" "strings" "text/template" "github.com/go-acme/lego/v4/cmd" "github.com/urfave/cli/v2" ) const outputFile = "../../docs/data/zz_cli_help.toml" const baseTemplate = `# THIS FILE IS AUTO-GENERATED. PLEASE DO NOT EDIT. {{ range .}} [[command]] title = "{{.Title}}" content = """ {{.Help}} """ {{end -}} ` type commandHelp struct { Title string Help string } func main() { log.SetFlags(0) err := generate() if err != nil { log.Fatal(err) } log.Println("cli_help.toml updated") } func generate() error { app := createStubApp() outputTpl := template.Must(template.New("output").Parse(baseTemplate)) // collect output of various help pages var help []commandHelp for _, args := range [][]string{ {"lego", "help"}, {"lego", "help", "run"}, {"lego", "help", "renew"}, {"lego", "help", "revoke"}, {"lego", "help", "list"}, {"lego", "dnshelp"}, } { content, err := run(app, args) if err != nil { return fmt.Errorf("running %s failed: %w", args, err) } help = append(help, content) } f, err := os.Create(outputFile) if err != nil { return fmt.Errorf("cannot open cli_help.toml: %w", err) } err = outputTpl.Execute(f, help) defer func() { _ = f.Close() }() if err != nil { return fmt.Errorf("failed to write cli_help.toml: %w", err) } return nil } // createStubApp Construct cli app, very similar to cmd/lego/main.go. // Notable differences: // - substitute "." for CWD in default config path, as the user will very likely see a different path // - do not include version information, because we're likely running against a snapshot // - skip DNS help and provider list, as initialization takes time, and we don't generate `lego dns --help` here. func createStubApp() *cli.App { app := cli.NewApp() app.Name = "lego" app.HelpName = "lego" app.Usage = "Let's Encrypt client written in Go" app.Flags = cmd.CreateFlags("./.lego") app.Commands = cmd.CreateCommands() return app } func run(app *cli.App, args []string) (h commandHelp, err error) { w := app.Writer defer func() { app.Writer = w }() var buf bytes.Buffer app.Writer = &buf if err := app.Run(args); err != nil { return h, err } return commandHelp{ Title: strings.Join(args, " "), Help: strings.TrimSpace(buf.String()), }, nil } ================================================ FILE: internal/dns/descriptors/descriptors.go ================================================ package descriptors import ( "os" "path/filepath" "github.com/BurntSushi/toml" ) type Providers struct { Providers []Provider } type Provider struct { Name string // Real name of the DNS provider Code string // DNS code Aliases []string // DNS code aliases (for compatibility/deprecation) Since string // First lego version URL string // DNS provider URL Description string // Provider summary Example string // CLI example Configuration *Configuration // Environment variables Links *Links // Links Additional string // Extra documentation GeneratedFrom string // Source file } type Configuration struct { Credentials map[string]string Additional map[string]string } type Links struct { API string GoClient string } // GetProviderInformation extract provider information from TOML description files. func GetProviderInformation(root string) (*Providers, error) { models := &Providers{} err := filepath.Walk(filepath.Join(root, "providers", "dns"), walker(root, models)) if err != nil { return nil, err } return models, nil } func walker(root string, prs *Providers) func(string, os.FileInfo, error) error { return func(path string, _ os.FileInfo, err error) error { if err != nil { return err } if filepath.Ext(path) != ".toml" { return nil } m := Provider{} m.GeneratedFrom, err = filepath.Rel(root, path) if err != nil { return err } _, err = toml.DecodeFile(path, &m) if err != nil { return err } prs.Providers = append(prs.Providers, m) return nil } } ================================================ FILE: internal/dns/docs/generator.go ================================================ package main //go:generate go run . import ( "bufio" "bytes" "embed" "errors" "fmt" "go/format" html "html/template" "log" "os" "path/filepath" "slices" "strings" "text/template" "github.com/go-acme/lego/v4/internal/dns/descriptors" ) //go:embed templates var templateFS embed.FS const ( root = "../../../" cliOutput = root + "cmd/zz_gen_cmd_dnshelp.go" docOutput = root + "docs/content/dns" readmePath = root + "README.md" ) const ( mdTemplate = "templates/dns.md.tmpl" cliTemplate = "templates/dns.go.tmpl" readmeTemplate = "templates/readme.md.tmpl" ) const ( startLine = "" endLine = "" ) func main() { models, err := descriptors.GetProviderInformation(root) if err != nil { log.Fatal(err) } err = cleanDocumentation() if err != nil { log.Fatal(err) } for _, m := range models.Providers { // generate documentation err = generateDocumentation(m) if err != nil { log.Fatal(err) } } // generate CLI help err = generateCLIHelp(models) if err != nil { log.Fatal(err) } // generate README.md err = generateReadMe(models) if err != nil { log.Fatal(err) } fmt.Printf("Documentation for %d DNS providers has been generated.\n", len(models.Providers)+1) } func cleanDocumentation() error { paths, err := filepath.Glob(filepath.Join(docOutput, "zz_gen_*.md")) if err != nil { return err } for _, p := range paths { err = os.RemoveAll(p) if err != nil { return err } } return nil } func generateDocumentation(m descriptors.Provider) error { filename := filepath.Join(docOutput, "zz_gen_"+m.Code+".md") file, err := os.Create(filename) if err != nil { return err } defer func() { _ = file.Close() }() return template.Must(template.ParseFS(templateFS, mdTemplate)).Execute(file, m) } func generateCLIHelp(models *descriptors.Providers) error { filename := filepath.Clean(cliOutput) file, err := os.Create(filename) if err != nil { return err } defer func() { _ = file.Close() }() b := &bytes.Buffer{} err = template.Must( template.New(filepath.Base(cliTemplate)).Funcs(map[string]any{ "safe": func(src string) string { return strings.ReplaceAll(src, "`", "'") }, }).ParseFS(templateFS, cliTemplate), ).Execute(b, models) if err != nil { return err } // gofmt source, err := format.Source(b.Bytes()) if err != nil { return err } _, err = file.Write(source) return err } func generateReadMe(models *descriptors.Providers) error { tpl := html.Must(html.New(filepath.Base(readmeTemplate)).ParseFS(templateFS, readmeTemplate)) providers := orderProviders(models) file, err := os.Open(readmePath) if err != nil { return err } defer func() { _ = file.Close() }() var skip bool buffer := bytes.NewBufferString("") fileScanner := bufio.NewScanner(file) for fileScanner.Scan() { text := fileScanner.Text() if text == startLine { _, _ = fmt.Fprintln(buffer, text) if err = tpl.Execute(buffer, providers); err != nil { return err } skip = true } if text == endLine { skip = false } if skip { continue } _, _ = fmt.Fprintln(buffer, text) } if fileScanner.Err() != nil { return fileScanner.Err() } if skip { return errors.New("missing end tag") } return os.WriteFile(readmePath, buffer.Bytes(), 0o666) } func orderProviders(models *descriptors.Providers) [][]descriptors.Provider { const nbCol = 4 slices.SortFunc(models.Providers, func(a, b descriptors.Provider) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) var ( matrix [][]descriptors.Provider row []descriptors.Provider ) for i, p := range models.Providers { switch { case len(row) == nbCol: matrix = append(matrix, row) row = []descriptors.Provider{p} case i == len(models.Providers)-1: row = append(row, p) for j := len(row); j < nbCol; j++ { row = append(row, descriptors.Provider{}) } matrix = append(matrix, row) default: row = append(row, p) } } if len(row) < nbCol { for j := len(row); j < nbCol; j++ { row = append(row, descriptors.Provider{}) } matrix = append(matrix, row) } return matrix } ================================================ FILE: internal/dns/docs/templates/dns.go.tmpl ================================================ // Code generated by 'make generate-dns'; DO NOT EDIT. package cmd import ( "fmt" "io" "sort" "strings" "text/tabwriter" ) func allDNSCodes() string { providers := []string{ {{- range $provider := .Providers }} "{{ $provider.Code }}", {{- end}} } sort.Strings(providers) return strings.Join(providers, ", ") } func displayDNSHelp(w io.Writer, name string) error { w = tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) ew := &errWriter{w: w} switch name { {{- range $provider := .Providers }} case "{{ $provider.Code }}": // generated from: {{ .GeneratedFrom }} ew.writeln(`Configuration for {{ $provider.Name }}.`) ew.writeln(`Code: '{{ $provider.Code }}'`) ew.writeln(`Since: '{{ $provider.Since }}'`) ew.writeln() {{if $provider.Configuration }}{{if $provider.Configuration.Credentials }} ew.writeln(`Credentials:`) {{- range $k, $v := $provider.Configuration.Credentials }} ew.writeln(` - "{{ $k }}": {{ safe $v }}`) {{- end}} ew.writeln() {{end}}{{if $provider.Configuration.Additional }} ew.writeln(`Additional Configuration:`) {{- range $k, $v := $provider.Configuration.Additional }} ew.writeln(` - "{{ $k }}": {{ safe $v }}`) {{- end}} {{end}}{{end}} ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/{{ $provider.Code }}`) {{end}} default: return fmt.Errorf("%q is not yet supported", name) } if flusher, ok := w.(interface{ Flush() error }); ok { return flusher.Flush() } return nil } ================================================ FILE: internal/dns/docs/templates/dns.md.tmpl ================================================ --- title: "{{ .Name }}" date: 2019-03-03T16:39:46+01:00 draft: false slug: {{ .Code }} dnsprovider: since: "{{ .Since }}" code: "{{ .Code }}" url: "{{ .URL }}" --- {{if .Description -}} {{ .Description }} {{else}} Configuration for [{{ .Name }}]({{ .URL }}). {{end}} - Code: `{{ .Code }}` - Since: {{ .Since }} {{if .Example }} Here is an example bash command using the {{ .Name }} provider: ```bash {{ .Example -}} ``` {{else}} {{ "{{" }}% notice note %}} _Please contribute by adding a CLI example._ {{ "{{" }}% /notice %}} {{end}} {{if .Configuration }} {{if .Configuration.Credentials }} ## Credentials | Environment Variable Name | Description | |-----------------------|-------------| {{- range $k, $v := .Configuration.Credentials }} | `{{$k}}` | {{$v}} | {{- end}} The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{ `{{% ref "dns#configuration-and-credentials" %}}` }}). {{- end}} {{if .Configuration.Additional }} ## Additional Configuration | Environment Variable Name | Description | |--------------------------------|-------------| {{- range $k, $v := .Configuration.Additional }} | `{{$k}}` | {{$v}} | {{- end}} The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{ `{{% ref "dns#configuration-and-credentials" %}}` }}). {{- end}} {{- end}} {{ .Additional }} {{if .Links }} ## More information {{if .Links.API -}} - [API documentation]({{ .Links.API }}) {{- end}} {{- if .Links.GoClient }} - [Go client]({{ .Links.GoClient }}) {{- end}} {{- end}} ================================================ FILE: internal/dns/docs/templates/readme.md.tmpl ================================================ {{- range . -}} {{- range . }} {{- end }} {{- end -}}
{{if .Code }}{{ .Name }}{{end}}
================================================ FILE: internal/dns/providers/dns_providers.go.tmpl ================================================ // Code generated by 'make generate-dns'; DO NOT EDIT. package dns import ( "fmt" "github.com/go-acme/lego/v4/challenge" {{- range $provider := .Providers }} "github.com/go-acme/lego/v4/providers/dns/{{ cleanName $provider.Code }}" {{- end}} ) // NewDNSChallengeProviderByName Factory for DNS providers. func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { switch name { {{- range $provider := .Providers }} case "{{ $provider.Code }}"{{range $alias := $provider.Aliases }},"{{ $alias }}"{{end}}: return {{ cleanName $provider.Code }}.NewDNSProvider() {{- end}} default: return nil, fmt.Errorf("unrecognized DNS provider: %s", name) } } ================================================ FILE: internal/dns/providers/generator.go ================================================ package main //go:generate go run . import ( "bytes" _ "embed" "fmt" "go/format" "log" "os" "path/filepath" "strings" "text/template" "github.com/go-acme/lego/v4/internal/dns/descriptors" ) const ( root = "../../../" outputPath = "providers/dns/zz_gen_dns_providers.go" ) //go:embed dns_providers.go.tmpl var srcTemplate string func main() { err := generate() if err != nil { log.Fatal(err) } } func generate() error { info, err := descriptors.GetProviderInformation(root) if err != nil { return err } file, err := os.Create(filepath.Join(root, outputPath)) if err != nil { return err } defer func() { _ = file.Close() }() b := &bytes.Buffer{} err = template.Must( template.New("").Funcs(map[string]any{ "cleanName": func(src string) string { return strings.ReplaceAll(src, "-", "") }, }).Parse(srcTemplate), ).Execute(b, info) if err != nil { return err } // gofmt source, err := format.Source(b.Bytes()) if err != nil { return err } _, err = file.Write(source) if err != nil { return err } fmt.Printf("Switch mapping for %d DNS providers has been generated.\n", len(info.Providers)+1) return nil } ================================================ FILE: internal/releaser/generator.go ================================================ package main import ( "bytes" "embed" "fmt" "go/format" "os" "path/filepath" "text/template" ) const ( dnsTemplate = "templates/dns.go.tmpl" dnsTargetFile = "./providers/dns/internal/useragent/useragent.go" ) const ( senderTemplate = "templates/sender.go.tmpl" senderTargetFile = "./acme/api/internal/sender/useragent.go" ) const ( versionTemplate = "templates/version.go.tmpl" versionTargetFile = "./cmd/lego/zz_gen_version.go" ) //go:embed templates var templateFS embed.FS type Generator struct { templatePath string targetFile string } func NewGenerator(templatePath, targetFile string) *Generator { return &Generator{templatePath: templatePath, targetFile: targetFile} } func (g *Generator) Generate(version, comment string) error { tmpl, err := template.New(filepath.Base(g.templatePath)).ParseFS(templateFS, g.templatePath) if err != nil { return fmt.Errorf("parsing template (%s): %w", g.templatePath, err) } b := &bytes.Buffer{} err = tmpl.Execute(b, map[string]string{ "version": version, "comment": comment, }) if err != nil { return fmt.Errorf("execute template (%s): %w", g.templatePath, err) } source, err := format.Source(b.Bytes()) if err != nil { return fmt.Errorf("format generated content (%s): %w", g.targetFile, err) } err = os.WriteFile(g.targetFile, source, 0o644) if err != nil { return fmt.Errorf("write file (%s): %w", g.targetFile, err) } return nil } func generate(targetVersion, comment string) error { generators := []*Generator{ NewGenerator(dnsTemplate, dnsTargetFile), NewGenerator(senderTemplate, senderTargetFile), NewGenerator(versionTemplate, versionTargetFile), } for _, generator := range generators { err := generator.Generate(targetVersion, comment) if err != nil { return fmt.Errorf("generate file(s): %w", err) } } return nil } ================================================ FILE: internal/releaser/releaser.go ================================================ package main import ( "fmt" "go/ast" "go/parser" "go/token" "log" "os" "strconv" hcversion "github.com/hashicorp/go-version" "github.com/urfave/cli/v2" ) const flgMode = "mode" const ( modePatch = "patch" modeMinor = "minor" modeMajor = "major" ) const versionSourceFile = "./cmd/lego/zz_gen_version.go" const ( commentRelease = "release" commentDetach = "detach" ) func main() { app := cli.NewApp() app.Name = "lego-releaser" app.Usage = "Lego releaser" app.HelpName = "releaser" app.Commands = []*cli.Command{ { Name: "release", Usage: "Update file for a release", Action: release, Before: func(ctx *cli.Context) error { mode := ctx.String("mode") switch mode { case modePatch, modeMinor, modeMajor: return nil default: return fmt.Errorf("invalid mode: %s", mode) } }, Flags: []cli.Flag{ &cli.StringFlag{ Name: flgMode, Aliases: []string{"m"}, Value: modePatch, Usage: fmt.Sprintf("The release mode: %s|%s|%s", modePatch, modeMinor, modeMajor), }, }, }, { Name: "detach", Usage: "Update file post release", Action: detach, }, } err := app.Run(os.Args) if err != nil { log.Fatal(err) } } func release(ctx *cli.Context) error { mode := ctx.String(flgMode) currentVersion, err := readCurrentVersion(versionSourceFile) if err != nil { return fmt.Errorf("read current version: %w", err) } nextVersion, err := bumpVersion(mode, currentVersion) if err != nil { return fmt.Errorf("bump version: %w", err) } err = generate(nextVersion, commentRelease) if err != nil { return err } return nil } func detach(_ *cli.Context) error { currentVersion, err := readCurrentVersion(versionSourceFile) if err != nil { return fmt.Errorf("read current version: %w", err) } v := currentVersion.Core().String() err = generate(v, commentDetach) if err != nil { return err } return nil } func readCurrentVersion(filename string) (*hcversion.Version, error) { fset := token.NewFileSet() file, err := parser.ParseFile(fset, filename, nil, parser.AllErrors) if err != nil { return nil, err } v := visitor{data: make(map[string]string)} ast.Walk(v, file) current, err := hcversion.NewSemver(v.data["defaultVersion"]) if err != nil { return nil, err } return current, nil } type visitor struct { data map[string]string } func (v visitor) Visit(n ast.Node) ast.Visitor { if n == nil { return nil } switch d := n.(type) { case *ast.GenDecl: if d.Tok == token.CONST { for _, spec := range d.Specs { valueSpec, ok := spec.(*ast.ValueSpec) if !ok { continue } if len(valueSpec.Names) != 1 || len(valueSpec.Values) != 1 { continue } va, ok := valueSpec.Values[0].(*ast.BasicLit) if !ok { continue } if va.Kind != token.STRING { continue } s, err := strconv.Unquote(va.Value) if err != nil { continue } v.data[valueSpec.Names[0].String()] = s } } default: // noop } return v } func bumpVersion(mode string, v *hcversion.Version) (string, error) { segments := v.Segments() switch mode { case modePatch: return fmt.Sprintf("%d.%d.%d", segments[0], segments[1], segments[2]+1), nil case modeMinor: return fmt.Sprintf("%d.%d.0", segments[0], segments[1]+1), nil case modeMajor: return fmt.Sprintf("%d.0.0", segments[0]+1), nil default: return "", fmt.Errorf("invalid mode: %s", mode) } } ================================================ FILE: internal/releaser/templates/dns.go.tmpl ================================================ // Code generated by 'internal/releaser'; DO NOT EDIT. package useragent import ( "fmt" "net/http" "runtime" ) const ( // ourUserAgent is the User-Agent of this underlying library package. ourUserAgent = "goacme-lego/{{ .version }}" // ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package. // values: detach|release // NOTE: Update this with each tagged release. ourUserAgentComment = "{{ .comment }}" ) // Get builds and returns the User-Agent string. func Get() string { return fmt.Sprintf("%s (%s; %s; %s)", ourUserAgent, ourUserAgentComment, runtime.GOOS, runtime.GOARCH) } // SetHeader sets the User-Agent header. func SetHeader(h http.Header) { h.Set("User-Agent", Get()) } ================================================ FILE: internal/releaser/templates/sender.go.tmpl ================================================ // Code generated by 'internal/releaser'; DO NOT EDIT. package sender const ( // ourUserAgent is the User-Agent of this underlying library package. ourUserAgent = "xenolf-acme/{{ .version }}" // ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package. // values: detach|release // NOTE: Update this with each tagged release. ourUserAgentComment = "{{ .comment }}" ) ================================================ FILE: internal/releaser/templates/version.go.tmpl ================================================ // Code generated by 'internal/releaser'; DO NOT EDIT. package main const defaultVersion = "v{{ .version }}+dev{{ if .comment }}-{{ .comment }}{{end}}" var version = "" func getVersion() string { if version == "" { return defaultVersion } return version } ================================================ FILE: lego/client.go ================================================ package lego import ( "errors" "net/url" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/challenge/resolver" "github.com/go-acme/lego/v4/registration" ) // Client is the user-friendly way to ACME. type Client struct { Certificate *certificate.Certifier Challenge *resolver.SolverManager Registration *registration.Registrar core *api.Core } // NewClient creates a new ACME client on behalf of the user. // The client will depend on the ACME directory located at CADirURL for the rest of its actions. // A private key of type keyType (see KeyType constants) will be generated when requesting a new certificate if one isn't provided. func NewClient(config *Config) (*Client, error) { if config == nil { return nil, errors.New("a configuration must be provided") } _, err := url.Parse(config.CADirURL) if err != nil { return nil, err } if config.HTTPClient == nil { return nil, errors.New("the HTTP client cannot be nil") } privateKey := config.User.GetPrivateKey() if privateKey == nil { return nil, errors.New("private key was nil") } var kid string if reg := config.User.GetRegistration(); reg != nil { kid = reg.URI } core, err := api.New(config.HTTPClient, config.UserAgent, config.CADirURL, kid, privateKey) if err != nil { return nil, err } solversManager := resolver.NewSolversManager(core) prober := resolver.NewProber(solversManager) options := certificate.CertifierOptions{ KeyType: config.Certificate.KeyType, Timeout: config.Certificate.Timeout, OverallRequestLimit: config.Certificate.OverallRequestLimit, DisableCommonName: config.Certificate.DisableCommonName, } certifier := certificate.NewCertifier(core, prober, options) return &Client{ Certificate: certifier, Challenge: solversManager, Registration: registration.NewRegistrar(core, config.User), core: core, }, nil } // GetToSURL returns the current ToS URL from the Directory. func (c *Client) GetToSURL() string { return c.core.GetDirectory().Meta.TermsOfService } // GetExternalAccountRequired returns the External Account Binding requirement of the Directory. func (c *Client) GetExternalAccountRequired() bool { return c.core.GetDirectory().Meta.ExternalAccountRequired } ================================================ FILE: lego/client_config.go ================================================ package lego import ( "crypto/tls" "crypto/x509" "fmt" "net" "net/http" "os" "strconv" "strings" "time" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/registration" ) const ( // caCertificatesEnvVar is the environment variable name that can be used to // specify the path to PEM encoded CA Certificates that can be used to // authenticate an ACME server with an HTTPS certificate not issued by a CA in // the system-wide trusted root list. // Multiple file paths can be added by using os.PathListSeparator as a separator. caCertificatesEnvVar = "LEGO_CA_CERTIFICATES" // caSystemCertPool is the environment variable name that can be used to define // if the certificates pool must use a copy of the system cert pool. caSystemCertPool = "LEGO_CA_SYSTEM_CERT_POOL" // caServerNameEnvVar is the environment variable name that can be used to // specify the CA server name that can be used to // authenticate an ACME server with an HTTPS certificate not issued by a CA in // the system-wide trusted root list. caServerNameEnvVar = "LEGO_CA_SERVER_NAME" // LEDirectoryProduction URL to the Let's Encrypt production. LEDirectoryProduction = "https://acme-v02.api.letsencrypt.org/directory" // LEDirectoryStaging URL to the Let's Encrypt staging. LEDirectoryStaging = "https://acme-staging-v02.api.letsencrypt.org/directory" ) type Config struct { CADirURL string User registration.User UserAgent string HTTPClient *http.Client Certificate CertificateConfig } func NewConfig(user registration.User) *Config { return &Config{ CADirURL: LEDirectoryProduction, User: user, HTTPClient: createDefaultHTTPClient(), Certificate: CertificateConfig{ KeyType: certcrypto.RSA2048, Timeout: 30 * time.Second, }, } } type CertificateConfig struct { KeyType certcrypto.KeyType Timeout time.Duration OverallRequestLimit int DisableCommonName bool } // createDefaultHTTPClient Creates an HTTP client with a reasonable timeout value // and potentially a custom *x509.CertPool // based on the caCertificatesEnvVar environment variable (see the `initCertPool` function). func createDefaultHTTPClient() *http.Client { return &http.Client{ Timeout: 2 * time.Minute, Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 30 * time.Second, ResponseHeaderTimeout: 30 * time.Second, TLSClientConfig: &tls.Config{ ServerName: os.Getenv(caServerNameEnvVar), RootCAs: initCertPool(), }, }, } } // initCertPool creates a *x509.CertPool populated with the PEM certificates // found in the filepath specified in the caCertificatesEnvVar OS environment variable. // If the caCertificatesEnvVar is not set then initCertPool will return nil. // If there is an error creating a *x509.CertPool from the provided caCertificatesEnvVar value then initCertPool will panic. // If the caSystemCertPool is set to a "truthy value" (`1`, `t`, `T`, `TRUE`, `true`, `True`) then a copy of system cert pool will be used. // caSystemCertPool requires caCertificatesEnvVar to be set. func initCertPool() *x509.CertPool { customCACertsPath := os.Getenv(caCertificatesEnvVar) if customCACertsPath == "" { return nil } useSystemCertPool, _ := strconv.ParseBool(os.Getenv(caSystemCertPool)) caCerts := strings.Split(customCACertsPath, string(os.PathListSeparator)) certPool, err := CreateCertPool(caCerts, useSystemCertPool) if err != nil { panic(fmt.Sprintf("create certificates pool: %v", err)) } return certPool } // CreateCertPool creates a *x509.CertPool populated with the PEM certificates. func CreateCertPool(caCerts []string, useSystemCertPool bool) (*x509.CertPool, error) { if len(caCerts) == 0 { return nil, nil } certPool := newCertPool(useSystemCertPool) for _, customPath := range caCerts { customCAs, err := os.ReadFile(customPath) if err != nil { return nil, fmt.Errorf("error reading %q: %w", customPath, err) } if ok := certPool.AppendCertsFromPEM(customCAs); !ok { return nil, fmt.Errorf("error creating x509 cert pool from %q: %w", customPath, err) } } return certPool, nil } func newCertPool(useSystemCertPool bool) *x509.CertPool { if !useSystemCertPool { return x509.NewCertPool() } pool, err := x509.SystemCertPool() if err == nil { return pool } return x509.NewCertPool() } ================================================ FILE: lego/client_test.go ================================================ package lego import ( "crypto" "crypto/rand" "crypto/rsa" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/registration" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewClient(t *testing.T) { server := tester.MockACMEServer().BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") user := mockUser{ email: "test@test.com", regres: new(registration.Resource), privatekey: key, } config := NewConfig(user) config.CADirURL = server.URL + "/dir" config.HTTPClient = server.Client() client, err := NewClient(config) require.NoError(t, err, "Could not create client") assert.NotNil(t, client) } type mockUser struct { email string regres *registration.Resource privatekey *rsa.PrivateKey } func (u mockUser) GetEmail() string { return u.email } func (u mockUser) GetRegistration() *registration.Resource { return u.regres } func (u mockUser) GetPrivateKey() crypto.PrivateKey { return u.privatekey } ================================================ FILE: log/logger.go ================================================ package log import ( "log" "os" ) // Logger is an optional custom logger. var Logger StdLogger = log.New(os.Stderr, "", log.LstdFlags) // StdLogger interface for Standard Logger. type StdLogger interface { Fatal(args ...any) Fatalln(args ...any) Fatalf(format string, args ...any) Print(args ...any) Println(args ...any) Printf(format string, args ...any) } // Fatal writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. func Fatal(args ...any) { Logger.Fatal(args...) } // Fatalf writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. func Fatalf(format string, args ...any) { Logger.Fatalf(format, args...) } // Print writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. func Print(args ...any) { Logger.Print(args...) } // Println writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. func Println(args ...any) { Logger.Println(args...) } // Printf writes a log entry. // It uses Logger if not nil, otherwise it uses the default log.Logger. func Printf(format string, args ...any) { Logger.Printf(format, args...) } // Warnf writes a log entry. func Warnf(format string, args ...any) { Printf("[WARN] "+format, args...) } // Infof writes a log entry. func Infof(format string, args ...any) { Printf("[INFO] "+format, args...) } ================================================ FILE: platform/config/env/env.go ================================================ package env import ( "errors" "fmt" "os" "strconv" "strings" "time" "github.com/go-acme/lego/v4/log" ) // Get environment variables. func Get(names ...string) (map[string]string, error) { values := map[string]string{} var missingEnvVars []string for _, envVar := range names { value := GetOrFile(envVar) if value == "" { missingEnvVars = append(missingEnvVars, envVar) } values[envVar] = value } if len(missingEnvVars) > 0 { return nil, fmt.Errorf("some credentials information are missing: %s", strings.Join(missingEnvVars, ",")) } return values, nil } // GetWithFallback Get environment variable values. // The first name in each group is use as key in the result map. // // case 1: // // // LEGO_ONE="ONE" // // LEGO_TWO="TWO" // env.GetWithFallback([]string{"LEGO_ONE", "LEGO_TWO"}) // // => "LEGO_ONE" = "ONE" // // case 2: // // // LEGO_ONE="" // // LEGO_TWO="TWO" // env.GetWithFallback([]string{"LEGO_ONE", "LEGO_TWO"}) // // => "LEGO_ONE" = "TWO" // // case 3: // // // LEGO_ONE="" // // LEGO_TWO="" // env.GetWithFallback([]string{"LEGO_ONE", "LEGO_TWO"}) // // => error func GetWithFallback(groups ...[]string) (map[string]string, error) { values := map[string]string{} var missingEnvVars []string for _, names := range groups { if len(names) == 0 { return nil, errors.New("undefined environment variable names") } value, envVar := getOneWithFallback(names[0], names[1:]...) if value == "" { missingEnvVars = append(missingEnvVars, envVar) continue } values[envVar] = value } if len(missingEnvVars) > 0 { return nil, fmt.Errorf("some credentials information are missing: %s", strings.Join(missingEnvVars, ",")) } return values, nil } func GetOneWithFallback[T any](main string, defaultValue T, fn func(string) (T, error), names ...string) T { v, _ := getOneWithFallback(main, names...) value, err := fn(v) if err != nil { return defaultValue } return value } func getOneWithFallback(main string, names ...string) (string, string) { value := GetOrFile(main) if value != "" { return value, main } for _, name := range names { value := GetOrFile(name) if value != "" { return value, main } } return "", main } // GetOrDefaultString returns the given environment variable value as a string. // Returns the default if the env var cannot be found. func GetOrDefaultString(envVar, defaultValue string) string { return getOrDefault(envVar, defaultValue, ParseString) } // GetOrDefaultBool returns the given environment variable value as a boolean. // Returns the default if the env var cannot be coopered to a boolean, or is not found. func GetOrDefaultBool(envVar string, defaultValue bool) bool { return getOrDefault(envVar, defaultValue, strconv.ParseBool) } // GetOrDefaultInt returns the given environment variable value as an integer. // Returns the default if the env var cannot be coopered to an int, or is not found. func GetOrDefaultInt(envVar string, defaultValue int) int { return getOrDefault(envVar, defaultValue, strconv.Atoi) } // GetOrDefaultSecond returns the given environment variable value as a time.Duration (second). // Returns the default if the env var cannot be coopered to an int, or is not found. func GetOrDefaultSecond(envVar string, defaultValue time.Duration) time.Duration { return getOrDefault(envVar, defaultValue, ParseSecond) } func getOrDefault[T any](envVar string, defaultValue T, fn func(string) (T, error)) T { v, err := fn(GetOrFile(envVar)) if err != nil { return defaultValue } return v } // GetOrFile Attempts to resolve 'key' as an environment variable. // Failing that, it will check to see if '_FILE' exists. // If so, it will attempt to read from the referenced file to populate a value. func GetOrFile(envVar string) string { envVarValue := os.Getenv(envVar) if envVarValue != "" { return envVarValue } fileVar := envVar + "_FILE" fileVarValue := os.Getenv(fileVar) if fileVarValue == "" { return envVarValue } fileContents, err := os.ReadFile(fileVarValue) if err != nil { log.Printf("Failed to read the file %s (defined by env var %s): %s", fileVarValue, fileVar, err) return "" } return strings.TrimSuffix(string(fileContents), "\n") } // ParseSecond parses env var value (string) to a second (time.Duration). func ParseSecond(s string) (time.Duration, error) { v, err := strconv.Atoi(s) if err != nil { return 0, err } if v < 0 { return 0, fmt.Errorf("unsupported value: %d", v) } return time.Duration(v) * time.Second, nil } // ParseString parses env var value (string) to a string but throws an error when the string is empty. func ParseString(s string) (string, error) { if s == "" { return "", errors.New("empty string") } return s, nil } // ParsePairs parses a raw string of comma-separated key-value pairs into a map. // Keys and values are separated by a colon and are trimmed of whitespace. func ParsePairs(raw string) (map[string]string, error) { result := make(map[string]string) for pair := range strings.SplitSeq(strings.TrimSuffix(raw, ","), ",") { data := strings.Split(pair, ":") if len(data) != 2 { return nil, fmt.Errorf("incorrect pair: %s", pair) } result[strings.TrimSpace(data[0])] = strings.TrimSpace(data[1]) } return result, nil } ================================================ FILE: platform/config/env/env_test.go ================================================ package env import ( "os" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetWithFallback(t *testing.T) { var1Exist := os.Getenv("TEST_LEGO_VAR_EXIST_1") var2Exist := os.Getenv("TEST_LEGO_VAR_EXIST_2") var1Missing := os.Getenv("TEST_LEGO_VAR_MISSING_1") var2Missing := os.Getenv("TEST_LEGO_VAR_MISSING_2") t.Cleanup(func() { _ = os.Setenv("TEST_LEGO_VAR_EXIST_1", var1Exist) _ = os.Setenv("TEST_LEGO_VAR_EXIST_2", var2Exist) _ = os.Setenv("TEST_LEGO_VAR_MISSING_1", var1Missing) _ = os.Setenv("TEST_LEGO_VAR_MISSING_2", var2Missing) }) err := os.Setenv("TEST_LEGO_VAR_EXIST_1", "VAR1") require.NoError(t, err) err = os.Setenv("TEST_LEGO_VAR_EXIST_2", "VAR2") require.NoError(t, err) err = os.Unsetenv("TEST_LEGO_VAR_MISSING_1") require.NoError(t, err) err = os.Unsetenv("TEST_LEGO_VAR_MISSING_2") require.NoError(t, err) type expected struct { value map[string]string error string } testCases := []struct { desc string groups [][]string expected expected }{ { desc: "no groups", groups: nil, expected: expected{ value: map[string]string{}, }, }, { desc: "empty groups", groups: [][]string{{}, {}}, expected: expected{ error: "undefined environment variable names", }, }, { desc: "missing env var", groups: [][]string{{"TEST_LEGO_VAR_MISSING_1"}}, expected: expected{ error: "some credentials information are missing: TEST_LEGO_VAR_MISSING_1", }, }, { desc: "all env var in a groups are missing", groups: [][]string{{"TEST_LEGO_VAR_MISSING_1", "TEST_LEGO_VAR_MISSING_2"}}, expected: expected{ error: "some credentials information are missing: TEST_LEGO_VAR_MISSING_1", }, }, { desc: "only the first env var have a value", groups: [][]string{{"TEST_LEGO_VAR_EXIST_1", "TEST_LEGO_VAR_MISSING_1"}}, expected: expected{ value: map[string]string{"TEST_LEGO_VAR_EXIST_1": "VAR1"}, }, }, { desc: "only the second env var have a value", groups: [][]string{{"TEST_LEGO_VAR_MISSING_1", "TEST_LEGO_VAR_EXIST_1"}}, expected: expected{ value: map[string]string{"TEST_LEGO_VAR_MISSING_1": "VAR1"}, }, }, { desc: "all env vars in a groups have a value", groups: [][]string{{"TEST_LEGO_VAR_EXIST_1", "TEST_LEGO_VAR_EXIST_2"}}, expected: expected{ value: map[string]string{"TEST_LEGO_VAR_EXIST_1": "VAR1"}, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() value, err := GetWithFallback(test.groups...) if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) } else { require.NoError(t, err) assert.Equal(t, test.expected.value, value) } }) } } func TestGetOneWithFallback(t *testing.T) { var1Exist := os.Getenv("TEST_LEGO_VAR_EXIST_1") var2Exist := os.Getenv("TEST_LEGO_VAR_EXIST_2") var1Missing := os.Getenv("TEST_LEGO_VAR_MISSING_1") var2Missing := os.Getenv("TEST_LEGO_VAR_MISSING_2") t.Cleanup(func() { _ = os.Setenv("TEST_LEGO_VAR_EXIST_1", var1Exist) _ = os.Setenv("TEST_LEGO_VAR_EXIST_2", var2Exist) _ = os.Setenv("TEST_LEGO_VAR_MISSING_1", var1Missing) _ = os.Setenv("TEST_LEGO_VAR_MISSING_2", var2Missing) }) err := os.Setenv("TEST_LEGO_VAR_EXIST_1", "VAR1") require.NoError(t, err) err = os.Setenv("TEST_LEGO_VAR_EXIST_2", "VAR2") require.NoError(t, err) err = os.Unsetenv("TEST_LEGO_VAR_MISSING_1") require.NoError(t, err) err = os.Unsetenv("TEST_LEGO_VAR_MISSING_2") require.NoError(t, err) testCases := []struct { desc string main string defaultValue string alts []string expected string }{ { desc: "with value and no alternative", main: "TEST_LEGO_VAR_EXIST_1", defaultValue: "oops", expected: "VAR1", }, { desc: "with value and alternatives", main: "TEST_LEGO_VAR_EXIST_1", defaultValue: "oops", alts: []string{"TEST_LEGO_VAR_MISSING_1"}, expected: "VAR1", }, { desc: "without value and no alternatives", main: "TEST_LEGO_VAR_MISSING_1", defaultValue: "oops", expected: "oops", }, { desc: "without value and alternatives", main: "TEST_LEGO_VAR_MISSING_1", defaultValue: "oops", alts: []string{"TEST_LEGO_VAR_EXIST_1"}, expected: "VAR1", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() value := GetOneWithFallback(test.main, test.defaultValue, ParseString, test.alts...) assert.Equal(t, test.expected, value) }) } } func TestGetOrDefaultInt(t *testing.T) { testCases := []struct { desc string envValue string defaultValue int expected int }{ { desc: "valid value", envValue: "100", defaultValue: 2, expected: 100, }, { desc: "invalid content, use default value", envValue: "abc123", defaultValue: 2, expected: 2, }, { desc: "valid negative value", envValue: "-111", defaultValue: 2, expected: -111, }, { desc: "float: invalid type, use default value", envValue: "1.11", defaultValue: 2, expected: 2, }, } const key = "LEGO_ENV_TC" for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Setenv(key, test.envValue) result := GetOrDefaultInt(key, test.defaultValue) assert.Equal(t, test.expected, result) }) } } func TestGetOrDefaultSecond(t *testing.T) { testCases := []struct { desc string envValue string defaultValue time.Duration expected time.Duration }{ { desc: "valid value", envValue: "100", defaultValue: 2 * time.Second, expected: 100 * time.Second, }, { desc: "invalid content, use default value", envValue: "abc123", defaultValue: 2 * time.Second, expected: 2 * time.Second, }, { desc: "invalid content, negative value", envValue: "-111", defaultValue: 2 * time.Second, expected: 2 * time.Second, }, { desc: "float: invalid type, use default value", envValue: "1.11", defaultValue: 2 * time.Second, expected: 2 * time.Second, }, } key := "LEGO_ENV_TC" for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Setenv(key, test.envValue) result := GetOrDefaultSecond(key, test.defaultValue) assert.Equal(t, test.expected, result) }) } } func TestGetOrDefaultString(t *testing.T) { testCases := []struct { desc string envValue string defaultValue string expected string }{ { desc: "missing env var", defaultValue: "foo", expected: "foo", }, { desc: "with env var", envValue: "bar", defaultValue: "foo", expected: "bar", }, } key := "LEGO_ENV_TC" for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Setenv(key, test.envValue) actual := GetOrDefaultString(key, test.defaultValue) assert.Equal(t, test.expected, actual) }) } } func TestGetOrDefaultBool(t *testing.T) { testCases := []struct { desc string envValue string defaultValue bool expected bool }{ { desc: "missing env var", defaultValue: true, expected: true, }, { desc: "with env var", envValue: "true", defaultValue: false, expected: true, }, { desc: "invalid value", envValue: "foo", defaultValue: false, expected: false, }, } key := "LEGO_ENV_TC" for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Setenv(key, test.envValue) actual := GetOrDefaultBool(key, test.defaultValue) assert.Equal(t, test.expected, actual) }) } } func TestGetOrFile_ReadsEnvVars(t *testing.T) { t.Setenv("TEST_LEGO_ENV_VAR", "lego_env") value := GetOrFile("TEST_LEGO_ENV_VAR") assert.Equal(t, "lego_env", value) } func TestGetOrFile_ReadsFiles(t *testing.T) { varEnvFileName := "TEST_LEGO_ENV_VAR_FILE" varEnvName := "TEST_LEGO_ENV_VAR" testCases := []struct { desc string fileContent []byte }{ { desc: "simple", fileContent: []byte("lego_file"), }, { desc: "with an empty last line", fileContent: []byte("lego_file\n"), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { err := os.Unsetenv(varEnvFileName) require.NoError(t, err) err = os.Unsetenv(varEnvName) require.NoError(t, err) file, err := os.CreateTemp(t.TempDir(), "lego") require.NoError(t, err) t.Cleanup(func() { _ = file.Close() }) err = os.WriteFile(file.Name(), []byte("lego_file\n"), 0o644) require.NoError(t, err) t.Setenv(varEnvFileName, file.Name()) value := GetOrFile(varEnvName) assert.Equal(t, "lego_file", value) }) } } func TestGetOrFile_PrefersEnvVars(t *testing.T) { varEnvFileName := "TEST_LEGO_ENV_VAR_FILE" varEnvName := "TEST_LEGO_ENV_VAR" err := os.Unsetenv(varEnvFileName) require.NoError(t, err) err = os.Unsetenv(varEnvName) require.NoError(t, err) file, err := os.CreateTemp(t.TempDir(), "lego") require.NoError(t, err) t.Cleanup(func() { _ = file.Close() }) err = os.WriteFile(file.Name(), []byte("lego_file"), 0o644) require.NoError(t, err) t.Setenv(varEnvFileName, file.Name()) t.Setenv(varEnvName, "lego_env") value := GetOrFile(varEnvName) assert.Equal(t, "lego_env", value) } func TestParsePairs(t *testing.T) { testCases := []struct { desc string value string expected map[string]string }{ { desc: "one pair", value: "foo:bar", expected: map[string]string{"foo": "bar"}, }, { desc: "multiple pairs", value: "foo:bar,a:b,c:d", expected: map[string]string{"a": "b", "c": "d", "foo": "bar"}, }, { desc: "multiple pairs with spaces", value: "foo:bar, a:b , c: d", expected: map[string]string{"a": "b", "c": "d", "foo": "bar"}, }, { desc: "empty value pair", value: "foo:", expected: map[string]string{"foo": ""}, }, { desc: "empty key pair", value: ":bar", expected: map[string]string{"": "bar"}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() pairs, err := ParsePairs(test.value) require.NoError(t, err) assert.Equal(t, test.expected, pairs) }) } } func TestParsePairs_error(t *testing.T) { testCases := []struct { desc string value string }{ { desc: "empty value", value: "", }, { desc: "multiple colons", value: "foo:bar:bir", }, { desc: "valid pair and multiple colons", value: "a:b,foo:bar:bir", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() _, err := ParsePairs(test.value) require.Error(t, err) }) } } ================================================ FILE: platform/tester/api.go ================================================ package tester import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/platform/tester/servermock" ) // MockACMEServer Minimal stub ACME server for validation. func MockACMEServer() *servermock.Builder[*httptest.Server] { return servermock.NewBuilder( func(server *httptest.Server) (*httptest.Server, error) { return server, nil }). Route("GET /dir", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { serverURL := fmt.Sprintf("https://%s", req.Context().Value(http.LocalAddrContextKey)) servermock.JSONEncode(acme.Directory{ NewNonceURL: serverURL + "/nonce", NewAccountURL: serverURL + "/account", NewOrderURL: serverURL + "/newOrder", RevokeCertURL: serverURL + "/revokeCert", KeyChangeURL: serverURL + "/keyChange", RenewalInfo: serverURL + "/renewalInfo", }).ServeHTTP(rw, req) })). Route("HEAD /nonce", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { rw.Header().Set("Replay-Nonce", "12345") rw.Header().Set("Retry-After", "0") })) } // WriteJSONResponse marshals the body as JSON and writes it to the response. func WriteJSONResponse(w http.ResponseWriter, body any) error { bs, err := json.Marshal(body) if err != nil { return err } w.Header().Set("Content-Type", "application/json") if _, err := w.Write(bs); err != nil { return err } return nil } ================================================ FILE: platform/tester/dnsmock/dnsmock.go ================================================ package dnsmock import ( "fmt" "math" "net" "strings" "sync" "testing" "time" "github.com/miekg/dns" "github.com/stretchr/testify/require" ) const noType uint16 = math.MaxUint16 type Option func(*dns.Server) error type Builder struct { // domain -> op -> type routes map[string]map[int]map[uint16]dns.Handler stringToType map[string]uint16 } func NewServer() *Builder { stringToType := make(map[string]uint16) for typ, str := range dns.TypeToString { stringToType[str] = typ } return &Builder{ routes: make(map[string]map[int]map[uint16]dns.Handler), stringToType: stringToType, } } func (b *Builder) Query(pattern string, handler dns.HandlerFunc) *Builder { route, err := b.route(pattern, dns.OpcodeQuery, handler) if err != nil { panic(err.Error()) } return route } func (b *Builder) Update(pattern string, handler dns.HandlerFunc) *Builder { route, err := b.route(pattern, dns.OpcodeUpdate, handler) if err != nil { panic(err.Error()) } return route } func (b *Builder) route(pattern string, op int, handler dns.HandlerFunc) (*Builder, error) { parts := strings.Fields(pattern) domain := parts[0] _, ok := dns.IsDomainName(domain) if !ok { return nil, fmt.Errorf("%s: invalid domain: %s", dns.OpcodeToString[op], domain) } if _, ok := b.routes[domain]; !ok { b.routes[domain] = make(map[int]map[uint16]dns.Handler) } if _, ok := b.routes[domain][op]; !ok { b.routes[domain][op] = make(map[uint16]dns.Handler) } if _, ok := b.routes[domain][op][noType]; ok { return nil, fmt.Errorf("%s: a global route already exists for the domain: %s", dns.OpcodeToString[op], domain) } switch len(parts) { case 1: if len(b.routes[domain][op]) > 0 { return nil, fmt.Errorf("%s: global route and specific routes cannot be mixed for the same domain: %s", dns.OpcodeToString[op], domain) } b.routes[domain][op][noType] = handler return b, nil case 2: raw := parts[1] qType, ok := b.stringToType[raw] if !ok { return nil, fmt.Errorf("%s: unknown type: %s", dns.OpcodeToString[op], raw) } if _, ok := b.routes[domain][op][qType]; ok { return nil, fmt.Errorf("%s: duplicate route: %s", dns.OpcodeToString[op], pattern) } b.routes[domain][op][qType] = handler return b, nil default: return nil, fmt.Errorf("%s: invalid pattern: %s", dns.OpcodeToString[op], pattern) } } func (b *Builder) Build(t *testing.T, options ...Option) net.Addr { t.Helper() mux := dns.NewServeMux() server := &dns.Server{ Addr: "127.0.0.1:0", Net: "udp", ReadTimeout: time.Hour, WriteTimeout: time.Hour, Handler: mux, MsgAcceptFunc: func(dh dns.Header) dns.MsgAcceptAction { // bypass defaultMsgAcceptFunc to allow dynamic update (https://github.com/miekg/dns/pull/830) return dns.MsgAccept }, } for _, option := range options { require.NoError(t, option(server)) } for pattern, ops := range b.routes { mux.HandleFunc(pattern, func(w dns.ResponseWriter, req *dns.Msg) { mTypes, ok := ops[req.Opcode] if !ok { _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeNotImplemented)) return } if h, found := mTypes[noType]; found { h.ServeDNS(w, req) return } // For safety but it doesn't happen. if len(req.Question) == 0 { _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeRefused)) return } // For safety but it doesn't happen. if req.Question[0].Qclass != dns.ClassINET { _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeRefused)) return } // Works only for [Query]. h, ok := mTypes[req.Question[0].Qtype] if !ok { _ = w.WriteMsg(new(dns.Msg).SetRcode(req, dns.RcodeNotImplemented)) return } h.ServeDNS(w, req) }) } t.Cleanup(func() { _ = server.Shutdown() }) waitLock := sync.Mutex{} waitLock.Lock() server.NotifyStartedFunc = waitLock.Unlock go func() { err := server.ListenAndServe() if err != nil { t.Log(err) } }() waitLock.Lock() return server.PacketConn.LocalAddr() } ================================================ FILE: platform/tester/dnsmock/dnsmock_test.go ================================================ package dnsmock import ( "testing" "time" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestServer_Query_matchType(t *testing.T) { addr := NewServer(). Query("example.com. SOA", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeSuccess, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestServer_Query_noType(t *testing.T) { addr := NewServer(). Query("example.com.", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeSuccess, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestServer_Query_noMatch_domain(t *testing.T) { addr := NewServer(). Query("example.com. SOA", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.org.", dns.TypeSOA) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeRefused, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeRefused], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestServer_Query_noMatch_type(t *testing.T) { addr := NewServer(). Query("example.com. SOA", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeNotImplemented, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestServer_Query_noMatch_opType(t *testing.T) { addr := NewServer(). Query("example.com.", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetUpdate("example.com.") m.Insert([]dns.RR{ &dns.TXT{ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}, Txt: []string{"foo"}, }, }) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeNotImplemented, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestServer_Query_unknownType(t *testing.T) { assert.PanicsWithValue(t, "QUERY: unknown type: ABC", func() { NewServer(). Query("example.com. ABC", Noop). Build(t) }) } func TestServer_Query_duplicate(t *testing.T) { assert.PanicsWithValue(t, "QUERY: duplicate route: example.com. SOA", func() { NewServer(). Query("example.com. SOA", Noop). Query("example.com. SOA", Noop). Build(t) }) } func TestServer_Query_duplicateGlobal(t *testing.T) { assert.PanicsWithValue(t, "QUERY: a global route already exists for the domain: example.com.", func() { NewServer(). Query("example.com.", Noop). Query("example.com.", Noop). Build(t) }) } func TestServer_Query_mixed(t *testing.T) { assert.PanicsWithValue(t, "QUERY: global route and specific routes cannot be mixed for the same domain: example.com.", func() { NewServer(). Query("example.com. SOA", Noop). Query("example.com.", Noop). Build(t) }) } func TestServer_Query_invalidDomain(t *testing.T) { assert.PanicsWithValue(t, "QUERY: invalid domain: .example.com.", func() { NewServer(). Query(".example.com. SOA", Noop). Build(t) }) } func TestServer_Query_invalidPattern(t *testing.T) { assert.PanicsWithValue(t, "QUERY: invalid pattern: example.com. SOA 13", func() { NewServer(). Query("example.com. SOA 13", Noop). Build(t) }) } func TestServer_Update(t *testing.T) { addr := NewServer(). Update("example.com.", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetUpdate("example.com.") m.Insert([]dns.RR{ &dns.TXT{ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}, Txt: []string{"foo"}, }, }) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeSuccess, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestServer_Update_noMatch_domain(t *testing.T) { addr := NewServer(). Update("example.com.", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetUpdate("example.org.") m.Insert([]dns.RR{ &dns.TXT{ Hdr: dns.RR_Header{Name: "example.org.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}, Txt: []string{"foo"}, }, }) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeRefused, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeRefused], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestServer_Update_noMatch_opType(t *testing.T) { addr := NewServer(). Update("example.com.", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeNotImplemented, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeNotImplemented], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestServer_Update_duplicate(t *testing.T) { assert.PanicsWithValue(t, "UPDATE: a global route already exists for the domain: example.com.", func() { NewServer(). Update("example.com.", Noop). Update("example.com.", Noop). Build(t) }) } func TestServer_Update_invalidDomain(t *testing.T) { assert.PanicsWithValue(t, "UPDATE: invalid domain: .example.com.", func() { NewServer(). Update(".example.com.", Noop). Build(t) }) } func TestServer_Update_invalidPattern(t *testing.T) { assert.PanicsWithValue(t, "UPDATE: invalid pattern: example.com. SOA 13", func() { NewServer(). Update("example.com. SOA 13", Noop). Build(t) }) } ================================================ FILE: platform/tester/dnsmock/handlers.go ================================================ package dnsmock import ( "fmt" "github.com/miekg/dns" ) func DumpRequest() dns.HandlerFunc { return func(w dns.ResponseWriter, req *dns.Msg) { fmt.Println(req) Noop(w, req) } } func SOA(name string) dns.HandlerFunc { return func(w dns.ResponseWriter, req *dns.Msg) { if name == "" { name = req.Question[0].Name } // Handle TLD base := name if dns.CountLabel(req.Question[0].Name) == 1 { base = "nic." + req.Question[0].Name } answer := &dns.SOA{ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120}, Ns: "ns1." + base, Mbox: "admin." + base, Serial: 2016022801, Refresh: 28800, Retry: 7200, Expire: 2419200, Minttl: 1200, } Answer(answer)(w, req) } } func CNAME(target string) dns.HandlerFunc { return func(w dns.ResponseWriter, req *dns.Msg) { answer := &dns.CNAME{ Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 1}, Target: dns.Fqdn(target), } Answer(answer)(w, req) } } func Noop(w dns.ResponseWriter, req *dns.Msg) { _ = w.WriteMsg(new(dns.Msg).SetReply(req)) } func Error(rcode int) dns.HandlerFunc { return func(w dns.ResponseWriter, req *dns.Msg) { _ = w.WriteMsg(new(dns.Msg).SetRcode(req, rcode)) } } func Answer(answer ...dns.RR) func(w dns.ResponseWriter, req *dns.Msg) { return func(w dns.ResponseWriter, req *dns.Msg) { m := new(dns.Msg).SetReply(req) m.Answer = answer err := w.WriteMsg(m) if err != nil { panic(err.Error()) } } } ================================================ FILE: platform/tester/dnsmock/handlers_test.go ================================================ package dnsmock import ( "testing" "time" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSOA_self(t *testing.T) { addr := NewServer(). Query("example.com. SOA", SOA("")). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) expectedSOA := []dns.RR{&dns.SOA{ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 56}, Ns: "ns1.example.com.", Mbox: "admin.example.com.", Serial: 2016022801, Refresh: 28800, Retry: 7200, Expire: 2419200, Minttl: 1200, }} require.Equal(t, dns.RcodeSuccess, r.Rcode) assert.Equal(t, expectedSOA, r.Answer) assert.Equal(t, m.Question, r.Question) } func TestSOA_differentDomain(t *testing.T) { addr := NewServer(). Query("example.com. SOA", SOA("example.org.")). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeSOA) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeSuccess, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) expectedSOA := []dns.RR{&dns.SOA{ Hdr: dns.RR_Header{Name: "example.org.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 56}, Ns: "ns1.example.org.", Mbox: "admin.example.org.", Serial: 2016022801, Refresh: 28800, Retry: 7200, Expire: 2419200, Minttl: 1200, }} assert.Equal(t, expectedSOA, r.Answer) assert.Equal(t, m.Question, r.Question) } func TestSOA_tld(t *testing.T) { addr := NewServer(). Query("com. SOA", SOA("")). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("com.", dns.TypeSOA) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeSuccess, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) expectedSOA := []dns.RR{&dns.SOA{ Hdr: dns.RR_Header{Name: "com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 120, Rdlength: 48}, Ns: "ns1.nic.com.", Mbox: "admin.nic.com.", Serial: 2016022801, Refresh: 28800, Retry: 7200, Expire: 2419200, Minttl: 1200, }} assert.Equal(t, expectedSOA, r.Answer) assert.Equal(t, m.Question, r.Question) } func TestCNAME(t *testing.T) { addr := NewServer(). Query("example.com. CNAME", CNAME("example.org.")). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeSuccess, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) expectedCNAME := []dns.RR{&dns.CNAME{ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 1, Rdlength: 13}, Target: "example.org.", }} assert.Equal(t, expectedCNAME, r.Answer) assert.Equal(t, m.Question, r.Question) } func TestNoop(t *testing.T) { addr := NewServer(). Query("example.com. CNAME", Noop). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeSuccess, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeSuccess], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } func TestError(t *testing.T) { addr := NewServer(). Query("example.com. CNAME", Error(dns.RcodeNameError)). Build(t) client := &dns.Client{Timeout: 1 * time.Second} m := new(dns.Msg).SetQuestion("example.com.", dns.TypeCNAME) r, _, err := client.Exchange(m, addr.String()) require.NoError(t, err) require.Equalf(t, dns.RcodeNameError, r.Rcode, "expected %s, got %s", dns.RcodeToString[dns.RcodeNameError], dns.RcodeToString[r.Rcode]) assert.Equal(t, m.Question, r.Question) } ================================================ FILE: platform/tester/env.go ================================================ package tester import ( "fmt" "os" "slices" ) // EnvTest Environment variables manager for tests. type EnvTest struct { keys []string values map[string]string liveTestHook func() bool liveTestExtraHook func() bool domain string domainKey string } // NewEnvTest Creates an EnvTest. func NewEnvTest(keys ...string) *EnvTest { values := make(map[string]string) for _, key := range keys { value := os.Getenv(key) if value != "" { values[key] = value } } return &EnvTest{ keys: keys, values: values, } } // WithDomain Defines the name of the environment variable used to define the domain related to the DNS request. // If the domain is defined, it was considered mandatory to define a test as a "live" test. func (e *EnvTest) WithDomain(key string) *EnvTest { e.domainKey = key e.domain = os.Getenv(key) return e } // WithLiveTestRequirements Defines the environment variables required to define a test as a "live" test. // Replaces the default behavior (all keys are required). func (e *EnvTest) WithLiveTestRequirements(keys ...string) *EnvTest { var countValuedVars int for _, key := range keys { if e.domainKey != key && !e.isManagedKey(key) { panic(fmt.Sprintf("Unauthorized action, the env var %s is not managed, or it's not the key of the domain.", key)) } if e.domainKey == key { countValuedVars++ continue } if _, ok := e.values[key]; ok { countValuedVars++ } } live := countValuedVars != 0 && len(keys) == countValuedVars e.liveTestHook = func() bool { return live } return e } // WithLiveTestExtra Allows to define an additional condition to flag a test as "live" test. // This does not replace the default behavior. func (e *EnvTest) WithLiveTestExtra(extra func() bool) *EnvTest { e.liveTestExtraHook = extra return e } // GetDomain Gets the domain value associated with the DNS challenge (linked to WithDomain method). func (e *EnvTest) GetDomain() string { return e.domain } // IsLiveTest Checks whether environment variables allow running a "live" test. func (e *EnvTest) IsLiveTest() bool { liveTest := e.liveTestExtra() if e.liveTestHook != nil { return liveTest && e.liveTestHook() } liveTest = liveTest && len(e.values) == len(e.keys) if liveTest && e.domainKey != "" && e.domain == "" { return false } return liveTest } // RestoreEnv Restores the environment variables to the initial state. func (e *EnvTest) RestoreEnv() { for key, value := range e.values { os.Setenv(key, value) } } // ClearEnv Deletes all environment variables related to the test. func (e *EnvTest) ClearEnv() { for _, key := range e.keys { os.Unsetenv(key) } } // GetValue Gets the stored value of an environment variable. func (e *EnvTest) GetValue(key string) string { return e.values[key] } func (e *EnvTest) liveTestExtra() bool { if e.liveTestExtraHook == nil { return true } return e.liveTestExtraHook() } // Apply Sets/Unsets environment variables. // Not related to the main environment variables. func (e *EnvTest) Apply(envVars map[string]string) { for key, value := range envVars { if !e.isManagedKey(key) { panic(fmt.Sprintf("Unauthorized action, the env var %s is not managed.", key)) } if value == "" { os.Unsetenv(key) } else { os.Setenv(key, value) } } } func (e *EnvTest) isManagedKey(varName string) bool { return slices.Contains(e.keys, varName) } ================================================ FILE: platform/tester/env_test.go ================================================ package tester_test import ( "os" "strings" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" ) const ( envNamespace = "LEGO_TEST_" envVar01 = envNamespace + "01" envVar02 = envNamespace + "02" envVarDomain = envNamespace + "DOMAIN" ) func TestMain(m *testing.M) { exitCode := m.Run() clearEnv() os.Exit(exitCode) } func applyEnv(envVars map[string]string) { for key, value := range envVars { if value == "" { os.Unsetenv(key) } else { os.Setenv(key, value) } } } func clearEnv() { environ := os.Environ() for _, key := range environ { if strings.HasPrefix(key, envNamespace) { os.Unsetenv(strings.Split(key, "=")[0]) } } os.Unsetenv("EXTRA_LEGO_TEST") } func TestEnvTest(t *testing.T) { testCases := []struct { desc string envVars map[string]string envTestSetup func() *tester.EnvTest expected func(t *testing.T, envTest *tester.EnvTest) }{ { desc: "simple", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "missing env var", envVars: map[string]string{ envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Empty(t, envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithDomain", envVars: map[string]string{ envVar01: "A", envVar02: "B", envVarDomain: "D", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02).WithDomain(envVarDomain) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetValue(envVarDomain)) assert.Equal(t, "D", envTest.GetDomain()) }, }, { desc: "WithDomain missing env var", envVars: map[string]string{ envVar01: "A", envVarDomain: "D", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02).WithDomain(envVarDomain) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Empty(t, envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetValue(envVarDomain)) assert.Equal(t, "D", envTest.GetDomain()) }, }, { desc: "WithDomain missing domain", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02).WithDomain(envVarDomain) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetValue(envVarDomain)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02).WithLiveTestRequirements(envVar02) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements with domain as requirement", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02).WithDomain(envVarDomain).WithLiveTestRequirements(envVar02, envVarDomain) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements non required var missing", envVars: map[string]string{ envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02).WithLiveTestRequirements(envVar02) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Empty(t, envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements required var missing", envVars: map[string]string{ envVar01: "A", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02).WithLiveTestRequirements(envVar02) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Empty(t, envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements WithDomain", envVars: map[string]string{ envVar01: "A", envVar02: "B", envVarDomain: "D", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02). WithDomain(envVarDomain). WithLiveTestRequirements(envVar02) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetValue(envVarDomain)) assert.Equal(t, "D", envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements WithDomain without domain", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02). WithDomain(envVarDomain). WithLiveTestRequirements(envVar02) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetValue(envVarDomain)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestExtra true", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02). WithLiveTestExtra(func() bool { return true }) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestExtra false", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02). WithLiveTestExtra(func() bool { return false }) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements WithLiveTestExtra true", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02). WithLiveTestRequirements(envVar02). WithLiveTestExtra(func() bool { return true }) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.True(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements WithLiveTestExtra false", envVars: map[string]string{ envVar01: "A", envVar02: "B", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02). WithLiveTestRequirements(envVar02). WithLiveTestExtra(func() bool { return false }) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Equal(t, "B", envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, { desc: "WithLiveTestRequirements require env var missing WithLiveTestExtra true", envVars: map[string]string{ envVar01: "A", }, envTestSetup: func() *tester.EnvTest { return tester.NewEnvTest(envVar01, envVar02). WithLiveTestRequirements(envVar02). WithLiveTestExtra(func() bool { return true }) }, expected: func(t *testing.T, envTest *tester.EnvTest) { assert.False(t, envTest.IsLiveTest()) assert.Equal(t, "A", envTest.GetValue(envVar01)) assert.Empty(t, envTest.GetValue(envVar02)) assert.Empty(t, envTest.GetDomain()) }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer clearEnv() applyEnv(test.envVars) envTest := test.envTestSetup() test.expected(t, envTest) }) } } func TestEnvTest_RestoreEnv(t *testing.T) { os.Setenv(envVar01, "A") os.Setenv(envVar02, "B") envTest := tester.NewEnvTest(envVar01, envVar02) clearEnv() envTest.RestoreEnv() assert.Equal(t, "A", os.Getenv(envVar01)) assert.Equal(t, "B", os.Getenv(envVar02)) } func TestEnvTest_ClearEnv(t *testing.T) { os.Setenv(envVar01, "A") os.Setenv(envVar02, "B") os.Setenv("EXTRA_LEGO_TEST", "X") envTest := tester.NewEnvTest(envVar01, envVar02) envTest.ClearEnv() assert.Empty(t, os.Getenv(envVar01)) assert.Empty(t, os.Getenv(envVar02)) assert.Equal(t, "X", os.Getenv("EXTRA_LEGO_TEST")) } ================================================ FILE: platform/tester/servermock/builder.go ================================================ package servermock import ( "net/http" "net/http/httptest" "slices" "testing" "github.com/stretchr/testify/require" ) // Link represents a middleware interface, enabling middleware chaining. type Link interface { Bind(next http.Handler) http.Handler } // LinkFunc defines a function type [Link]. type LinkFunc func(next http.Handler) http.Handler func (f LinkFunc) Bind(next http.Handler) http.Handler { return f(next) } // ClientBuilder defines a function type for creating a client of type T based on a httptest.Server instance. type ClientBuilder[T any] func(server *httptest.Server) (T, error) // Builder is a type that facilitates the construction of testable HTTP clients and server. // It allows defining routes, attaching middleware, and creating custom HTTP clients. type Builder[T any] struct { mux *http.ServeMux chain []Link clientBuilder ClientBuilder[T] } func NewBuilder[T any](clientBuilder ClientBuilder[T], chain ...Link) *Builder[T] { return &Builder[T]{ mux: http.NewServeMux(), chain: chain, clientBuilder: clientBuilder, } } func (b *Builder[T]) Route(pattern string, handler http.Handler, chain ...Link) *Builder[T] { if handler == nil { handler = Noop() } for _, link := range slices.Backward(b.chain) { handler = link.Bind(handler) } for _, link := range slices.Backward(chain) { handler = link.Bind(handler) } b.mux.Handle(pattern, handler) return b } func (b *Builder[T]) Build(t *testing.T) T { t.Helper() server := httptest.NewServer(b.mux) t.Cleanup(server.Close) client, err := b.clientBuilder(server) require.NoError(t, err) return client } func (b *Builder[T]) BuildHTTPS(t *testing.T) T { t.Helper() server := httptest.NewTLSServer(b.mux) t.Cleanup(server.Close) client, err := b.clientBuilder(server) require.NoError(t, err) return client } ================================================ FILE: platform/tester/servermock/handler_dump.go ================================================ package servermock import ( "fmt" "net/http" "net/http/httputil" ) // DumpRequest logs the full HTTP request to the console, including the body if present. func DumpRequest() http.HandlerFunc { return func(rw http.ResponseWriter, req *http.Request) { dump, err := httputil.DumpRequest(req, true) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } fmt.Println(string(dump)) } } ================================================ FILE: platform/tester/servermock/handler_file.go ================================================ package servermock import ( "io" "net/http" "os" "path/filepath" "slices" ) // ResponseFromFileHandler handles HTTP responses using the content of a file. type ResponseFromFileHandler struct { statusCode int headers http.Header filename string } // ResponseFromFile creates a [ResponseFromFileHandler] using a filename. func ResponseFromFile(filename string) *ResponseFromFileHandler { return &ResponseFromFileHandler{ statusCode: http.StatusOK, headers: http.Header{}, filename: filename, } } // ResponseFromFixture creates a [ResponseFromFileHandler] using a filename from the `fixtures` directory. func ResponseFromFixture(filename string) *ResponseFromFileHandler { return ResponseFromFile(filepath.Join("fixtures", filename)) } // ResponseFromInternal creates a [ResponseFromFileHandler] using a filename from the `internal/fixtures` directory. func ResponseFromInternal(filename string) *ResponseFromFileHandler { return ResponseFromFile(filepath.Join("internal", "fixtures", filename)) } func (h *ResponseFromFileHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { for k, values := range h.headers { for _, v := range values { rw.Header().Add(k, v) } } if h.filename == "" { rw.WriteHeader(h.statusCode) return } if filepath.Ext(h.filename) == ".json" { rw.Header().Set(contentTypeHeader, applicationJSONMimeType) } file, err := os.Open(h.filename) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } defer func() { _ = file.Close() }() rw.WriteHeader(h.statusCode) _, err = io.Copy(rw, file) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } } func (h *ResponseFromFileHandler) WithStatusCode(status int) *ResponseFromFileHandler { if h.statusCode >= http.StatusContinue { h.statusCode = status } return h } func (h *ResponseFromFileHandler) WithHeader(name, value string, values ...string) *ResponseFromFileHandler { for _, v := range slices.Concat([]string{value}, values) { h.headers.Add(name, v) } return h } ================================================ FILE: platform/tester/servermock/handler_json.go ================================================ package servermock import ( "encoding/json" "net/http" ) // JSONEncodeHandler is a handler that encodes data into JSON and writes it to an HTTP response. type JSONEncodeHandler struct { data any statusCode int } func JSONEncode(data any) *JSONEncodeHandler { return &JSONEncodeHandler{ data: data, statusCode: http.StatusOK, } } func (h *JSONEncodeHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { rw.Header().Set(contentTypeHeader, applicationJSONMimeType) rw.WriteHeader(h.statusCode) err := json.NewEncoder(rw).Encode(h.data) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } } func (h *JSONEncodeHandler) WithStatusCode(status int) *JSONEncodeHandler { if h.statusCode >= http.StatusContinue { h.statusCode = status } return h } ================================================ FILE: platform/tester/servermock/handler_noop.go ================================================ package servermock import ( "net/http" "slices" ) // NoopHandler is a simple HTTP handler that responds without processing requests. type NoopHandler struct { statusCode int headers http.Header } func Noop() *NoopHandler { return &NoopHandler{ statusCode: http.StatusOK, headers: http.Header{}, } } func (h *NoopHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { for k, values := range h.headers { for _, v := range values { rw.Header().Add(k, v) } } rw.WriteHeader(h.statusCode) } func (h *NoopHandler) WithStatusCode(status int) *NoopHandler { if h.statusCode >= http.StatusContinue { h.statusCode = status } return h } func (h *NoopHandler) WithHeader(name, value string, values ...string) *NoopHandler { for _, v := range slices.Concat([]string{value}, values) { h.headers.Add(name, v) } return h } ================================================ FILE: platform/tester/servermock/handler_raw.go ================================================ package servermock import ( "net/http" "slices" ) // RawResponseHandler is a custom HTTP handler that serves raw response data. type RawResponseHandler struct { statusCode int headers http.Header data []byte } func RawResponse(data []byte) *RawResponseHandler { return &RawResponseHandler{ statusCode: http.StatusOK, headers: http.Header{}, data: data, } } func RawStringResponse(data string) *RawResponseHandler { return RawResponse([]byte(data)) } func (h *RawResponseHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { for k, values := range h.headers { for _, v := range values { rw.Header().Add(k, v) } } rw.WriteHeader(h.statusCode) if len(h.data) == 0 { return } _, err := rw.Write(h.data) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } } func (h *RawResponseHandler) WithStatusCode(status int) *RawResponseHandler { if h.statusCode >= http.StatusContinue { h.statusCode = status } return h } func (h *RawResponseHandler) WithHeader(name, value string, values ...string) *RawResponseHandler { for _, v := range slices.Concat([]string{value}, values) { h.headers.Add(name, v) } return h } ================================================ FILE: platform/tester/servermock/link_form.go ================================================ package servermock import ( "fmt" "net/http" "net/url" "regexp" "slices" ) // FormLink is a type used for validating and processing form data in HTTP requests. // It supports strict validation, predefined values, and regex-based checks to ensure form compliance. type FormLink struct { values url.Values regexes map[string]*regexp.Regexp strict bool usePostForm bool statusCode int } func CheckForm() *FormLink { return &FormLink{ values: url.Values{}, regexes: map[string]*regexp.Regexp{}, statusCode: http.StatusBadRequest, } } func (l *FormLink) Bind(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { err := req.ParseForm() if err != nil { http.Error(rw, err.Error(), l.statusCode) return } form := req.Form if l.usePostForm { form = req.PostForm } if l.strict { if len(form) != len(l.values)+len(l.regexes) { msg := fmt.Sprintf("invalid query parameters, got %v, want %v", req.Form, l.values) http.Error(rw, msg, l.statusCode) return } } for k, v := range l.values { value := form[k] if !slices.Equal(v, value) { msg := fmt.Sprintf("invalid %q form value, got %q, want %q", k, value, v) http.Error(rw, msg, l.statusCode) return } } for k, exp := range l.regexes { value := form.Get(k) if !exp.MatchString(value) { msg := fmt.Sprintf("invalid %q form value, %q doesn't match to %q", k, value, exp) http.Error(rw, msg, l.statusCode) return } } next.ServeHTTP(rw, req) }) } func (l *FormLink) Strict() *FormLink { l.strict = true return l } func (l *FormLink) UsePostForm() *FormLink { l.usePostForm = true return l } func (l *FormLink) With(name, value string) *FormLink { l.values.Set(name, value) return l } func (l *FormLink) WithRegexp(name, exp string) *FormLink { l.regexes[name] = regexp.MustCompile(exp) return l } ================================================ FILE: platform/tester/servermock/link_headers.go ================================================ package servermock import ( "fmt" "net/http" "regexp" "slices" ) const ( authorizationHeader = "Authorization" contentTypeHeader = "Content-Type" acceptHeader = "Accept" ) const ( applicationJSONMimeType = "application/json" applicationFormMimeType = "application/x-www-form-urlencoded" ) type basicAuth struct { username, password string } // HeaderLink validates HTTP request headers. type HeaderLink struct { values http.Header regexes map[string]*regexp.Regexp json bool basicAuth *basicAuth statusCode int } func CheckHeader() *HeaderLink { return &HeaderLink{ values: http.Header{}, regexes: map[string]*regexp.Regexp{}, statusCode: http.StatusBadRequest, } } func (l *HeaderLink) Bind(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { for k, v := range l.values { err := checkHeader(req, k, v) if err != nil { http.Error(rw, err.Error(), l.statusCode) return } } for k, exp := range l.regexes { value := req.Header.Get(k) if !exp.MatchString(value) { msg := fmt.Sprintf("invalid %q header value, %q doesn't match to %q", k, value, exp) http.Error(rw, msg, l.statusCode) return } } if l.json && !l.checkJSONHeaders(rw, req) { return } if l.basicAuth != nil && !l.checkBasicAuth(rw, req) { return } next.ServeHTTP(rw, req) }) } func (l *HeaderLink) With(name, value string, values ...string) *HeaderLink { for _, v := range slices.Concat([]string{value}, values) { l.values.Add(name, v) } return l } func (l *HeaderLink) WithRegexp(name, exp string) *HeaderLink { l.regexes[name] = regexp.MustCompile(exp) return l } func (l *HeaderLink) WithJSONHeaders() *HeaderLink { l.json = true return l } func (l *HeaderLink) WithContentTypeFromURLEncoded() *HeaderLink { l.values.Set(contentTypeHeader, applicationFormMimeType) return l } func (l *HeaderLink) WithContentType(value string) *HeaderLink { l.values.Set(contentTypeHeader, value) return l } func (l *HeaderLink) WithAccept(value string) *HeaderLink { l.values.Set(acceptHeader, value) return l } func (l *HeaderLink) WithAuthorization(value string) *HeaderLink { l.values.Set(authorizationHeader, value) return l } func (l *HeaderLink) WithStatusCode(status int) *HeaderLink { if l.statusCode >= http.StatusContinue { l.statusCode = status } return l } func (l *HeaderLink) WithBasicAuth(username, password string) *HeaderLink { l.basicAuth = &basicAuth{username: username, password: password} return l } func (l *HeaderLink) checkBasicAuth(rw http.ResponseWriter, req *http.Request) bool { usr, pwd, ok := req.BasicAuth() if !ok { http.Error(rw, "missing Basic auth", l.statusCode) return false } if usr != l.basicAuth.username || pwd != l.basicAuth.password { msg := fmt.Sprintf("invalid credentials: got [username: %q, password: %q], want [username: %q, password: %q]", usr, pwd, l.basicAuth.username, l.basicAuth.password) http.Error(rw, msg, l.statusCode) return false } return true } func (l *HeaderLink) checkJSONHeaders(rw http.ResponseWriter, req *http.Request) bool { err := checkHeader(req, acceptHeader, []string{applicationJSONMimeType}) if err != nil { http.Error(rw, err.Error(), l.statusCode) return false } if req.ContentLength > 0 { err = checkHeader(req, contentTypeHeader, []string{applicationJSONMimeType}) if err != nil { http.Error(rw, err.Error(), l.statusCode) return false } } return true } func checkHeader(req *http.Request, k string, v []string) error { if !slices.Equal(req.Header[k], v) { return fmt.Errorf("invalid %q header value, got %q, want %q", k, req.Header[k], v) } return nil } ================================================ FILE: platform/tester/servermock/link_query.go ================================================ package servermock import ( "fmt" "net/http" "net/url" "regexp" ) // QueryParameterLink validates query parameters in HTTP requests. // The strict flag enforces exact matches with specified query parameters. type QueryParameterLink struct { values map[string]string regexes map[string]*regexp.Regexp strict bool statusCode int } func CheckQueryParameter() *QueryParameterLink { return &QueryParameterLink{ values: map[string]string{}, regexes: map[string]*regexp.Regexp{}, statusCode: http.StatusBadRequest, } } func (l *QueryParameterLink) Bind(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { query := req.URL.Query() if l.strict { if len(query) != len(l.values)+len(l.regexes) { msg := fmt.Sprintf("invalid query parameters, got %v, want %v", query, l.values) http.Error(rw, msg, l.statusCode) return } } for k, v := range l.values { p := query.Get(k) if p != v { msg := fmt.Sprintf("invalid %q query parameter value, got %q, want %q", k, p, v) http.Error(rw, msg, l.statusCode) return } } for k, exp := range l.regexes { value := query.Get(k) if !exp.MatchString(value) { msg := fmt.Sprintf("invalid %q query parameter value, %q doesn't match to %q", k, value, exp) http.Error(rw, msg, l.statusCode) return } } next.ServeHTTP(rw, req) }) } func (l *QueryParameterLink) Strict() *QueryParameterLink { l.strict = true return l } func (l *QueryParameterLink) With(name, value string) *QueryParameterLink { l.values[name] = value return l } func (l *QueryParameterLink) WithRegexp(name, exp string) *QueryParameterLink { l.regexes[name] = regexp.MustCompile(exp) return l } func (l *QueryParameterLink) WithValues(values url.Values) *QueryParameterLink { for k, v := range values { if len(v) != 1 { continue } l.values[k] = v[0] } return l } func (l *QueryParameterLink) WithStatusCode(status int) *QueryParameterLink { if l.statusCode >= http.StatusContinue { l.statusCode = status } return l } ================================================ FILE: platform/tester/servermock/link_request_body.go ================================================ package servermock import ( "bytes" "fmt" "io" "net/http" "os" "path/filepath" "slices" ) // RequestBodyLink represents a handler utility to validate HTTP request bodies against a predefined byte slice. type RequestBodyLink struct { body []byte filename string ignoreWhitespace bool } // CheckRequestBody creates a [RequestBodyLink] initialized with the provided request body string. func CheckRequestBody(body string) *RequestBodyLink { return &RequestBodyLink{body: []byte(body)} } // CheckRequestBodyFromFile creates a [RequestBodyLink] initialized with the provided request body file. func CheckRequestBodyFromFile(filename string) *RequestBodyLink { return &RequestBodyLink{filename: filename} } // CheckRequestBodyFromFixture creates a [RequestBodyLink] initialized with the provided request body file from the `fixtures` directory. func CheckRequestBodyFromFixture(filename string) *RequestBodyLink { return CheckRequestBodyFromFile(filepath.Join("fixtures", filename)) } // CheckRequestBodyFromInternal creates a [RequestBodyLink] initialized with the provided request body file from the `internal/fixtures directory. func CheckRequestBodyFromInternal(filename string) *RequestBodyLink { return CheckRequestBodyFromFile(filepath.Join("internal", "fixtures", filename)) } func (l *RequestBodyLink) Bind(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { if req.ContentLength == 0 { http.Error(rw, fmt.Sprintf("%s: empty request body", req.URL.Path), http.StatusBadRequest) return } body, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } _ = req.Body.Close() expectedRaw := slices.Clone(l.body) if l.filename != "" { expectedRaw, err = os.ReadFile(l.filename) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } } if len(expectedRaw) == 0 { http.Error(rw, fmt.Sprintf("%s: empty expected request body", req.URL.Path), http.StatusBadRequest) return } if l.ignoreWhitespace { body = trimLineSpace(body) expectedRaw = trimLineSpace(expectedRaw) } if !bytes.Equal(bytes.TrimSpace(expectedRaw), bytes.TrimSpace(body)) { msg := fmt.Sprintf("%s: request body differences: got: %s, want: %s", req.URL.Path, string(bytes.TrimSpace(body)), string(bytes.TrimSpace(expectedRaw))) http.Error(rw, msg, http.StatusBadRequest) return } next.ServeHTTP(rw, req) }) } func (l *RequestBodyLink) IgnoreWhitespace() *RequestBodyLink { l.ignoreWhitespace = true return l } func trimLineSpace(body []byte) []byte { buf := bytes.NewBuffer(nil) for line := range bytes.Lines(body) { buf.Write(bytes.TrimSpace(line)) } return buf.Bytes() } ================================================ FILE: platform/tester/servermock/link_request_body_json.go ================================================ package servermock import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "slices" "github.com/google/go-cmp/cmp" ) // RequestBodyJSONLink validates JSON request bodies. type RequestBodyJSONLink struct { body []byte filename string data any } // CheckRequestJSONBody creates a [RequestBodyJSONLink] initialized with a string. func CheckRequestJSONBody(body string) *RequestBodyJSONLink { return &RequestBodyJSONLink{body: []byte(body)} } // CheckRequestJSONBodyFromStruct creates a [RequestBodyJSONLink] initialized with a struct. func CheckRequestJSONBodyFromStruct(data any) *RequestBodyJSONLink { return &RequestBodyJSONLink{data: data} } // CheckRequestJSONBodyFromFile creates a [RequestBodyJSONLink] initialized with the provided request body file. func CheckRequestJSONBodyFromFile(filename string) *RequestBodyJSONLink { return &RequestBodyJSONLink{ filename: filename, } } // CheckRequestJSONBodyFromFixture creates a [RequestBodyJSONLink] initialized with the provided request body file from the `fixtures` directory. func CheckRequestJSONBodyFromFixture(filename string) *RequestBodyJSONLink { return CheckRequestJSONBodyFromFile(filepath.Join("fixtures", filename)) } // CheckRequestJSONBodyFromInternal creates a [RequestBodyJSONLink] initialized with the provided request body file from the `internal/fixtures` directory. func CheckRequestJSONBodyFromInternal(filename string) *RequestBodyJSONLink { return CheckRequestJSONBodyFromFile(filepath.Join("internal", "fixtures", filename)) } func (l *RequestBodyJSONLink) Bind(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { if req.ContentLength == 0 { http.Error(rw, fmt.Sprintf("%s: empty request body", req.URL.Path), http.StatusBadRequest) return } body, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } _ = req.Body.Close() var expected, actual any expectedRaw := slices.Clone(l.body) switch { case l.filename != "": expectedRaw, err = os.ReadFile(l.filename) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } case l.data != nil: expectedRaw, err = json.Marshal(l.data) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } } if len(expectedRaw) == 0 { http.Error(rw, fmt.Sprintf("%s: empty expected request body", req.URL.Path), http.StatusBadRequest) return } err = json.Unmarshal(expectedRaw, &expected) if err != nil { msg := fmt.Sprintf("%s: the expected request body is not valid JSON: %v", req.URL.Path, err) http.Error(rw, msg, http.StatusBadRequest) return } err = json.Unmarshal(body, &actual) if err != nil { msg := fmt.Sprintf("%s: request body is not valid JSON: %v", req.URL.Path, err) http.Error(rw, msg, http.StatusBadRequest) return } if !cmp.Equal(actual, expected) { msg := fmt.Sprintf("%s: request body differences: %s", req.URL.Path, cmp.Diff(actual, expected)) http.Error(rw, msg, http.StatusBadRequest) return } next.ServeHTTP(rw, req) }) } ================================================ FILE: platform/wait/wait.go ================================================ package wait import ( "context" "fmt" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/log" ) // For polls the given function 'f', once every 'interval', up to 'timeout'. func For(msg string, timeout, interval time.Duration, f func() (bool, error)) error { log.Infof("Wait for %s [timeout: %s, interval: %s]", msg, timeout, interval) var lastErr error timeUp := time.After(timeout) for { select { case <-timeUp: if lastErr == nil { return fmt.Errorf("%s: time limit exceeded", msg) } return fmt.Errorf("%s: time limit exceeded: last error: %w", msg, lastErr) default: } stop, err := f() if stop { return err } if err != nil { lastErr = err } time.Sleep(interval) } } // Retry retries the given operation until it succeeds or the context is canceled. // Similar to [backoff.Retry] but with a different signature. func Retry(ctx context.Context, operation func() error, opts ...backoff.RetryOption) error { _, err := backoff.Retry(ctx, func() (any, error) { return nil, operation() }, opts...) return err } ================================================ FILE: platform/wait/wait_test.go ================================================ package wait import ( "errors" "sync/atomic" "testing" "time" "github.com/stretchr/testify/require" ) // TODO(ldez): rewrite those tests when upgrading to go1.25 as minimum Go version. func TestFor_timeout(t *testing.T) { var io atomic.Int64 c := make(chan error) go func() { c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) { io.Add(1) if io.Load() == 1 { return false, nil } return false, nil }) }() timeout := time.After(6 * time.Second) select { case <-timeout: t.Fatal("timeout exceeded") case err := <-c: require.EqualError(t, err, "test: time limit exceeded") } require.EqualValues(t, 3, io.Load()) } func TestFor_timeout_with_error(t *testing.T) { var io atomic.Int64 c := make(chan error) go func() { c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) { io.Add(1) // This allows be sure that the latest previous error is returned. if io.Load() == 1 { return false, errors.New("oops") } return false, nil }) }() timeout := time.After(6 * time.Second) select { case <-timeout: t.Fatal("timeout exceeded") case err := <-c: require.EqualError(t, err, "test: time limit exceeded: last error: oops") } require.EqualValues(t, 3, io.Load()) } func TestFor_stop(t *testing.T) { var io atomic.Int64 c := make(chan error) go func() { c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) { io.Add(1) return true, nil }) }() timeout := time.After(6 * time.Second) select { case <-timeout: t.Fatal("timeout exceeded") case err := <-c: require.NoError(t, err) } require.EqualValues(t, 1, io.Load()) } func TestFor_stop_with_error(t *testing.T) { var io atomic.Int64 c := make(chan error) go func() { c <- For("test", 3*time.Second, 1*time.Second, func() (bool, error) { io.Add(1) return true, errors.New("oops") }) }() timeout := time.After(6 * time.Second) select { case <-timeout: t.Fatal("timeout exceeded") case err := <-c: require.EqualError(t, err, "oops") } require.EqualValues(t, 1, io.Load()) } ================================================ FILE: providers/dns/acmedns/acmedns.go ================================================ // Package acmedns implements a DNS provider for solving DNS-01 challenges using Joohoi's acme-dns project. // For more information see the ACME-DNS homepage: https://github.com/joohoi/acme-dns package acmedns import ( "context" "errors" "fmt" "strings" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/acmedns/internal" "github.com/nrdcg/goacmedns" "github.com/nrdcg/goacmedns/storage" ) const ( // envNamespace is the prefix for ACME-DNS environment variables. envNamespace = "ACME_DNS_" // EnvAPIBase is the environment variable name for the ACME-DNS API address. // (e.g. https://acmedns.your-domain.com). EnvAPIBase = envNamespace + "API_BASE" // EnvAllowList are source networks using CIDR notation, // e.g. "192.168.100.1/24,1.2.3.4/32,2002:c0a8:2a00::0/40". EnvAllowList = envNamespace + "ALLOWLIST" // EnvStoragePath is the environment variable name for the ACME-DNS JSON account data file. // A per-domain account will be registered/persisted to this file and used for TXT updates. EnvStoragePath = envNamespace + "STORAGE_PATH" // EnvStorageBaseURL is the environment variable name for the ACME-DNS JSON account data. // The URL to the storage server. EnvStorageBaseURL = envNamespace + "STORAGE_BASE_URL" ) var _ challenge.Provider = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIBase string AllowList []string StoragePath string StorageBaseURL string } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{} } // acmeDNSClient is an interface describing the goacmedns.Client functions the DNSProvider uses. // It makes it easier for tests to shim a mock Client into the DNSProvider. type acmeDNSClient interface { // UpdateTXTRecord updates the provided account's TXT record // to the given value or returns an error. UpdateTXTRecord(ctx context.Context, account goacmedns.Account, value string) error // RegisterAccount registers and returns a new account // with the given allowFrom restriction or returns an error. RegisterAccount(ctx context.Context, allowFrom []string) (goacmedns.Account, error) } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client acmeDNSClient storage goacmedns.Storage } // NewDNSProvider returns a DNSProvider instance configured for Joohoi's acme-dns. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIBase) if err != nil { return nil, fmt.Errorf("acme-dns: %w", err) } config := NewDefaultConfig() config.APIBase = values[EnvAPIBase] config.StoragePath = env.GetOrFile(EnvStoragePath) config.StorageBaseURL = env.GetOrFile(EnvStorageBaseURL) allowList := env.GetOrFile(EnvAllowList) if allowList != "" { config.AllowList = strings.Split(allowList, ",") } return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Joohoi's acme-dns. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("acme-dns: the configuration of the DNS provider is nil") } st, err := getStorage(config) if err != nil { return nil, fmt.Errorf("acme-dns: %w", err) } client, err := goacmedns.NewClient(config.APIBase) if err != nil { return nil, fmt.Errorf("acme-dns: new client: %w", err) } return &DNSProvider{ config: config, client: client, storage: st, }, nil } // NewDNSProviderClient creates an ACME-DNS DNSProvider with the given acmeDNSClient and [goacmedns.Storage]. // // Deprecated: use [NewDNSProviderConfig] instead. func NewDNSProviderClient(client acmeDNSClient, store goacmedns.Storage) (*DNSProvider, error) { if client == nil { return nil, errors.New("acme-dns: Client must be not nil") } if store == nil { return nil, errors.New("acme-dns: Storage must be not nil") } return &DNSProvider{ config: NewDefaultConfig(), client: client, storage: store, }, nil } // ErrCNAMERequired is returned by Present when the Domain indicated had no // existing ACME-DNS account in the Storage and additional setup is required. // The user must create a CNAME in the DNS zone for Domain that aliases FQDN // to Target in order to complete setup for the ACME-DNS account that was created. type ErrCNAMERequired struct { // The Domain that is being issued for. Domain string // The alias of the CNAME (left hand DNS label). FQDN string // The RDATA of the CNAME (right hand side, canonical name). Target string } // Error returns a descriptive message for the ErrCNAMERequired instance telling // the user that a CNAME needs to be added to the DNS zone of c.Domain before // the ACME-DNS hook will work. // The CNAME to be created should be of the form: {{ c.FQDN }} CNAME {{ c.Target }}. func (e ErrCNAMERequired) Error() string { return fmt.Sprintf("acme-dns: new account created for %q. "+ "To complete setup for %q you must provision the following "+ "CNAME in your DNS zone and re-run this provider when it is "+ "in place:\n"+ "%s CNAME %s.", e.Domain, e.Domain, e.FQDN, e.Target) } // Present creates a TXT record to fulfill the DNS-01 challenge. // If there is an existing account for the domain in the provider's storage // then it will be used to set the challenge response TXT record with the ACME-DNS server and issuance will continue. // If there is not an account for the given domain present in the DNSProvider storage // one will be created and registered with the ACME DNS server and an ErrCNAMERequired error is returned. // This will halt issuance and indicate to the user that a one-time manual setup is required for the domain. func (d *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() // Compute the challenge response FQDN and TXT value for the domain based on the keyAuth. info := dns01.GetChallengeInfo(domain, keyAuth) // Check if credentials were previously saved for this domain. account, err := d.storage.Fetch(ctx, domain) if err != nil { if !errors.Is(err, storage.ErrDomainNotFound) { return err } // The account did not exist. // Create a new one and return an error indicating the required one-time manual CNAME setup. account, err = d.register(ctx, domain, info.FQDN) if err != nil { return err } } // Update the acme-dns TXT record. return d.client.UpdateTXTRecord(ctx, account, info.Value) } // CleanUp removes the record matching the specified parameters. It is not // implemented for the ACME-DNS provider. func (d *DNSProvider) CleanUp(_, _, _ string) error { // ACME-DNS doesn't support the notion of removing a record. // For users of ACME-DNS it is expected the stale records remain in-place. return nil } // register creates a new ACME-DNS account for the given domain. // If account creation works as expected a ErrCNAMERequired error is returned describing // the one-time manual CNAME setup required to complete setup of the ACME-DNS hook for the domain. // If any other error occurs it is returned as-is. func (d *DNSProvider) register(ctx context.Context, domain, fqdn string) (goacmedns.Account, error) { newAcct, err := d.client.RegisterAccount(ctx, d.config.AllowList) if err != nil { return goacmedns.Account{}, err } var cnameCreated bool // Store the new account in the storage and call save to persist the data. err = d.storage.Put(ctx, domain, newAcct) if err != nil { cnameCreated = errors.Is(err, internal.ErrCNAMEAlreadyCreated) if !cnameCreated { return goacmedns.Account{}, err } } err = d.storage.Save(ctx) if err != nil { return goacmedns.Account{}, err } if cnameCreated { return newAcct, nil } // Stop issuance by returning an error. // The user needs to perform a manual one-time CNAME setup in their DNS zone // to complete the setup of the new account we created. return goacmedns.Account{}, ErrCNAMERequired{ Domain: domain, FQDN: fqdn, Target: newAcct.FullDomain, } } func getStorage(config *Config) (goacmedns.Storage, error) { if config.StoragePath == "" && config.StorageBaseURL == "" { return nil, errors.New("storagePath or storageBaseURL is not set") } if config.StoragePath != "" && config.StorageBaseURL != "" { return nil, errors.New("storagePath and storageBaseURL cannot be used at the same time") } if config.StoragePath != "" { return storage.NewFile(config.StoragePath, 0o600), nil } st, err := internal.NewHTTPStorage(config.StorageBaseURL) if err != nil { return nil, fmt.Errorf("new HTTP storage: %w", err) } return st, nil } ================================================ FILE: providers/dns/acmedns/acmedns.toml ================================================ Name = "Joohoi's ACME-DNS" Description = '''''' URL = "https://github.com/joohoi/acme-dns" Code = "acme-dns" Aliases = ["acmedns"] # TODO(ldez): remove "-" in v5 Since = "v1.1.0" Example = ''' ACME_DNS_API_BASE=http://10.0.0.8:4443 \ ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \ lego --dns "acme-dns" -d '*.example.com' -d example.com run # or ACME_DNS_API_BASE=http://10.0.0.8:4443 \ ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \ lego --dns "acme-dns" -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ACME_DNS_API_BASE = "The ACME-DNS API address" ACME_DNS_STORAGE_PATH = "The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates." ACME_DNS_STORAGE_BASE_URL = "The ACME-DNS JSON account data server." [Configuration.Additional] ACME_DNS_ALLOWLIST = "Source networks using CIDR notation (multiple values should be separated with a comma)." [Links] API = "https://github.com/joohoi/acme-dns#api" GoClient = "https://github.com/nrdcg/goacmedns" ================================================ FILE: providers/dns/acmedns/acmedns_test.go ================================================ package acmedns import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/nrdcg/goacmedns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( egDomain = "example.com" egFQDN = "_acme-challenge." + egDomain + "." egKeyAuth = "⚷" ) func TestPresent(t *testing.T) { // validAccountStorage is a mockStorage configured to return the egTestAccount. validAccountStorage := newMockStorage().WithAccount(egDomain, egTestAccount) // validUpdateClient is a mockClient configured with the egTestAccount that will track TXT updates in a map. validUpdateClient := newMockClient() testCases := []struct { Name string Client acmeDNSClient Storage goacmedns.Storage ExpectedError error }{ { Name: "present when client storage returns unexpected error", Client: newMockClient().WithRegisterAccount(egTestAccount), Storage: newMockStorage().WithFetchError(errorStorageErr), ExpectedError: errorStorageErr, }, { Name: "present when client storage returns ErrDomainNotFound", Client: newMockClient().WithRegisterAccount(egTestAccount), ExpectedError: ErrCNAMERequired{ Domain: egDomain, FQDN: egFQDN, Target: egTestAccount.FullDomain, }, }, { Name: "present when client UpdateTXTRecord returns unexpected error", Client: newMockClient().WithUpdateTXTRecordError(errorClientErr), Storage: validAccountStorage, ExpectedError: errorClientErr, }, { Name: "present when everything works", Storage: validAccountStorage, Client: validUpdateClient, }, } for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { p := &DNSProvider{ config: NewDefaultConfig(), client: test.Client, storage: newMockStorage(), } if test.Storage != nil { p.storage = test.Storage } err := p.Present(egDomain, "foo", egKeyAuth) if test.ExpectedError != nil { assert.Equal(t, test.ExpectedError, err) } else { require.NoError(t, err) } }) } // Check that the success test case set a record. assert.Len(t, validUpdateClient.records, 1) // Check that the success test case set the right record for the right account. assert.Len(t, validUpdateClient.records[egTestAccount], 43) } func TestRegister(t *testing.T) { testCases := []struct { Name string Client acmeDNSClient Storage goacmedns.Storage ExpectedError error }{ { Name: "register when acme-dns client returns an error", Client: newMockClient().WithRegisterAccountError(errorClientErr), ExpectedError: errorClientErr, }, { Name: "register when acme-dns storage put returns an error", Client: newMockClient().WithRegisterAccount(egTestAccount), Storage: newMockStorage().WithPutError(errorStorageErr), ExpectedError: errorStorageErr, }, { Name: "register when acme-dns storage save returns an error", Client: newMockClient().WithRegisterAccount(egTestAccount), Storage: newMockStorage().WithSaveError(errorStorageErr), ExpectedError: errorStorageErr, }, { Name: "register when everything works", Client: newMockClient().WithRegisterAccount(egTestAccount), ExpectedError: ErrCNAMERequired{ Domain: egDomain, FQDN: egFQDN, Target: egTestAccount.FullDomain, }, }, } for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { p := &DNSProvider{ config: NewDefaultConfig(), client: test.Client, storage: newMockStorage(), } if test.Storage != nil { p.storage = test.Storage } acc, err := p.register(t.Context(), egDomain, egFQDN) if test.ExpectedError != nil { assert.Equal(t, test.ExpectedError, err) } else { assert.Equal(t, goacmedns.Account{}, acc) require.NoError(t, err) } }) } } func TestPresent_httpStorage(t *testing.T) { testCases := []struct { desc string StatusCode int ExpectedError error }{ { desc: "the CNAME is not handled by the storage", StatusCode: http.StatusOK, ExpectedError: ErrCNAMERequired{ Domain: egDomain, FQDN: egFQDN, Target: egTestAccount.FullDomain, }, }, { desc: "the CNAME is handled by the storage", StatusCode: http.StatusCreated, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { provider := servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.StorageBaseURL = server.URL return NewDNSProviderConfig(config) }). // Fetch Route("GET /example.com", servermock.Noop().WithStatusCode(http.StatusNotFound)). // Put Route("POST /example.com", servermock.Noop().WithStatusCode(test.StatusCode)). Build(t) client := newMockClient().WithRegisterAccount(egTestAccount) provider.client = client err := provider.Present(egDomain, "foo", egKeyAuth) if test.ExpectedError != nil { assert.EqualError(t, err, test.ExpectedError.Error()) assert.True(t, client.registerAccountCalled) assert.False(t, client.updateTXTRecordCalled) } else { require.NoError(t, err) assert.True(t, client.registerAccountCalled) assert.True(t, client.updateTXTRecordCalled) } }) } } func TestRegister_httpStorage(t *testing.T) { testCases := []struct { Name string StatusCode int ExpectedError error }{ { Name: "status code 200", StatusCode: http.StatusOK, ExpectedError: ErrCNAMERequired{ Domain: egDomain, FQDN: egFQDN, Target: egTestAccount.FullDomain, }, }, { Name: "status code 201", StatusCode: http.StatusCreated, }, } for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { provider := servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.StorageBaseURL = server.URL return NewDNSProviderConfig(config) }). // Put Route("POST /example.com", servermock.Noop().WithStatusCode(test.StatusCode)). Build(t) provider.client = newMockClient().WithRegisterAccount(egTestAccount) acc, err := provider.register(t.Context(), egDomain, egFQDN) if test.ExpectedError != nil { assert.Equal(t, test.ExpectedError, err) } else { require.NoError(t, err) assert.Equal(t, egTestAccount, acc) } }) } } ================================================ FILE: providers/dns/acmedns/internal/fixtures/error.json ================================================ { "message": "There is an error" } ================================================ FILE: providers/dns/acmedns/internal/fixtures/fetch-request.json ================================================ { "fulldomain": "foo.example.com", "subdomain": "foo", "username": "user", "password": "secret", "server_url": "https://example.com" } ================================================ FILE: providers/dns/acmedns/internal/fixtures/fetch.json ================================================ { "fulldomain": "foo.example.com", "subdomain": "foo", "username": "user", "password": "secret", "server_url": "https://example.com" } ================================================ FILE: providers/dns/acmedns/internal/fixtures/fetch_all.json ================================================ { "a": { "fulldomain": "foo.example.com", "subdomain": "foo", "username": "user", "password": "secret", "server_url": "https://example.com" }, "b": { "fulldomain": "bar.example.com", "subdomain": "bar", "username": "user", "password": "secret", "server_url": "https://example.com" } } ================================================ FILE: providers/dns/acmedns/internal/http_storage.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/nrdcg/goacmedns" "github.com/nrdcg/goacmedns/storage" ) var _ goacmedns.Storage = (*HTTPStorage)(nil) var ErrCNAMEAlreadyCreated = errors.New("the CNAME has already been created") // HTTPStorage is an implementation of [acmedns.Storage] over HTTP. type HTTPStorage struct { client *http.Client baseURL *url.URL } // NewHTTPStorage created a new [HTTPStorage]. func NewHTTPStorage(baseURL string) (*HTTPStorage, error) { endpoint, err := url.Parse(baseURL) if err != nil { return nil, err } return &HTTPStorage{ client: &http.Client{Timeout: 2 * time.Minute}, baseURL: endpoint, }, nil } func (s *HTTPStorage) Save(_ context.Context) error { return nil } func (s *HTTPStorage) Put(ctx context.Context, domain string, account goacmedns.Account) error { req, err := newJSONRequest(ctx, http.MethodPost, s.baseURL.JoinPath(domain), account) if err != nil { return fmt.Errorf("unable to create request: %w", err) } return s.do(req, nil) } func (s *HTTPStorage) Fetch(ctx context.Context, domain string) (goacmedns.Account, error) { req, err := newJSONRequest(ctx, http.MethodGet, s.baseURL.JoinPath(domain), nil) if err != nil { return goacmedns.Account{}, fmt.Errorf("unable to create request: %w", err) } var account goacmedns.Account err = s.do(req, &account) if err != nil { return goacmedns.Account{}, err } return account, nil } func (s *HTTPStorage) FetchAll(ctx context.Context) (map[string]goacmedns.Account, error) { req, err := newJSONRequest(ctx, http.MethodGet, s.baseURL, nil) if err != nil { return nil, err } var mapping map[string]goacmedns.Account err = s.do(req, &mapping) if err != nil { return nil, err } return mapping, nil } func (s *HTTPStorage) do(req *http.Request, result any) error { resp, err := s.client.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotFound { return storage.ErrDomainNotFound } if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { // Hack related to `Put`. if resp.StatusCode == http.StatusCreated { return ErrCNAMEAlreadyCreated } return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/acmedns/internal/http_storage_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/nrdcg/goacmedns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*HTTPStorage] { return servermock.NewBuilder[*HTTPStorage]( func(server *httptest.Server) (*HTTPStorage, error) { storage, err := NewHTTPStorage(server.URL) if err != nil { return nil, err } storage.client = server.Client() return storage, nil }, servermock.CheckHeader().WithJSONHeaders()) } func TestHTTPStorage_Fetch(t *testing.T) { storage := mockBuilder(). Route("GET /example.com", servermock.ResponseFromFixture("fetch.json")). Build(t) account, err := storage.Fetch(t.Context(), "example.com") require.NoError(t, err) expected := goacmedns.Account{ FullDomain: "foo.example.com", SubDomain: "foo", Username: "user", Password: "secret", ServerURL: "https://example.com", } assert.Equal(t, expected, account) } func TestHTTPStorage_Fetch_error(t *testing.T) { storage := mockBuilder(). Route("GET /example.com", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusInternalServerError)). Build(t) _, err := storage.Fetch(t.Context(), "example.com") require.Error(t, err) } func TestHTTPStorage_FetchAll(t *testing.T) { storage := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("fetch_all.json")). Build(t) account, err := storage.FetchAll(t.Context()) require.NoError(t, err) expected := map[string]goacmedns.Account{ "a": { FullDomain: "foo.example.com", SubDomain: "foo", Username: "user", Password: "secret", ServerURL: "https://example.com", }, "b": { FullDomain: "bar.example.com", SubDomain: "bar", Username: "user", Password: "secret", ServerURL: "https://example.com", }, } assert.Equal(t, expected, account) } func TestHTTPStorage_FetchAll_error(t *testing.T) { storage := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusInternalServerError)). Build(t) _, err := storage.FetchAll(t.Context()) require.Error(t, err) } func TestHTTPStorage_Put(t *testing.T) { storage := mockBuilder(). Route("POST /example.com", nil, servermock.CheckRequestJSONBodyFromFixture("fetch-request.json")). Build(t) account := goacmedns.Account{ FullDomain: "foo.example.com", SubDomain: "foo", Username: "user", Password: "secret", ServerURL: "https://example.com", } err := storage.Put(t.Context(), "example.com", account) require.NoError(t, err) } func TestHTTPStorage_Put_error(t *testing.T) { storage := mockBuilder(). Route("POST /example.com", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusInternalServerError)). Build(t) account := goacmedns.Account{ FullDomain: "foo.example.com", SubDomain: "foo", Username: "user", Password: "secret", ServerURL: "https://example.com", } err := storage.Put(t.Context(), "example.com", account) require.Error(t, err) } func TestHTTPStorage_Put_CNAME_created(t *testing.T) { storage := mockBuilder(). Route("POST /example.com", servermock.Noop(). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromFixture("fetch-request.json")). Build(t) account := goacmedns.Account{ FullDomain: "foo.example.com", SubDomain: "foo", Username: "user", Password: "secret", ServerURL: "https://example.com", } err := storage.Put(t.Context(), "example.com", account) require.ErrorIs(t, err, ErrCNAMEAlreadyCreated) } ================================================ FILE: providers/dns/acmedns/internal/readme.md ================================================ # HTTP Storage ## Fetch ### Request Endpoint: `GET /` ### Response Response status code 200. Response body (account): ```json { "fulldomain": "foo.example.com", "subdomain": "foo", "username": "user", "password": "secret", "server_url": "https://example.com" } ``` ## Fetch All ### Request Endpoint: `GET ` ### Response Response status code 200. Response body (domain/account mapping): ```json { "foo.example.com": { "fulldomain": "foo.example.com", "subdomain": "foo", "username": "user", "password": "secret", "server_url": "https://example.com" }, "bar.example.com": { "fulldomain": "bar.example.com", "subdomain": "bar", "username": "user", "password": "secret", "server_url": "https://example.com" } } ``` ## Put ### Request Endpoint: `POST /` ### Response Response status code: - 200: the process will be stopped to allow the user to create the CNAME. - 201: the process will continue without error (the CNAME should be created by the server) No expected body. ## Save No dedicated endpoint. ================================================ FILE: providers/dns/acmedns/mock_test.go ================================================ package acmedns import ( "context" "errors" "github.com/nrdcg/goacmedns" "github.com/nrdcg/goacmedns/storage" ) var ( // errorClientErr is used by the Client mocks that return an error. errorClientErr = errors.New("errorClient always errors") // errorStorageErr is used by the Storage mocks that return an error. errorStorageErr = errors.New("errorStorage always errors") ) var egTestAccount = goacmedns.Account{ FullDomain: "acme-dns." + egDomain, SubDomain: "random-looking-junk." + egDomain, Username: "spooky.mulder", Password: "trustno1", } type mockClient struct { records map[goacmedns.Account]string updateTXTRecordCalled bool updateTXTRecord func(ctx context.Context, acct goacmedns.Account, value string) error registerAccountCalled bool registerAccount func(ctx context.Context, allowFrom []string) (goacmedns.Account, error) } func newMockClient() *mockClient { return &mockClient{ records: make(map[goacmedns.Account]string), updateTXTRecord: func(_ context.Context, _ goacmedns.Account, _ string) error { return nil }, registerAccount: func(_ context.Context, _ []string) (goacmedns.Account, error) { return goacmedns.Account{}, nil }, } } func (c *mockClient) UpdateTXTRecord(ctx context.Context, acct goacmedns.Account, value string) error { c.updateTXTRecordCalled = true c.records[acct] = value return c.updateTXTRecord(ctx, acct, value) } func (c *mockClient) RegisterAccount(ctx context.Context, allowFrom []string) (goacmedns.Account, error) { c.registerAccountCalled = true return c.registerAccount(ctx, allowFrom) } func (c *mockClient) WithUpdateTXTRecordError(err error) *mockClient { c.updateTXTRecord = func(_ context.Context, _ goacmedns.Account, _ string) error { return err } return c } func (c *mockClient) WithRegisterAccount(acct goacmedns.Account) *mockClient { c.registerAccount = func(_ context.Context, _ []string) (goacmedns.Account, error) { return acct, nil } return c } func (c *mockClient) WithRegisterAccountError(err error) *mockClient { c.registerAccount = func(_ context.Context, _ []string) (goacmedns.Account, error) { return goacmedns.Account{}, err } return c } type mockStorage struct { accounts map[string]goacmedns.Account fetchAll func(ctx context.Context) (map[string]goacmedns.Account, error) fetch func(ctx context.Context, domain string) (goacmedns.Account, error) put func(ctx context.Context, domain string, acct goacmedns.Account) error save func(ctx context.Context) error } func newMockStorage() *mockStorage { m := &mockStorage{ accounts: make(map[string]goacmedns.Account), put: func(_ context.Context, _ string, _ goacmedns.Account) error { return nil }, save: func(_ context.Context) error { return nil }, } m.fetchAll = func(ctx context.Context) (map[string]goacmedns.Account, error) { return m.accounts, nil } m.fetch = func(_ context.Context, domain string) (goacmedns.Account, error) { if acct, ok := m.accounts[domain]; ok { return acct, nil } return goacmedns.Account{}, storage.ErrDomainNotFound } return m } func (m *mockStorage) FetchAll(ctx context.Context) (map[string]goacmedns.Account, error) { return m.fetchAll(ctx) } func (m *mockStorage) Fetch(ctx context.Context, domain string) (goacmedns.Account, error) { return m.fetch(ctx, domain) } func (m *mockStorage) Put(ctx context.Context, domain string, account goacmedns.Account) error { return m.put(ctx, domain, account) } func (m *mockStorage) Save(ctx context.Context) error { return m.save(ctx) } func (m *mockStorage) WithAccount(domain string, acct goacmedns.Account) *mockStorage { m.accounts[domain] = acct return m } func (m *mockStorage) WithFetchError(err error) *mockStorage { m.fetch = func(_ context.Context, _ string) (goacmedns.Account, error) { return goacmedns.Account{}, err } return m } func (m *mockStorage) WithPutError(err error) *mockStorage { m.put = func(_ context.Context, _ string, _ goacmedns.Account) error { return err } return m } func (m *mockStorage) WithSaveError(err error) *mockStorage { m.save = func(ctx context.Context) error { return err } return m } ================================================ FILE: providers/dns/active24/active24.go ================================================ // Package active24 implements a DNS provider for solving the DNS-01 challenge using Active24. package active24 import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/active24" ) const baseAPIDomain = "active24.cz" // Environment variables names. const ( envNamespace = "ACTIVE24_" EnvAPIKey = envNamespace + "API_KEY" EnvSecret = envNamespace + "SECRET" 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 = active24.Config // 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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Active24. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvSecret) if err != nil { return nil, fmt.Errorf("active24: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.Secret = values[EnvSecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Active24. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("active24: the configuration of the DNS provider is nil") } provider, err := active24.NewDNSProviderConfig(config, baseAPIDomain) if err != nil { return nil, fmt.Errorf("active24: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("active24: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("active24: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/active24/active24.toml ================================================ Name = "Active24" Description = '''''' URL = "https://www.active24.cz" Code = "active24" Since = "v4.23.0" Example = ''' ACTIVE24_API_KEY="xxx" \ ACTIVE24_SECRET="yyy" \ lego --dns active24 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ACTIVE24_API_KEY = "API key" ACTIVE24_SECRET = "Secret" [Configuration.Additional] ACTIVE24_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ACTIVE24_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ACTIVE24_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" ACTIVE24_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://rest.active24.cz/v2/docs" APIv1 = "https://rest.active24.cz/docs/v1.service#services" ================================================ FILE: providers/dns/active24/active24_test.go ================================================ package active24 import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "user", EnvSecret: "secret", }, }, { desc: "missing API key", envVars: map[string]string{ EnvAPIKey: "", EnvSecret: "secret", }, expected: "active24: some credentials information are missing: ACTIVE24_API_KEY", }, { desc: "missing secret", envVars: map[string]string{ EnvAPIKey: "user", EnvSecret: "", }, expected: "active24: some credentials information are missing: ACTIVE24_SECRET", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "active24: some credentials information are missing: ACTIVE24_API_KEY,ACTIVE24_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string secret string expected string }{ { desc: "success", apiKey: "user", secret: "secret", }, { desc: "missing API key", apiKey: "", secret: "secret", expected: "active24: credentials missing", }, { desc: "missing secret", apiKey: "user", secret: "", expected: "active24: credentials missing", }, { desc: "missing credentials", expected: "active24: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Secret = test.secret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/alidns/alidns.go ================================================ // Package alidns implements a DNS provider for solving the DNS-01 challenge using Alibaba Cloud DNS. package alidns import ( "context" "errors" "fmt" "time" openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" "github.com/alibabacloud-go/tea/dara" "github.com/aliyun/credentials-go/credentials" alidns "github.com/go-acme/alidns-20150109/v4/client" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "golang.org/x/net/idna" ) // Environment variables names. const ( envNamespace = "ALICLOUD_" EnvRAMRole = envNamespace + "RAM_ROLE" EnvAccessKey = envNamespace + "ACCESS_KEY" EnvSecretKey = envNamespace + "SECRET_KEY" EnvSecurityToken = envNamespace + "SECURITY_TOKEN" EnvRegionID = envNamespace + "REGION_ID" EnvLine = envNamespace + "LINE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultRegionID = "cn-hangzhou" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { RAMRole string APIKey string SecretKey string SecurityToken string RegionID string Line string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *alidns.Client } // NewDNSProvider returns a DNSProvider instance configured for Alibaba Cloud DNS. // - If you're using the instance RAM role, the RAM role environment variable must be passed in: ALICLOUD_RAM_ROLE. // - Other than that, credentials must be passed in the environment variables: // ALICLOUD_ACCESS_KEY, ALICLOUD_SECRET_KEY, and optionally ALICLOUD_SECURITY_TOKEN. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.RegionID = env.GetOrFile(EnvRegionID) config.Line = env.GetOrFile(EnvLine) values, err := env.Get(EnvRAMRole) if err == nil { config.RAMRole = values[EnvRAMRole] return NewDNSProviderConfig(config) } values, err = env.Get(EnvAccessKey, EnvSecretKey) if err != nil { return nil, fmt.Errorf("alicloud: %w", err) } config.APIKey = values[EnvAccessKey] config.SecretKey = values[EnvSecretKey] config.SecurityToken = env.GetOrFile(EnvSecurityToken) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for alidns. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("alicloud: the configuration of the DNS provider is nil") } if config.RegionID == "" { config.RegionID = defaultRegionID } cfg := new(openapi.Config). SetRegionId(config.RegionID). SetReadTimeout(int(config.HTTPTimeout.Milliseconds())) switch { case config.RAMRole != "": // https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance credentialsCfg := new(credentials.Config). SetType("ecs_ram_role"). SetRoleName(config.RAMRole) credentialClient, err := credentials.NewCredential(credentialsCfg) if err != nil { return nil, fmt.Errorf("alicloud: new credential: %w", err) } cfg = cfg.SetCredential(credentialClient) case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "": cfg = cfg. SetAccessKeyId(config.APIKey). SetAccessKeySecret(config.SecretKey). SetSecurityToken(config.SecurityToken) case config.APIKey != "" && config.SecretKey != "": cfg = cfg. SetAccessKeyId(config.APIKey). SetAccessKeySecret(config.SecretKey) default: return nil, errors.New("alicloud: ram role or credentials missing") } client, err := alidns.NewClient(cfg) if err != nil { return nil, fmt.Errorf("alicloud: new client: %w", err) } return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("alicloud: %w", err) } recordRequest, err := d.newTxtRecord(zoneName, info.EffectiveFQDN, info.Value) if err != nil { return err } _, err = alidns.AddDomainRecordWithContext(ctx, d.client, recordRequest, &dara.RuntimeOptions{}) if err != nil { return fmt.Errorf("alicloud: API call failed: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) records, err := d.findTxtRecords(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("alicloud: %w", err) } _, err = d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("alicloud: %w", err) } for _, rec := range records { request := &alidns.DeleteDomainRecordRequest{ RecordId: rec.RecordId, } _, err = alidns.DeleteDomainRecordWithContext(ctx, d.client, request, &dara.RuntimeOptions{}) if err != nil { return fmt.Errorf("alicloud: %w", err) } } return nil } func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) { request := new(alidns.DescribeDomainsRequest) var domains []*alidns.DescribeDomainsResponseBodyDomainsDomain var startPage int64 = 1 for { request.SetPageNumber(startPage) response, err := alidns.DescribeDomainsWithContext(ctx, d.client, request, &dara.RuntimeOptions{}) if err != nil { return "", fmt.Errorf("API call failed: %w", err) } domains = append(domains, response.Body.Domains.Domain...) if ptr.Deref(response.Body.PageNumber)*ptr.Deref(response.Body.PageSize) >= ptr.Deref(response.Body.TotalCount) { break } startPage++ } authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return "", fmt.Errorf("could not find zone: %w", err) } var hostedZone *alidns.DescribeDomainsResponseBodyDomainsDomain for _, zone := range domains { if ptr.Deref(zone.DomainName) == dns01.UnFqdn(authZone) || ptr.Deref(zone.PunyCode) == dns01.UnFqdn(authZone) { hostedZone = zone } } if hostedZone == nil || ptr.Deref(hostedZone.DomainId) == "" { return "", fmt.Errorf("zone %s not found in AliDNS for domain %s", authZone, domain) } return ptr.Deref(hostedZone.DomainName), nil } func (d *DNSProvider) newTxtRecord(zone, fqdn, value string) (*alidns.AddDomainRecordRequest, error) { rr, err := extractRecordName(fqdn, zone) if err != nil { return nil, err } adrr := new(alidns.AddDomainRecordRequest). SetType("TXT"). SetDomainName(zone). SetRR(rr). SetValue(value). SetTTL(int64(d.config.TTL)) if d.config.Line != "" { adrr.SetLine(d.config.Line) } return adrr, nil } func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord, error) { zoneName, err := d.getHostedZone(ctx, fqdn) if err != nil { return nil, err } request := new(alidns.DescribeDomainRecordsRequest). SetDomainName(zoneName). SetPageSize(500) var records []*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord result, err := alidns.DescribeDomainRecordsWithContext(ctx, d.client, request, &dara.RuntimeOptions{}) if err != nil { return records, fmt.Errorf("API call has failed: %w", err) } recordName, err := extractRecordName(fqdn, zoneName) if err != nil { return nil, err } for _, record := range result.Body.DomainRecords.Record { if ptr.Deref(record.RR) == recordName && ptr.Deref(record.Type) == "TXT" { records = append(records, record) } } return records, nil } func extractRecordName(fqdn, zone string) (string, error) { asciiDomain, err := idna.ToASCII(zone) if err != nil { return "", fmt.Errorf("fail to convert punycode: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, asciiDomain) if err != nil { return "", err } return subDomain, nil } ================================================ FILE: providers/dns/alidns/alidns.toml ================================================ Name = "Alibaba Cloud DNS" Description = '''''' URL = "https://www.alibabacloud.com/product/dns" Code = "alidns" Since = "v1.1.0" Example = ''' # Setup using instance RAM role ALICLOUD_RAM_ROLE=lego \ lego --dns alidns -d '*.example.com' -d example.com run # Or, using credentials ALICLOUD_ACCESS_KEY=abcdefghijklmnopqrstuvwx \ ALICLOUD_SECRET_KEY=your-secret-key \ ALICLOUD_SECURITY_TOKEN=your-sts-token \ lego --dns alidns - -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ALICLOUD_RAM_ROLE = "Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)" ALICLOUD_ACCESS_KEY = "Access key ID" ALICLOUD_SECRET_KEY = "Access Key secret" ALICLOUD_SECURITY_TOKEN = "STS Security Token (optional)" [Configuration.Additional] ALICLOUD_REGION_ID = "Region ID (Default: cn-hangzhou)" ALICLOUD_LINE = "Line (Default: default)" ALICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ALICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ALICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" ALICLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.alibabacloud.com/help/en/alibaba-cloud-dns/latest/api-alidns-2015-01-09-dir-parsing-records" GoClient = "https://github.com/alibabacloud-go/alidns-20150109" GoClient2 = "https://github.com/aliyun/alibabacloud-go-sdk/tree/HEAD/alidns-20150109" ================================================ FILE: providers/dns/alidns/alidns_test.go ================================================ package alidns import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAccessKey, EnvSecretKey, EnvRAMRole). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessKey: "123", EnvSecretKey: "456", }, }, { desc: "success (RAM role)", envVars: map[string]string{ EnvRAMRole: "LegoInstanceRole", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAccessKey: "", EnvSecretKey: "", }, expected: "alicloud: some credentials information are missing: ALICLOUD_ACCESS_KEY,ALICLOUD_SECRET_KEY", }, { desc: "missing access key", envVars: map[string]string{ EnvAccessKey: "", EnvSecretKey: "456", }, expected: "alicloud: some credentials information are missing: ALICLOUD_ACCESS_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAccessKey: "123", EnvSecretKey: "", }, expected: "alicloud: some credentials information are missing: ALICLOUD_SECRET_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string ramRole string apiKey string secretKey string expected string }{ { desc: "success", apiKey: "123", secretKey: "456", }, { desc: "success", ramRole: "LegoInstanceRole", }, { desc: "missing credentials", expected: "alicloud: ram role or credentials missing", }, { desc: "missing api key", secretKey: "456", expected: "alicloud: ram role or credentials missing", }, { desc: "missing secret key", apiKey: "123", expected: "alicloud: ram role or credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.SecretKey = test.secretKey config.RAMRole = test.ramRole p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/aliesa/aliesa.go ================================================ // Package aliesa implements a DNS provider for solving the DNS-01 challenge using AlibabaCloud ESA. package aliesa import ( "context" "errors" "fmt" "sync" "time" openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" "github.com/alibabacloud-go/tea/dara" "github.com/aliyun/credentials-go/credentials" esa "github.com/go-acme/esa-20240910/v2/client" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) // Environment variables names. const ( envNamespace = "ALIESA_" EnvRAMRole = envNamespace + "RAM_ROLE" EnvAccessKey = envNamespace + "ACCESS_KEY" EnvSecretKey = envNamespace + "SECRET_KEY" EnvSecurityToken = envNamespace + "SECURITY_TOKEN" EnvRegionID = envNamespace + "REGION_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultRegionID = "cn-hangzhou" // Config is used to configure the creation of the DNSProvider. type Config struct { RAMRole string APIKey string SecretKey string SecurityToken string RegionID string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout time.Duration } // 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), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *esa.Client recordIDs map[string]int64 recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for AlibabaCloud ESA. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.RegionID = env.GetOrFile(EnvRegionID) values, err := env.Get(EnvRAMRole) if err == nil { config.RAMRole = values[EnvRAMRole] return NewDNSProviderConfig(config) } values, err = env.Get(EnvAccessKey, EnvSecretKey) if err != nil { return nil, fmt.Errorf("aliesa: %w", err) } config.APIKey = values[EnvAccessKey] config.SecretKey = values[EnvSecretKey] config.SecurityToken = env.GetOrFile(EnvSecurityToken) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for AlibabaCloud ESA. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("aliesa: the configuration of the DNS provider is nil") } if config.RegionID == "" { config.RegionID = defaultRegionID } cfg := new(openapi.Config). SetRegionId(config.RegionID). SetReadTimeout(int(config.HTTPTimeout.Milliseconds())) switch { case config.RAMRole != "": // https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance credentialsCfg := new(credentials.Config). SetType("ecs_ram_role"). SetRoleName(config.RAMRole) credentialClient, err := credentials.NewCredential(credentialsCfg) if err != nil { return nil, fmt.Errorf("aliesa: new credential: %w", err) } cfg = cfg.SetCredential(credentialClient) case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "": cfg = cfg. SetAccessKeyId(config.APIKey). SetAccessKeySecret(config.SecretKey). SetSecurityToken(config.SecurityToken) case config.APIKey != "" && config.SecretKey != "": cfg = cfg. SetAccessKeyId(config.APIKey). SetAccessKeySecret(config.SecretKey) default: return nil, errors.New("aliesa: ram role or credentials missing") } client, err := esa.NewClient(cfg) if err != nil { return nil, fmt.Errorf("aliesa: new client: %w", err) } // Workaround to get a regional URL. // https://github.com/alibabacloud-go/esa-20240910/blame/7660e3aab2045d4820e4b83427a154efe0c79319/client/client.go#L27 // The `EndpointRule` is hardcoded with an empty string, so the region is ignored. client.Endpoint = nil client.EndpointRule = ptr.Pointer("regional") client.Endpoint, err = esa.GetEndpoint(client, dara.String("esa"), client.RegionId, client.EndpointRule, client.Network, client.Suffix, client.EndpointMap, client.Endpoint) if err != nil { return nil, fmt.Errorf("aliesa: get endpoint: %w", err) } return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int64), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) siteID, err := d.getSiteID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("aliesa: %w", err) } crReq := new(esa.CreateRecordRequest). SetSiteId(siteID). SetType("TXT"). SetRecordName(dns01.UnFqdn(info.EffectiveFQDN)). SetTtl(int32(d.config.TTL)). SetData(new(esa.CreateRecordRequestData).SetValue(info.Value)) // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord crResp, err := esa.CreateRecordWithContext(ctx, d.client, crReq, &dara.RuntimeOptions{}) if err != nil { return fmt.Errorf("aliesa: create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = ptr.Deref(crResp.Body.GetRecordId()) d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) // gets the record's unique ID d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("aliesa: unknown record ID for '%s'", info.EffectiveFQDN) } drReq := new(esa.DeleteRecordRequest). SetRecordId(recordID) // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-deleterecord _, err := esa.DeleteRecordWithContext(ctx, d.client, drReq, &dara.RuntimeOptions{}) if err != nil { return fmt.Errorf("aliesa: delete record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } func (d *DNSProvider) getSiteID(ctx context.Context, fqdn string) (int64, error) { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return 0, fmt.Errorf("aliesa: could not find zone for domain %q: %w", fqdn, err) } lsReq := new(esa.ListSitesRequest). SetSiteName(dns01.UnFqdn(authZone)). SetSiteSearchType("suffix") // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-listsites lsResp, err := esa.ListSitesWithContext(ctx, d.client, lsReq, &dara.RuntimeOptions{}) if err != nil { return 0, fmt.Errorf("list sites: %w", err) } for f := range dns01.UnFqdnDomainsSeq(fqdn) { domain := dns01.UnFqdn(f) for _, site := range lsResp.Body.GetSites() { if ptr.Deref(site.GetSiteName()) == domain { return ptr.Deref(site.GetSiteId()), nil } } } return 0, fmt.Errorf("site not found (fqdn: %q)", fqdn) } ================================================ FILE: providers/dns/aliesa/aliesa.toml ================================================ Name = "AlibabaCloud ESA" Description = '''''' URL = "https://www.alibabacloud.com/en/product/esa" Code = "aliesa" Since = "v4.29.0" Example = ''' # Setup using instance RAM role ALIESA_RAM_ROLE=lego \ lego --dns aliesa -d '*.example.com' -d example.com run # Or, using credentials ALIESA_ACCESS_KEY=abcdefghijklmnopqrstuvwx \ ALIESA_SECRET_KEY=your-secret-key \ ALIESA_SECURITY_TOKEN=your-sts-token \ lego --dns aliesa - -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ALIESA_RAM_ROLE = "Your instance RAM role (https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance)" ALIESA_ACCESS_KEY = "Access key ID" ALIESA_SECRET_KEY = "Access Key secret" ALIESA_SECURITY_TOKEN = "STS Security Token (optional)" [Configuration.Additional] ALIESA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ALIESA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ALIESA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" ALIESA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-overview?spm=a2c63.p38356.help-menu-2673927.d_6_0_0.20b224c28PSZDc#:~:text=DNS-,DNS%20records,-DNS%20records" GoClient = "https://github.com/alibabacloud-go/esa-20240910" ================================================ FILE: providers/dns/aliesa/aliesa_test.go ================================================ package aliesa import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAccessKey, EnvSecretKey, EnvRAMRole). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessKey: "123", EnvSecretKey: "456", }, }, { desc: "success (RAM role)", envVars: map[string]string{ EnvRAMRole: "LegoInstanceRole", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAccessKey: "", EnvSecretKey: "", }, expected: "aliesa: some credentials information are missing: ALIESA_ACCESS_KEY,ALIESA_SECRET_KEY", }, { desc: "missing access key", envVars: map[string]string{ EnvAccessKey: "", EnvSecretKey: "456", }, expected: "aliesa: some credentials information are missing: ALIESA_ACCESS_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAccessKey: "123", EnvSecretKey: "", }, expected: "aliesa: some credentials information are missing: ALIESA_SECRET_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string ramRole string apiKey string secretKey string expected string }{ { desc: "success", apiKey: "123", secretKey: "456", }, { desc: "success", ramRole: "LegoInstanceRole", }, { desc: "missing credentials", expected: "aliesa: ram role or credentials missing", }, { desc: "missing api key", secretKey: "456", expected: "aliesa: ram role or credentials missing", }, { desc: "missing secret key", apiKey: "123", expected: "aliesa: ram role or credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.SecretKey = test.secretKey config.RAMRole = test.ramRole p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/allinkl/allinkl.go ================================================ // Package allinkl implements a DNS provider for solving the DNS-01 challenge using all-inkl. package allinkl import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/allinkl/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "ALL_INKL_" EnvLogin = envNamespace + "LOGIN" EnvPassword = envNamespace + "PASSWORD" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Login string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config identifier *internal.Identifier client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for all-inkl. // Credentials must be passed in the environment variable: ALL_INKL_LOGIN, ALL_INKL_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvLogin, EnvPassword) if err != nil { return nil, fmt.Errorf("allinkl: %w", err) } config := NewDefaultConfig() config.Login = values[EnvLogin] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for all-inkl. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("allinkl: the configuration of the DNS provider is nil") } if config.Login == "" || config.Password == "" { return nil, errors.New("allinkl: missing credentials") } identifier := internal.NewIdentifier(config.Login, config.Password) if config.HTTPClient != nil { identifier.HTTPClient = config.HTTPClient } identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient) client := internal.NewClient(config.Login) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, identifier: identifier, client: client, recordIDs: make(map[string]string), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() credential, err := d.identifier.Authentication(ctx, 60, true) if err != nil { return fmt.Errorf("allinkl: authentication: %w", err) } ctx = internal.WithContext(ctx, credential) authZone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("allinkl: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("allinkl: %w", err) } record := internal.DNSRequest{ ZoneHost: authZone, RecordType: "TXT", RecordName: subDomain, RecordData: info.Value, } recordID, err := d.client.AddDNSSettings(ctx, record) if err != nil { return fmt.Errorf("allinkl: add DNS settings: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() credential, err := d.identifier.Authentication(ctx, 60, true) if err != nil { return fmt.Errorf("allinkl: authentication: %w", err) } ctx = internal.WithContext(ctx, credential) // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("allinkl: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } _, err = d.client.DeleteDNSSettings(ctx, recordID) if err != nil { return fmt.Errorf("allinkl: delete DNS settings: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { for z := range dns01.DomainsSeq(fqdn) { _, errG := d.client.GetDNSSettings(ctx, z, "") if errG != nil { log.Infof("get DNS settings zone[%q] %v", z, errG) continue } return z, nil } return "", fmt.Errorf("unable to find auth zone for '%s'", fqdn) } ================================================ FILE: providers/dns/allinkl/allinkl.toml ================================================ Name = "all-inkl" Description = '''''' URL = "https://all-inkl.com" Code = "allinkl" Since = "v4.5.0" Example = ''' ALL_INKL_LOGIN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ ALL_INKL_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \ lego --dns allinkl -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ALL_INKL_LOGIN = "KAS login" ALL_INKL_PASSWORD = "KAS password" [Configuration.Additional] ALL_INKL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ALL_INKL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ALL_INKL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://kasapi.kasserver.com/dokumentation/phpdoc/index.html" Guide = "https://kasapi.kasserver.com/dokumentation/" ================================================ FILE: providers/dns/allinkl/allinkl_test.go ================================================ package allinkl import ( "encoding/json" "encoding/xml" "fmt" "io" "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/allinkl/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvLogin, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvLogin: "user", EnvPassword: "secret", }, }, { desc: "missing credentials: account name", envVars: map[string]string{ EnvLogin: "", EnvPassword: "secret", }, expected: "allinkl: some credentials information are missing: ALL_INKL_LOGIN", }, { desc: "missing credentials: api key", envVars: map[string]string{ EnvLogin: "user", EnvPassword: "", }, expected: "allinkl: some credentials information are missing: ALL_INKL_PASSWORD", }, { desc: "missing credentials: all", envVars: map[string]string{ EnvLogin: "", EnvPassword: "", }, expected: "allinkl: some credentials information are missing: ALL_INKL_LOGIN,ALL_INKL_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string login string password string expected string }{ { desc: "success", login: "user", password: "secret", }, { desc: "missing account name", password: "secret", expected: "allinkl: missing credentials", }, { desc: "missing api key", login: "user", expected: "allinkl: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Login = test.login config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func 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) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Login = "user" config.Password = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) p.identifier.BaseURL, _ = url.Parse(server.URL) return p, err }, ).Route("POST /KasAuth.php", servermock.ResponseFromInternal("auth.xml"), servermock.CheckRequestBodyFromInternal("auth-request.xml"). IgnoreWhitespace(), ) } func extractKasRequest(reader io.Reader) (*internal.KasRequest, error) { type ReqEnvelope struct { XMLName xml.Name `xml:"Envelope"` Body struct { KasAPI struct { Params string `xml:"Params"` } `xml:"KasApi"` } `xml:"Body"` } raw, err := io.ReadAll(reader) if err != nil { return nil, err } reqEnvelope := ReqEnvelope{} err = xml.Unmarshal(raw, &reqEnvelope) if err != nil { return nil, err } var kReq internal.KasRequest err = json.Unmarshal([]byte(reqEnvelope.Body.KasAPI.Params), &kReq) if err != nil { return nil, err } return &kReq, nil } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /KasApi.php", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { kReq, err := extractKasRequest(req.Body) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } switch kReq.Action { case "get_dns_settings": params := kReq.RequestParams.(map[string]any) if params["zone_host"] == "_acme-challenge.example.com." { servermock.ResponseFromInternal("get_dns_settings_not_found.xml").ServeHTTP(rw, req) } else { servermock.ResponseFromInternal("get_dns_settings.xml").ServeHTTP(rw, req) } case "add_dns_settings": servermock.ResponseFromInternal("add_dns_settings.xml").ServeHTTP(rw, req) default: http.Error(rw, fmt.Sprintf("unknown action: %v", kReq.Action), http.StatusBadRequest) } }), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("POST /KasApi.php", servermock.ResponseFromInternal("delete_dns_settings.xml"), servermock.CheckRequestBodyFromInternal("delete_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) provider.recordIDs["abc"] = "57347450" err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/allinkl/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-viper/mapstructure/v2" ) const defaultBaseURL = "https://kasapi.kasserver.com/soap/" const apiPath = "KasApi.php" type Authentication interface { Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error) } // Client a KAS server client. type Client struct { login string floodTime time.Time muFloodTime sync.Mutex maxElapsedTime time.Duration BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(login string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ login: login, BaseURL: baseURL, maxElapsedTime: 3 * time.Minute, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // GetDNSSettings Reading out the DNS settings of a zone. // - zone: host zone. // - recordID: the ID of the resource record (optional). func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]ReturnInfo, error) { requestParams := map[string]string{"zone_host": zone} if recordID != "" { requestParams["record_id"] = recordID } var g APIResponse[GetDNSSettingsResponse] err := c.doRequest(ctx, "get_dns_settings", requestParams, &g) if err != nil { return nil, err } c.updateFloodTime(g.Response.KasFloodDelay) return g.Response.ReturnInfo, nil } // AddDNSSettings Creation of a DNS resource record. func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) { var g APIResponse[AddDNSSettingsResponse] err := c.doRequest(ctx, "add_dns_settings", record, &g) if err != nil { return "", err } c.updateFloodTime(g.Response.KasFloodDelay) return g.Response.ReturnInfo, nil } // DeleteDNSSettings Deleting a DNS Resource Record. func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (string, error) { requestParams := map[string]string{"record_id": recordID} var g APIResponse[DeleteDNSSettingsResponse] err := c.doRequest(ctx, "delete_dns_settings", requestParams, &g) if err != nil { return "", err } c.updateFloodTime(g.Response.KasFloodDelay) return g.Response.ReturnString, nil } func (c *Client) newRequest(ctx context.Context, action string, requestParams any) (*http.Request, error) { ar := KasRequest{ Login: c.login, AuthType: "session", AuthData: getToken(ctx), Action: action, RequestParams: requestParams, } body, err := json.Marshal(ar) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body))) endpoint := c.BaseURL.JoinPath(apiPath) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } return req, nil } func (c *Client) doRequest(ctx context.Context, action string, requestParams, result any) error { return wait.Retry(ctx, func() error { req, err := c.newRequest(ctx, action, requestParams) if err != nil { return backoff.Permanent(err) } return c.do(req, result) }, backoff.WithBackOff(&backoff.ZeroBackOff{}), backoff.WithMaxElapsedTime(c.maxElapsedTime), ) } func (c *Client) do(req *http.Request, result any) error { c.muFloodTime.Lock() time.Sleep(time.Until(c.floodTime)) c.muFloodTime.Unlock() resp, err := c.HTTPClient.Do(req) if err != nil { return backoff.Permanent(errutils.NewHTTPDoError(req, err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return backoff.Permanent(errutils.NewUnexpectedResponseStatusCodeError(req, resp)) } envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body) if err != nil { return backoff.Permanent(err) } if envlp.Body.Fault != nil { if envlp.Body.Fault.Message == "flood_protection" { ft, errP := strconv.ParseFloat(envlp.Body.Fault.Detail, 64) if errP != nil { return fmt.Errorf("parse flood protection delay: %w", envlp.Body.Fault) } c.updateFloodTime(ft) return envlp.Body.Fault } return backoff.Permanent(envlp.Body.Fault) } raw := getValue(envlp.Body.KasAPIResponse.Return) err = mapstructure.Decode(raw, result) if err != nil { return backoff.Permanent(fmt.Errorf("response struct decode: %w", err)) } return nil } func (c *Client) updateFloodTime(delay float64) { c.muFloodTime.Lock() c.floodTime = time.Now().Add(time.Duration(delay * float64(time.Second))) c.muFloodTime.Unlock() } func getValue(item *Item) any { switch { case item.Raw != "": v, _ := strconv.ParseBool(item.Raw) return v case item.Text != "": switch item.Type { case "xsd:string": return item.Text case "xsd:float": v, _ := strconv.ParseFloat(item.Text, 64) return v case "xsd:int": v, _ := strconv.ParseInt(item.Text, 10, 64) return v default: return item.Text } case item.Value != nil: return getValue(item.Value) case len(item.Items) > 0 && item.Type == "SOAP-ENC:Array": var v []any for _, i := range item.Items { v = append(v, getValue(i)) } return v case len(item.Items) > 0: v := map[string]any{} for _, i := range item.Items { v[getKey(i)] = getValue(i) } return v default: return "" } } func getKey(item *Item) string { if item.Key == nil { return "" } return item.Key.Text } ================================================ FILE: providers/dns/allinkl/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user") client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() client.maxElapsedTime = 1 * time.Second return client, nil } func TestClient_GetDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /KasApi.php", servermock.ResponseFromFixture("get_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("get_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) records, err := client.GetDNSSettings(mockContext(t), "example.com", "") require.NoError(t, err) expected := []ReturnInfo{ { ID: "57297429", Zone: "example.org", Name: "", Type: "A", Data: "10.0.0.1", Changeable: "Y", Aux: 0, }, { ID: int64(0), Zone: "example.org", Name: "", Type: "NS", Data: "ns5.kasserver.com.", Changeable: "N", Aux: 0, }, { ID: int64(0), Zone: "example.org", Name: "", Type: "NS", Data: "ns6.kasserver.com.", Changeable: "N", Aux: 0, }, { ID: "57297479", Zone: "example.org", Name: "*", Type: "A", Data: "10.0.0.1", Changeable: "Y", Aux: 0, }, { ID: "57297481", Zone: "example.org", Name: "", Type: "MX", Data: "user.kasserver.com.", Changeable: "Y", Aux: 10, }, { ID: "57297483", Zone: "example.org", Name: "", Type: "TXT", Data: "v=spf1 mx a ?all", Changeable: "Y", Aux: 0, }, { ID: "57297485", Zone: "example.org", Name: "_dmarc", Type: "TXT", Data: "v=DMARC1; p=none;", Changeable: "Y", Aux: 0, }, } assert.Equal(t, expected, records) } func TestClient_GetDNSSettings_error_flood_protection(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /KasApi.php", servermock.ResponseFromFixture("flood_protection.xml"), ). Build(t) assert.Zero(t, client.floodTime) _, err := client.GetDNSSettings(mockContext(t), "example.com", "") require.EqualError(t, err, "KasApi: SOAP-ENV:Server: flood_protection: 0.0688529014587") assert.NotZero(t, client.floodTime) } func TestClient_AddDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /KasApi.php", servermock.ResponseFromFixture("add_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("add_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) record := DNSRequest{ ZoneHost: "42cnc.de.", RecordType: "TXT", RecordName: "lego", RecordData: "abcdefgh", } recordID, err := client.AddDNSSettings(mockContext(t), record) require.NoError(t, err) assert.Equal(t, "57347444", recordID) } func TestClient_DeleteDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /KasApi.php", servermock.ResponseFromFixture("delete_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("delete_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) r, err := client.DeleteDNSSettings(mockContext(t), "57347450") require.NoError(t, err) assert.Equal(t, "TRUE", r) } ================================================ FILE: providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml ================================================ {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"add_dns_settings","KasRequestParams":{"zone_host":"42cnc.de.","record_type":"TXT","record_name":"lego","record_data":"abcdefgh","record_aux":0}} ================================================ FILE: providers/dns/allinkl/internal/fixtures/add_dns_settings.json ================================================ { "Request": { "KasRequestParams": { "record_aux": 0, "record_data": "abcdefgh", "record_name": "lego", "record_type": "TXT", "zone_host": "example.org." }, "KasRequestTime": 1625014992, "KasRequestType": true }, "Response": { "KasFloodDelay": 0.5, "ReturnInfo": "57347444", "ReturnString": "TRUE" } } ================================================ FILE: providers/dns/allinkl/internal/fixtures/add_dns_settings.xml ================================================ Request KasRequestTime 1625014992 KasRequestType KasRequestParams zone_host example.org. record_type TXT record_name lego record_data abcdefgh record_aux 0 Response KasFloodDelay 0.5 ReturnString TRUE ReturnInfo 57347444 ================================================ FILE: providers/dns/allinkl/internal/fixtures/auth-request.xml ================================================ {"kas_login":"user","kas_auth_data":"secret","kas_auth_type":"plain","session_lifetime":60,"session_update_lifetime":"Y"} ================================================ FILE: providers/dns/allinkl/internal/fixtures/auth.xml ================================================ 593959ca04f0de9689b586c6a647d15d ================================================ FILE: providers/dns/allinkl/internal/fixtures/auth_fault.xml ================================================ SOAP-ENV:Client kas_login_syntax_incorrect KasAuth ================================================ FILE: providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml ================================================ {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"delete_dns_settings","KasRequestParams":{"record_id":"57347450"}} ================================================ FILE: providers/dns/allinkl/internal/fixtures/delete_dns_settings.json ================================================ { "Request": { "KasRequestParams": { "record_id": "57347444" }, "KasRequestTime": 1625016066, "KasRequestType": true }, "Response": { "KasFloodDelay": 0.5, "ReturnInfo": true, "ReturnString": "TRUE" } } ================================================ FILE: providers/dns/allinkl/internal/fixtures/delete_dns_settings.xml ================================================ Request KasRequestTime 1625016066 KasRequestType KasRequestParams record_id 57347444 Response KasFloodDelay 0.5 ReturnString TRUE ReturnInfo ================================================ FILE: providers/dns/allinkl/internal/fixtures/flood_protection.xml ================================================ SOAP-ENV:Server flood_protection KasApi 0.0688529014587 ================================================ FILE: providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml ================================================ {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"get_dns_settings","KasRequestParams":{"zone_host":"example.com"}} ================================================ FILE: providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml ================================================ SOAP-ENV:Server zone_not_found KasApi example.com ================================================ FILE: providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml ================================================ SOAP-ENV:Server zone_syntax_incorrect KasApi _acme-challenge.example.com ================================================ FILE: providers/dns/allinkl/internal/fixtures/get_dns_settings.json ================================================ { "Request": { "KasRequestParams": { "zone_host": "example.org" }, "KasRequestTime": 1625012975, "KasRequestType": true }, "Response": { "KasFloodDelay": 0.5, "ReturnInfo": [ { "record_aux": 0, "record_changeable": "Y", "record_data": "10.0.0.1", "record_id": "57297429", "record_name": "", "record_type": "A", "record_zone": "example.org" }, { "record_aux": 0, "record_changeable": "N", "record_data": "ns5.kasserver.com.", "record_id": 0, "record_name": "", "record_type": "NS", "record_zone": "example.org" }, { "record_aux": 0, "record_changeable": "N", "record_data": "ns6.kasserver.com.", "record_id": 0, "record_name": "", "record_type": "NS", "record_zone": "example.org" }, { "record_aux": 0, "record_changeable": "Y", "record_data": "10.0.0.1", "record_id": "57297479", "record_name": "*", "record_type": "A", "record_zone": "example.org" }, { "record_aux": 10, "record_changeable": "Y", "record_data": "user.kasserver.com.", "record_id": "57297481", "record_name": "", "record_type": "MX", "record_zone": "example.org" }, { "record_aux": 0, "record_changeable": "Y", "record_data": "v=spf1 mx a ?all", "record_id": "57297483", "record_name": "", "record_type": "TXT", "record_zone": "example.org" }, { "record_aux": 0, "record_changeable": "Y", "record_data": "v=DMARC1; p=none;", "record_id": "57297485", "record_name": "_dmarc", "record_type": "TXT", "record_zone": "example.org" } ], "ReturnString": "TRUE" } } ================================================ FILE: providers/dns/allinkl/internal/fixtures/get_dns_settings.xml ================================================ Request KasRequestTime 1624993260 KasRequestType KasRequestParams zone_host example.org Response KasFloodDelay 0.5 ReturnString TRUE ReturnInfo record_zone example.org record_name record_type A record_data 10.0.0.1 record_aux 0 record_id 57297429 record_changeable Y record_zone example.org record_name record_type NS record_data ns5.kasserver.com. record_aux 0 record_id 0 record_changeable N record_zone example.org record_name record_type NS record_data ns6.kasserver.com. record_aux 0 record_id 0 record_changeable N record_zone example.org record_name * record_type A record_data 10.0.0.1 record_aux 0 record_id 57297479 record_changeable Y record_zone example.org record_name record_type MX record_data user.kasserver.com. record_aux 10 record_id 57297481 record_changeable Y record_zone example.org record_name record_type TXT record_data v=spf1 mx a ?all record_aux 0 record_id 57297483 record_changeable Y record_zone example.org record_name _dmarc record_type TXT record_data v=DMARC1; p=none; record_aux 0 record_id 57297485 record_changeable Y ================================================ FILE: providers/dns/allinkl/internal/identity.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const authPath = "KasAuth.php" type token string const tokenKey token = "token" // Identifier generates credential tokens. type Identifier struct { login string password string BaseURL *url.URL HTTPClient *http.Client } // NewIdentifier creates a new Identifier. func NewIdentifier(login, password string) *Identifier { baseURL, _ := url.Parse(defaultBaseURL) return &Identifier{ login: login, password: password, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // Authentication Creates a credential token. // - sessionLifetime: Validity of the token in seconds. // - sessionUpdateLifetime: with `true` the session is extended with every request. func (c *Identifier) Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error) { sul := "N" if sessionUpdateLifetime { sul = "Y" } ar := AuthRequest{ Login: c.login, AuthData: c.password, AuthType: "plain", SessionLifetime: sessionLifetime, SessionUpdateLifetime: sul, } body, err := json.Marshal(ar) if err != nil { return "", fmt.Errorf("failed to create request JSON body: %w", err) } payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body))) endpoint := c.BaseURL.JoinPath(authPath) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return "", fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return "", errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp) } envlp, err := decodeXML[KasAuthEnvelope](resp.Body) if err != nil { return "", err } if envlp.Body.Fault != nil { return "", envlp.Body.Fault } return envlp.Body.KasAuthResponse.Return.Text, nil } func WithContext(ctx context.Context, credential string) context.Context { return context.WithValue(ctx, tokenKey, credential) } func getToken(ctx context.Context) string { credential, ok := ctx.Value(tokenKey).(string) if !ok { return "" } return credential } ================================================ FILE: providers/dns/allinkl/internal/identity_test.go ================================================ package internal import ( "context" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupIdentifierClient(server *httptest.Server) (*Identifier, error) { client := NewIdentifier("user", "secret") client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil } func mockContext(t *testing.T) context.Context { t.Helper() return context.WithValue(t.Context(), tokenKey, "593959ca04f0de9689b586c6a647d15d") } func TestIdentifier_Authentication(t *testing.T) { client := servermock.NewBuilder[*Identifier](setupIdentifierClient). Route("POST /KasAuth.php", servermock.ResponseFromFixture("auth.xml"), servermock.CheckRequestBodyFromFixture("auth-request.xml"). IgnoreWhitespace()). Build(t) credentialToken, err := client.Authentication(t.Context(), 60, true) require.NoError(t, err) assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken) } func TestIdentifier_Authentication_error(t *testing.T) { client := servermock.NewBuilder[*Identifier](setupIdentifierClient). Route("POST /KasAuth.php", servermock.ResponseFromFixture("auth_fault.xml")). Build(t) _, err := client.Authentication(t.Context(), 60, false) require.Error(t, err) } ================================================ FILE: providers/dns/allinkl/internal/types.go ================================================ package internal import ( "bytes" "encoding/xml" "fmt" "io" ) // Trimmer trim all XML fields. type Trimmer struct { decoder *xml.Decoder } func (tr Trimmer) Token() (xml.Token, error) { t, err := tr.decoder.Token() if cd, ok := t.(xml.CharData); ok { t = xml.CharData(bytes.TrimSpace(cd)) } return t, err } // Fault a SOAP fault. type Fault struct { Code string `xml:"faultcode"` Message string `xml:"faultstring"` Actor string `xml:"faultactor"` Detail string `xml:"detail"` } func (f *Fault) Error() string { return fmt.Sprintf("%s: %s: %s: %s", f.Actor, f.Code, f.Message, f.Detail) } // KasResponse a KAS SOAP response. type KasResponse struct { Return *Item `xml:"return"` } // Item an item of the KAS SOAP response. type Item struct { Text string `xml:",chardata" json:"text,omitempty"` Type string `xml:"type,attr" json:"type,omitempty"` Raw string `xml:"nil,attr" json:"raw,omitempty"` Key *Item `xml:"key" json:"key,omitempty"` Value *Item `xml:"value" json:"value,omitempty"` Items []*Item `xml:"item" json:"item,omitempty"` } func decodeXML[T any](reader io.Reader) (*T, error) { raw, err := io.ReadAll(reader) if err != nil { return nil, fmt.Errorf("read response body: %w", err) } var result T err = xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))}).Decode(&result) if err != nil { return nil, fmt.Errorf("decode XML response: %w", err) } return &result, nil } ================================================ FILE: providers/dns/allinkl/internal/types_api.go ================================================ package internal import "encoding/xml" // kasAPIEnvelope a KAS API request envelope. const kasAPIEnvelope = ` %s ` // KasAPIResponseEnvelope a KAS envelope of the API response. type KasAPIResponseEnvelope struct { XMLName xml.Name `xml:"Envelope"` Body KasAPIBody `xml:"Body"` } type KasAPIBody struct { KasAPIResponse *KasResponse `xml:"KasApiResponse"` Fault *Fault `xml:"Fault"` } // --- type KasRequest struct { // Login the relevant KAS login. Login string `json:"kas_login,omitempty"` // AuthType the authentication type. AuthType string `json:"kas_auth_type,omitempty"` // AuthData the authentication data. AuthData string `json:"kas_auth_data,omitempty"` // Action API function. Action string `json:"kas_action,omitempty"` // RequestParams Parameters to the API function. RequestParams any `json:"KasRequestParams,omitempty"` } type DNSRequest struct { // ZoneHost the zone in question (must be a FQDN). ZoneHost string `json:"zone_host"` // RecordType the TYPE of the resource record (MX, A, AAAA etc.). RecordType string `json:"record_type"` // RecordName the NAME of the resource record. RecordName string `json:"record_name"` // RecordData the DATA of the resource record. RecordData string `json:"record_data"` // RecordAux the AUX of the resource record. RecordAux int `json:"record_aux"` } // --- type APIResponse[T any] struct { Response T `json:"Response" mapstructure:"Response"` } type GetDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay" mapstructure:"KasFloodDelay"` ReturnInfo []ReturnInfo `json:"ReturnInfo" mapstructure:"ReturnInfo"` ReturnString string `json:"ReturnString"` } type ReturnInfo struct { ID any `json:"record_id,omitempty" mapstructure:"record_id"` Zone string `json:"record_zone,omitempty" mapstructure:"record_zone"` Name string `json:"record_name,omitempty" mapstructure:"record_name"` Type string `json:"record_type,omitempty" mapstructure:"record_type"` Data string `json:"record_data,omitempty" mapstructure:"record_data"` Changeable string `json:"record_changeable,omitempty" mapstructure:"record_changeable"` Aux int `json:"record_aux,omitempty" mapstructure:"record_aux"` } type AddDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay" mapstructure:"KasFloodDelay"` ReturnInfo string `json:"ReturnInfo" mapstructure:"ReturnInfo"` ReturnString string `json:"ReturnString" mapstructure:"ReturnString"` } type DeleteDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay"` ReturnString string `json:"ReturnString"` // NOTE: ReturnInfo (!= ReturnString) doesn't seem to have a stable type } ================================================ FILE: providers/dns/allinkl/internal/types_auth.go ================================================ package internal import "encoding/xml" // kasAuthEnvelope a KAS authentication request envelope. const kasAuthEnvelope = ` %s ` // KasAuthEnvelope a KAS envelope of the authentication response. type KasAuthEnvelope struct { XMLName xml.Name `xml:"Envelope"` Body KasAuthBody `xml:"Body"` } type KasAuthBody struct { KasAuthResponse *KasResponse `xml:"KasAuthResponse"` Fault *Fault `xml:"Fault"` } // --- type AuthRequest struct { Login string `json:"kas_login,omitempty"` AuthData string `json:"kas_auth_data,omitempty"` AuthType string `json:"kas_auth_type,omitempty"` SessionLifetime int `json:"session_lifetime,omitempty"` SessionUpdateLifetime string `json:"session_update_lifetime,omitempty"` } ================================================ FILE: providers/dns/alwaysdata/alwaysdata.go ================================================ // Package alwaysdata implements a DNS provider for solving the DNS-01 challenge using Alwaysdata. package alwaysdata import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/alwaysdata/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "ALWAYSDATA_" EnvAPIKey = envNamespace + "API_KEY" EnvAccount = envNamespace + "ACCOUNT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string Account string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Alwaysdata. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("alwaysdata: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.Account = env.GetOrFile(EnvAccount) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Alwaysdata. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("alwaysdata: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIKey, config.Account) if err != nil { return nil, fmt.Errorf("alwaysdata: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("alwaysdata: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) if err != nil { return fmt.Errorf("alwaysdata: %w", err) } record := internal.RecordRequest{ DomainID: zone.ID, Name: subDomain, Type: "TXT", Value: info.Value, TTL: d.config.TTL, Annotation: "lego", } err = d.client.AddRecord(ctx, record) if err != nil { return fmt.Errorf("alwaysdata: add TXT record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("alwaysdata: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) if err != nil { return fmt.Errorf("alwaysdata: %w", err) } records, err := d.client.ListRecords(ctx, zone.ID, subDomain) if err != nil { return fmt.Errorf("alwaysdata: list records: %w", err) } for _, record := range records { if record.Type != "TXT" || record.Value != info.Value { continue } err = d.client.DeleteRecord(ctx, record.ID) if err != nil { return fmt.Errorf("alwaysdata: delete TXT record: %w", err) } } return 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 } func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.Domain, error) { domains, err := d.client.ListDomains(ctx) if err != nil { return nil, fmt.Errorf("list domains: %w", err) } for a := range dns01.UnFqdnDomainsSeq(fqdn) { for _, domain := range domains { if a == domain.Name { return &domain, nil } } } return nil, errors.New("domain not found") } ================================================ FILE: providers/dns/alwaysdata/alwaysdata.toml ================================================ Name = "Alwaysdata" Description = '''''' URL = "https://alwaysdata.com/" Code = "alwaysdata" Since = "v4.31.0" Example = ''' ALWAYSDATA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns alwaysdata -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ALWAYSDATA_API_KEY = "API Key" [Configuration.Additional] ALWAYSDATA_ACCOUNT = "Account name" ALWAYSDATA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ALWAYSDATA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ALWAYSDATA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" ALWAYSDATA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://help.alwaysdata.com/en/api/resources/" APIDocDomains = "https://api.alwaysdata.com/v1/domain/doc/" APIDocRecords = "https://api.alwaysdata.com/v1/record/doc/" APIExamples = "https://help.alwaysdata.com/en/api/examples/" ================================================ FILE: providers/dns/alwaysdata/alwaysdata_test.go ================================================ package alwaysdata import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvAccount).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "secret", }, }, { desc: "success with an account", envVars: map[string]string{ EnvAPIKey: "secret", EnvAccount: "foo", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "alwaysdata: some credentials information are missing: ALWAYSDATA_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string account string expected string }{ { desc: "success", apiKey: "secret", }, { desc: "success with an account", apiKey: "secret", account: "foo", }, { desc: "missing credentials", expected: "alwaysdata: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Account = test.account p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). WithBasicAuth("secret", ""), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /domain/", servermock.ResponseFromInternal("domains.json")). Route("POST /record/", servermock.Noop().WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromInternal("record_add-request.json")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /domain/", servermock.ResponseFromInternal("domains.json")). Route("GET /record/", servermock.ResponseFromInternal("records.json"), servermock.CheckQueryParameter().Strict(). With("domain", "132"). With("name", "_acme-challenge"), ). Route("DELETE /record/789/", servermock.Noop()). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/alwaysdata/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://api.alwaysdata.com/v1" // Client the Alwaysdata API client. type Client struct { apiKey string account string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey, account string) (*Client, error) { if apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, account: account, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { endpoint := c.BaseURL.JoinPath("domain", "/") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []Domain err = c.do(req, &result) if err != nil { return nil, err } return result, nil } func (c *Client) AddRecord(ctx context.Context, record RecordRequest) error { endpoint := c.BaseURL.JoinPath("record", "/") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } err = c.do(req, nil) if err != nil { return err } return nil } func (c *Client) DeleteRecord(ctx context.Context, recordID int64) error { endpoint := c.BaseURL.JoinPath("record", strconv.FormatInt(recordID, 10), "/") req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) ListRecords(ctx context.Context, domainID int64, name string) ([]Record, error) { endpoint := c.BaseURL.JoinPath("record", "/") query := endpoint.Query() query.Set("domain", strconv.FormatInt(domainID, 10)) query.Set("name", name) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []Record err = c.do(req, &result) if err != nil { return nil, err } return result, nil } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) user := c.apiKey if c.account != "" { user += "account=" + c.account } req.SetBasicAuth(user, "") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/alwaysdata/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret", "") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = clientdebug.Wrap(server.Client()) return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). WithBasicAuth("secret", ""), ) } func TestClient_ListDomains(t *testing.T) { client := mockBuilder(). Route("GET /domain/", servermock.ResponseFromFixture("domains.json")). Build(t) result, err := client.ListDomains(t.Context()) require.NoError(t, err) expected := []Domain{ {ID: 132, Name: "example.com", Annotation: "test"}, {ID: 133, Name: "example.net", IsInternal: true}, {ID: 134, Name: "example.org"}, } assert.Equal(t, expected, result) } func TestClient_AddRecord(t *testing.T) { t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true") client := mockBuilder(). Route("POST /record/", servermock.Noop().WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromFixture("record_add-request.json")). Build(t) record := RecordRequest{ DomainID: 132, Name: "_acme-challenge", Type: "TXT", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, Annotation: "lego", } err := client.AddRecord(t.Context(), record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /record/789/", servermock.Noop()). Build(t) err := client.DeleteRecord(t.Context(), 789) require.NoError(t, err) } func TestClient_ListRecords(t *testing.T) { client := mockBuilder(). Route("GET /record/", servermock.ResponseFromFixture("records.json"), servermock.CheckQueryParameter().Strict(). With("domain", "132"). With("name", "_acme-challenge"), ). Build(t) result, err := client.ListRecords(t.Context(), 132, "_acme-challenge") require.NoError(t, err) expected := []Record{ { ID: 789, Domain: &Domain{ Href: "/v1/domain/132/", }, Type: "TXT", Name: "_acme-challenge", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, Annotation: "lego", }, { ID: 11619270, Domain: &Domain{ Href: "/v1/domain/118935/", }, Name: "home", Type: "A", Value: "149.202.90.65", TTL: 300, IsUserDefined: true, IsActive: true, }, } assert.Equal(t, expected, result) } ================================================ FILE: providers/dns/alwaysdata/internal/fixtures/domains.json ================================================ [ { "id": 132, "name": "example.com", "annotation": "test" }, { "id": 133, "name": "example.net", "is_internal": true }, { "id": 134, "name": "example.org" } ] ================================================ FILE: providers/dns/alwaysdata/internal/fixtures/record_add-request.json ================================================ { "domain": 132, "name": "_acme-challenge", "type": "TXT", "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 120, "annotation": "lego" } ================================================ FILE: providers/dns/alwaysdata/internal/fixtures/records.json ================================================ [ { "id": 789, "domain": { "href": "/v1/domain/132/" }, "name": "_acme-challenge", "type": "TXT", "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 120, "annotation": "lego" }, { "id": 11619270, "domain": { "href": "/v1/domain/118935/" }, "type": "A", "name": "home", "value": "149.202.90.65", "priority": null, "ttl": 300, "href": "/v1/record/11619270/", "annotation": "", "is_user_defined": true, "is_active": true } ] ================================================ FILE: providers/dns/alwaysdata/internal/types.go ================================================ package internal type RecordRequest struct { ID int64 `json:"id,omitempty"` DomainID int64 `json:"domain,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value,omitempty"` TTL int `json:"ttl,omitempty"` Annotation string `json:"annotation,omitempty"` IsUserDefined bool `json:"is_user_defined,omitempty"` IsActive bool `json:"is_active,omitempty"` } type Record struct { ID int64 `json:"id,omitempty"` Domain *Domain `json:"domain,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` TTL int `json:"ttl,omitempty"` Annotation string `json:"annotation,omitempty"` IsUserDefined bool `json:"is_user_defined,omitempty"` IsActive bool `json:"is_active,omitempty"` } type Domain struct { ID int64 `json:"id,omitempty"` Href string `json:"href,omitempty"` Name string `json:"name,omitempty"` IsInternal bool `json:"is_internal,omitempty"` Annotation string `json:"annotation,omitempty"` } ================================================ FILE: providers/dns/anexia/anexia.go ================================================ // Package anexia implements a DNS provider for solving the DNS-01 challenge using Anexia CloudDNS. package anexia import ( "context" "errors" "fmt" "net/http" "net/url" "strconv" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/anexia/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "ANEXIA_" EnvToken = envNamespace + "TOKEN" EnvAPIURL = envNamespace + "API_URL" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string APIURL 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, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Anexia CloudDNS. // Credentials must be passed in the environment variable: ANEXIA_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("anexia: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] config.APIURL = env.GetOrFile(EnvAPIURL) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Anexia CloudDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("anexia: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("anexia: incomplete credentials, missing token") } client, err := internal.NewClient(config.Token) if err != nil { return nil, fmt.Errorf("anexia: %w", err) } if config.APIURL != "" { var err error client.BaseURL, err = url.Parse(config.APIURL) if err != nil { return nil, fmt.Errorf("anexia: %w", err) } } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("anexia: could not find zone for domain %q: %w", domain, err) } recordName, err := extractRecordName(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("anexia: %w", err) } zoneName := dns01.UnFqdn(authZone) recordReq := internal.Record{ Name: recordName, Type: "TXT", RData: info.Value, TTL: d.config.TTL, } // Ignores returned zone, because of UUID unstability. // https://github.com/go-acme/lego/pull/2675#issuecomment-3418678194 _, err = d.client.CreateRecord(ctx, zoneName, recordReq) if err != nil { return fmt.Errorf("anexia: new record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("anexia: could not find zone for domain %q: %w", domain, err) } recordName, err := extractRecordName(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("anexia: %w", err) } recordID, err := d.findRecordID(ctx, dns01.UnFqdn(authZone), recordName, info.Value) if err != nil { return fmt.Errorf("anexia: %w", err) } err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID) if err != nil { return fmt.Errorf("anexia: delete TXT record: %w", err) } return nil } // findRecordID attempts to find the record ID from the zone response. // If the record is not immediately available in the response, it retries by querying the zone. func (d *DNSProvider) findRecordID(ctx context.Context, zoneName, recordName, rdata string) (string, error) { return backoff.Retry(ctx, func() (string, error) { currentZone, err := d.client.GetZone(ctx, zoneName) if err != nil { return "", backoff.Permanent(fmt.Errorf("get zone: %w", err)) } recordID := findRecordIdentifier(currentZone, recordName, rdata) if recordID == "" { return "", fmt.Errorf("get record identifier: %w", err) } return recordID, nil }, backoff.WithBackOff(backoff.NewConstantBackOff(5*time.Second)), backoff.WithMaxElapsedTime(300*time.Second), ) } func findRecordIdentifier(zone *internal.Zone, recordName, rdata string) string { if len(zone.Revisions) == 0 { return "" } // Check the first revision (index 0) which should be the current one for _, record := range zone.Revisions[0].Records { if record.Name != recordName || record.Type != "TXT" { continue } if record.RData == rdata || record.RData == strconv.Quote(rdata) { return record.Identifier } } return "" } func extractRecordName(fqdn, authZone string) (string, error) { if dns01.UnFqdn(fqdn) == dns01.UnFqdn(authZone) { // "@" for the root domain instead of an empty string. return "@", nil } return dns01.ExtractSubDomain(fqdn, authZone) } ================================================ FILE: providers/dns/anexia/anexia.toml ================================================ Name = "Anexia CloudDNS" Description = '''''' URL = "https://www.anexia-it.com/" Code = "anexia" Since = "v4.28.0" Example = ''' ANEXIA_TOKEN=xxx \ lego --dns anexia -d '*.example.com' -d example.com run ''' Additional = ''' ## Description You need to create an API token in the [Anexia Engine](https://engine.anexia-it.com/). The token must have permissions to manage DNS zones and records. ''' [Configuration] [Configuration.Credentials] ANEXIA_TOKEN = "API token for Anexia Engine" [Configuration.Additional] ANEXIA_API_URL = "API endpoint URL (default: https://engine.anexia-it.com)" ANEXIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ANEXIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" ANEXIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" ANEXIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://engine.anexia-it.com/docs/en/module/clouddns/api" ================================================ FILE: providers/dns/anexia/anexia_test.go ================================================ package anexia import ( "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvToken, EnvAPIURL). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success with token", envVars: map[string]string{ EnvToken: "secret", }, }, { desc: "missing token", envVars: map[string]string{ EnvToken: "", }, expected: "anexia: some credentials information are missing: ANEXIA_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success with token", token: "secret", }, { desc: "missing token", token: "", expected: "anexia: incomplete credentials, missing token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Token = "secret" config.APIURL = server.URL config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithAuthorization("Token secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /api/clouddns/v1/zone.json/example.com/records", servermock.ResponseFromInternal("create_record.json"), servermock.CheckHeader(). WithContentType("application/json; charset=utf-8"), servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /api/clouddns/v1/zone.json/example.com", servermock.ResponseFromInternal("get_zone.json")). Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc", servermock.Noop()). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/anexia/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://engine.anexia-it.com" // Client the Anexia CloudDNS API client. type Client struct { token string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(token string) (*Client, error) { if token == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) CreateRecord(ctx context.Context, zoneName string, record Record) (*Zone, error) { endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } var zone Zone err = c.do(req, &zone) if err != nil { return nil, err } return &zone, nil } func (c *Client) DeleteRecord(ctx context.Context, zoneName, recordID string) error { endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName, "records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) GetZone(ctx context.Context, zoneName string) (*Zone, error) { endpoint := c.BaseURL.JoinPath("api", "clouddns", "v1", "zone.json", zoneName) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var zone Zone err = c.do(req, &zone) if err != nil { return nil, err } return &zone, nil } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) req.Header.Add("Authorization", fmt.Sprintf("Token %s", c.token)) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json; charset=utf-8") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/anexia/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithAuthorization("Token secret"), ) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /api/clouddns/v1/zone.json/example.com/records", servermock.ResponseFromFixture("create_record.json"), servermock.CheckHeader(). WithContentType("application/json; charset=utf-8"), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := Record{ Name: "_acme-challenge", RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300, Type: "TXT", } zone, err := client.CreateRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Zone{ Name: "example.com", TTL: 86400, ZoneName: "example.com", Revisions: []Revision{{ Identifier: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", Records: []Record{{ Identifier: "12345678-1234-1234-1234-123456789abc", Name: "_acme-challenge", RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300, Type: "TXT", }}, State: "deployed", }}, } assert.Equal(t, expected, zone) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc", servermock.Noop()). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "12345678-1234-1234-1234-123456789abc") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /api/clouddns/v1/zone.json/example.com/records/12345678-1234-1234-1234-123456789abc", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "12345678-1234-1234-1234-123456789abc") require.EqualError(t, err, "401: Unauthorized") } func TestClient_GetZone(t *testing.T) { client := mockBuilder(). Route("GET /api/clouddns/v1/zone.json/example.com", servermock.ResponseFromFixture("get_zone.json")). Build(t) zone, err := client.GetZone(t.Context(), "example.com") require.NoError(t, err) expected := &Zone{ Identifier: "fdb355ffd07c48aba3d4f6bf6a116296", Name: "example.com", TTL: 3600, ZoneName: "", Revisions: []Revision{{ Identifier: "eeed7e08-f1ad-442b-9e75-369a0958c7d8", Records: []Record{ { Identifier: "5ced498b-c89d-4487-824d-c03ded84f849", Immutable: true, Name: "@", RData: "acns02.xaas.systems.", Region: "9a1609af9dae4ce1a4ef63f51d305321", TTL: 3600, Type: "NS", }, { Identifier: "12345678-1234-1234-1234-123456789abc", Immutable: false, Name: "_acme-challenge", RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", Region: "", TTL: 300, Type: "TXT", }, }, State: "active", }}, } assert.Equal(t, expected, zone) } ================================================ FILE: providers/dns/anexia/internal/fixtures/create_record-request.json ================================================ { "name": "_acme-challenge", "type": "TXT", "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "region": "", "ttl": 300 } ================================================ FILE: providers/dns/anexia/internal/fixtures/create_record.json ================================================ { "name": "example.com", "zone_name": "example.com", "master": true, "dnssec_mode": "managed", "admin_email": "admin@example.com", "refresh": 10800, "retry": 3600, "expire": 604800, "ttl": 86400, "customer": "ANX12345", "created_at": "0001-01-01T00:00:00Z", "updated_at": "0001-01-01T00:00:00Z", "published_at": "0001-01-01T00:00:00Z", "is_editable": true, "validation_level": 0, "deployment_level": 0, "revisions": [ { "created_at": "0001-01-01T00:00:00Z", "identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "modified_at": "0001-01-01T00:00:00Z", "records": [ { "identifier": "12345678-1234-1234-1234-123456789abc", "immutable": false, "name": "_acme-challenge", "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "region": "", "ttl": 300, "type": "TXT" } ], "serial": 1, "state": "deployed" } ] } ================================================ FILE: providers/dns/anexia/internal/fixtures/create_record_incomplete.json ================================================ { "name": "example.com", "zone_name": "example.com", "master": true, "dnssec_mode": "managed", "admin_email": "admin@example.com", "refresh": 10800, "retry": 3600, "expire": 604800, "ttl": 86400, "customer": "ANX12345", "created_at": "0001-01-01T00:00:00Z", "updated_at": "0001-01-01T00:00:00Z", "published_at": "0001-01-01T00:00:00Z", "is_editable": true, "validation_level": 0, "deployment_level": 0, "revisions": [ { "created_at": "0001-01-01T00:00:00Z", "identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "modified_at": "0001-01-01T00:00:00Z", "records": [ { "immutable": false, "name": "_acme-challenge", "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "region": "", "ttl": 300, "type": "TXT" } ], "serial": 1, "state": "deployed" } ] } ================================================ FILE: providers/dns/anexia/internal/fixtures/error.json ================================================ { "error": { "code": 401, "message": "Unauthorized" } } ================================================ FILE: providers/dns/anexia/internal/fixtures/get_zone.json ================================================ { "identifier": "fdb355ffd07c48aba3d4f6bf6a116296", "admin_email": "admin@example.com", "created_at": "2019-02-06T10:02:07.000Z", "current_revision": "eeed7e08-f1ad-442b-9e75-369a0958c7d8", "deployment_level": 100, "dns_servers": [ { "server": "acns01.xaas.systems", "alias": null }, { "server": "acns04.xaas.systems", "alias": null }, { "server": "acns02.xaas.systems", "alias": null }, { "server": "acns03.xaas.systems", "alias": null }, { "server": "acns05.xaas.systems", "alias": null } ], "dnsCluster": null, "dnssec_ksk": null, "dnssec_mode": "unvalidated", "dnssec_sig_expires_at": null, "dnssec_zsk": null, "expire": 604800, "inherit_ns_from": null, "nameserver_set": null, "master": true, "master_ns": "acns02.xaas.systems.", "name": "example.com", "notify_allowed_ips": [ "127.0.0.1" ], "published_at": "2023-06-20T08:41:06.000Z", "refresh": 14400, "revisions": [ { "created_at": "2023-06-20T08:41:06.000000Z", "identifier": "eeed7e08-f1ad-442b-9e75-369a0958c7d8", "modified_at": "2023-06-20T08:41:06.000000Z", "records": [ { "identifier": "5ced498b-c89d-4487-824d-c03ded84f849", "immutable": true, "name": "@", "rdata": "acns02.xaas.systems.", "region": "9a1609af9dae4ce1a4ef63f51d305321", "ttl": 3600, "type": "NS", "options": null }, { "identifier": "12345678-1234-1234-1234-123456789abc", "immutable": false, "name": "_acme-challenge", "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "region": "", "ttl": 300, "Type": "TXT" } ], "serial": 14, "state": "active" } ], "retry": 3600, "ttl": 3600, "updated_at": "2020-06-04T18:34:22.000Z", "validation_level": 100, "whitelabel_config": null, "is_editable": true, "deploy_zone": "49459f420f614eb2a979fc7e961f83e6" } ================================================ FILE: providers/dns/anexia/internal/types.go ================================================ package internal import "fmt" type APIError struct { Details struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } func (a *APIError) Error() string { return fmt.Sprintf("%d: %s", a.Details.Code, a.Details.Message) } type Zone struct { Identifier string `json:"identifier,omitempty"` Name string `json:"name,omitempty"` TTL int `json:"ttl,omitempty"` ZoneName string `json:"zone_name,omitempty"` Revisions []Revision `json:"revisions,omitempty"` } type Revision struct { Identifier string `json:"identifier,omitempty"` Records []Record `json:"records,omitempty"` State string `json:"state,omitempty"` } type Record struct { Identifier string `json:"identifier,omitempty"` Immutable bool `json:"immutable,omitempty"` Name string `json:"name,omitempty"` RData string `json:"rdata,omitempty"` Region string `json:"region"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` } ================================================ FILE: providers/dns/artfiles/artfiles.go ================================================ // Package artfiles implements a DNS provider for solving the DNS-01 challenge using ArtFiles. package artfiles import ( "context" "encoding/json" "errors" "fmt" "net/http" "slices" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/artfiles/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "ARTFILES_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" 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 { Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for ArtFiles. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("artfiles: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ArtFiles. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("artfiles: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.Username, config.Password) if err != nil { return nil, fmt.Errorf("artfiles: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("artfiles: %w", err) } records, err := d.client.GetRecords(ctx, zone) if err != nil { return fmt.Errorf("artfiles: get records: %w", err) } rv := internal.RecordValue{} if len(records["TXT"]) > 0 { var raw string err = json.Unmarshal(records["TXT"], &raw) if err != nil { return fmt.Errorf("artfiles: unmarshal TXT records: %w", err) } rv = internal.ParseRecordValue(raw) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("artfiles: %w", err) } rv.Add(subDomain, info.Value) err = d.client.SetRecords(ctx, zone, "TXT", rv) if err != nil { return fmt.Errorf("artfiles: set TXT records: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("artfiles: %w", err) } records, err := d.client.GetRecords(ctx, zone) if err != nil { return fmt.Errorf("artfiles: get records: %w", err) } var raw string err = json.Unmarshal(records["TXT"], &raw) if err != nil { return fmt.Errorf("artfiles: unmarshal TXT records: %w", err) } rv := internal.ParseRecordValue(raw) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("artfiles: %w", err) } rv.RemoveValue(subDomain, info.Value) err = d.client.SetRecords(ctx, zone, "TXT", rv) if err != nil { return fmt.Errorf("artfiles: set TXT records: %w", err) } return 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 } func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { domains, err := d.client.GetDomains(ctx) if err != nil { return "", fmt.Errorf("artfiles: get domains: %w", err) } var zone string for s := range dns01.UnFqdnDomainsSeq(fqdn) { if slices.Contains(domains, s) { zone = s } } if zone == "" { return "", fmt.Errorf("artfiles: could not find the zone for domain %q", fqdn) } return zone, nil } ================================================ FILE: providers/dns/artfiles/artfiles.toml ================================================ Name = "ArtFiles" Description = '''''' URL = "https://www.artfiles.de/extras/domains/" Code = "artfiles" Since = "v4.32.0" Example = ''' ARTFILES_USERNAME="xxx" \ ARTFILES_PASSWORD="yyy" \ lego --dns artfiles -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ARTFILES_USERNAME = "API username" ARTFILES_PASSWORD = "API password" [Configuration.Additional] ARTFILES_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ARTFILES_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" ARTFILES_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" ARTFILES_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://support.artfiles.de/DCP-API#dns" ================================================ FILE: providers/dns/artfiles/artfiles_test.go ================================================ package artfiles import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", }, }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "secret", }, expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "", }, expected: "artfiles: some credentials information are missing: ARTFILES_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME,ARTFILES_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "user", password: "secret", }, { desc: "missing username", password: "secret", expected: "artfiles: credentials missing", }, { desc: "missing Example", username: "user", expected: "artfiles: credentials missing", }, { desc: "missing credentials", expected: "artfiles: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func 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) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Username = "user" config.Password = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithBasicAuth("user", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /domain/get_domains.html", servermock.ResponseFromInternal("domains.txt"), ). Route("GET /dns/get_dns.html", servermock.ResponseFromInternal("get_dns.json"), servermock.CheckQueryParameter().Strict(). With("domain", "example.com"), ). Route("POST /dns/set_dns.html", servermock.ResponseFromInternal("set_dns.json"), servermock.CheckQueryParameter().Strict(). With("TXT", `@ "v=spf1 a mx ~all" _acme-challenge "TheAcmeChallenge" _acme-challenge "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" _dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" _mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" _smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`). With("domain", "example.com"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /domain/get_domains.html", servermock.ResponseFromInternal("domains.txt"), ). Route("GET /dns/get_dns.html", servermock.ResponseFromInternal("get_dns.json"), servermock.CheckQueryParameter().Strict(). With("domain", "example.com"), ). Route("POST /dns/set_dns.html", servermock.ResponseFromInternal("set_dns.json"), servermock.CheckQueryParameter().Strict(). With("TXT", `@ "v=spf1 a mx ~all" _acme-challenge "TheAcmeChallenge" _dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" _mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" _smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`). With("domain", "example.com"), ). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/artfiles/internal/client.go ================================================ package internal import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://dcp.c.artfiles.de/api/" // Client the ArtFiles API client. type Client struct { username string password string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(username, password string) (*Client, error) { if username == "" || password == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ username: username, password: password, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) GetDomains(ctx context.Context) ([]string, error) { endpoint := c.BaseURL.JoinPath("domain", "get_domains.html") req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } raw, err := c.do(req) if err != nil { return nil, err } return parseDomains(string(raw)) } func (c *Client) GetRecords(ctx context.Context, domain string) (map[string]json.RawMessage, error) { endpoint := c.BaseURL.JoinPath("dns", "get_dns.html") query := endpoint.Query() query.Set("domain", domain) endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } raw, err := c.do(req) if err != nil { return nil, err } var result Records err = json.Unmarshal(raw, &result) if err != nil { return nil, errutils.NewUnmarshalError(req, http.StatusOK, raw, err) } return result.Data, nil } func (c *Client) SetRecords(ctx context.Context, domain, rType string, value RecordValue) error { endpoint := c.BaseURL.JoinPath("dns", "set_dns.html") query := endpoint.Query() query.Set("domain", domain) query.Set(rType, value.String()) endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) if err != nil { return fmt.Errorf("unable to create request: %w", err) } _, err = c.do(req) return err } func (c *Client) do(req *http.Request) ([]byte, error) { useragent.SetHeader(req.Header) req.SetBasicAuth(c.username, c.password) if req.Method == http.MethodPost { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } if resp.StatusCode/100 != 2 { return nil, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return raw, nil } ================================================ FILE: providers/dns/artfiles/internal/client_test.go ================================================ package internal import ( "encoding/json" "net/http/httptest" "net/url" "strconv" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithBasicAuth("user", "secret"), ) } func TestClient_GetDomains(t *testing.T) { client := mockBuilder(). Route("GET /domain/get_domains.html", servermock.ResponseFromFixture("domains.txt"), ). Build(t) zones, err := client.GetDomains(t.Context()) require.NoError(t, err) expected := []string{"example.com", "example.org", "example.net"} assert.Equal(t, expected, zones) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/get_dns.html", servermock.ResponseFromFixture("get_dns.json"), servermock.CheckQueryParameter().Strict(). With("domain", "example.com"), ). Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) expected := map[string]json.RawMessage{ "A": json.RawMessage(strconv.Quote("sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4")), "AAAA": json.RawMessage(strconv.Quote("")), "CAA": json.RawMessage(strconv.Quote("@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"")), "CName": json.RawMessage(strconv.Quote("some cname.to.example.tld.")), "MX": json.RawMessage(strconv.Quote("10 mail.example.tld.")), "SRV": json.RawMessage(strconv.Quote("_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .")), "TLSA": json.RawMessage(strconv.Quote("_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2")), "TXT": json.RawMessage(strconv.Quote("_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"")), "TTL": json.RawMessage("3600"), "comment": json.RawMessage(strconv.Quote("TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php")), "nameserver": json.RawMessage(strconv.Quote("auth1.artfiles.de.\nauth2.artfiles.de.")), } assert.Equal(t, expected, records) } func TestClient_SetRecords(t *testing.T) { client := mockBuilder(). Route("POST /dns/set_dns.html", servermock.ResponseFromFixture("set_dns.json"), servermock.CheckQueryParameter().Strict(). With("TXT", "a b\nc \"d\""). With("domain", "example.com"), ). Build(t) err := client.SetRecords(t.Context(), "example.com", "TXT", RecordValue{"c": []string{`"d"`}, "a": []string{"b"}}) require.NoError(t, err) } ================================================ FILE: providers/dns/artfiles/internal/fixtures/domains.txt ================================================ example.com normal 2026-10-01 2017-09-18 163477 example.org normal 2026-08-01 2016-07-07 156216 example.net normal 2026-07-01 2017-06-06 162462 ================================================ FILE: providers/dns/artfiles/internal/fixtures/get_dns.json ================================================ { "data": { "SRV": "_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .", "AAAA": "", "MX": "10 mail.example.tld.", "CAA": "@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"", "TTL": 3600, "comment": "TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php", "TXT": "_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"", "A": "sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4", "nameserver": "auth1.artfiles.de.\nauth2.artfiles.de.", "CName": "some cname.to.example.tld.", "TLSA": "_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2" }, "status": "OK" } ================================================ FILE: providers/dns/artfiles/internal/fixtures/set_dns.json ================================================ { "status": "OK", "error": "" } ================================================ FILE: providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt ================================================ _dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" _mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" _smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" @ "v=spf1 a mx ~all" selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff" _acme-challenge "xxx" _acme-challenge "yyy" ================================================ FILE: providers/dns/artfiles/internal/fixtures/txt_record.txt ================================================ _dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" _mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" _smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" @ "v=spf1 a mx ~all" selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff" _acme-challenge "TheAcmeChallenge" ================================================ FILE: providers/dns/artfiles/internal/types.go ================================================ package internal import ( "encoding/csv" "encoding/json" "errors" "io" "maps" "slices" "strconv" "strings" "unicode" ) type Records struct { Data map[string]json.RawMessage `json:"data"` Status string `json:"status"` } type RecordValue map[string][]string func (r RecordValue) Set(key, value string) { r[key] = []string{strconv.Quote(value)} } func (r RecordValue) Add(key, value string) { r[key] = append(r[key], strconv.Quote(value)) } func (r RecordValue) Delete(key string) { delete(r, key) } func (r RecordValue) RemoveValue(key, value string) { if len(r[key]) == 0 { return } quotedValue := strconv.Quote(value) var data []string for _, s := range r[key] { if s != quotedValue { data = append(data, s) } } r[key] = data if len(r[key]) == 0 { r.Delete(key) } } func (r RecordValue) String() string { var parts []string for _, key := range slices.Sorted(maps.Keys(r)) { for _, s := range r[key] { parts = append(parts, key+" "+s) } } return strings.Join(parts, "\n") } func ParseRecordValue(lines string) RecordValue { data := make(RecordValue) for line := range strings.Lines(lines) { line = strings.TrimSpace(line) idx := strings.IndexFunc(line, unicode.IsSpace) data[line[:idx]] = append(data[line[:idx]], line[idx+1:]) } return data } func parseDomains(input string) ([]string, error) { reader := csv.NewReader(strings.NewReader(input)) reader.Comma = '\t' reader.TrimLeadingSpace = true reader.LazyQuotes = true var data []string for { record, err := reader.Read() if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } if len(record) < 1 { // Malformed line continue } data = append(data, record[0]) } return data, nil } ================================================ FILE: providers/dns/artfiles/internal/types_test.go ================================================ package internal import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRecordValue_Set(t *testing.T) { rv := make(RecordValue) rv.Set("a", "1") rv.Set("b", "2") rv.Set("b", "3") assert.Equal(t, "a \"1\"\nb \"3\"", rv.String()) } func TestRecordValue_Add(t *testing.T) { rv := make(RecordValue) rv.Add("a", "1") rv.Add("b", "2") rv.Add("b", "3") assert.Equal(t, "a \"1\"\nb \"2\"\nb \"3\"", rv.String()) } func TestRecordValue_Delete(t *testing.T) { rv := make(RecordValue) rv.Set("a", "1") rv.Add("b", "2") rv.Delete("b") assert.Equal(t, "a \"1\"", rv.String()) } func TestRecordValue_RemoveValue(t *testing.T) { testCases := []struct { desc string data map[string][]string toRemove map[string][]string expected string }{ { desc: "remove the only value", data: map[string][]string{ "a": {"1"}, }, toRemove: map[string][]string{ "a": {"1"}, }, expected: ``, }, { desc: "remove value in the middle", data: map[string][]string{ "a": {"1", "2", "3"}, }, toRemove: map[string][]string{ "a": {"2"}, }, expected: "a \"1\"\na \"3\"", }, { desc: "remove value at the beginning", data: map[string][]string{ "a": {"1", "2", "3"}, }, toRemove: map[string][]string{ "a": {"1"}, }, expected: "a \"2\"\na \"3\"", }, { desc: "remove value at the end", data: map[string][]string{ "a": {"1", "2", "3"}, }, toRemove: map[string][]string{ "a": {"3"}, }, expected: "a \"1\"\na \"2\"", }, { desc: "remove all (delete)", data: map[string][]string{ "a": {"1", "2", "3"}, }, toRemove: map[string][]string{ "a": {"1", "2", "3"}, }, expected: ``, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() rv := make(RecordValue) for k, values := range test.data { for _, v := range values { rv.Add(k, v) } } for k, values := range test.toRemove { for _, v := range values { rv.RemoveValue(k, v) } } assert.Equal(t, test.expected, rv.String()) }) } } func TestParseRecordValue(t *testing.T) { testCases := []struct { desc string filename string expected RecordValue }{ { desc: "simple", filename: "txt_record.txt", expected: RecordValue{ "@": []string{"\"v=spf1 a mx ~all\""}, "_acme-challenge": []string{"\"TheAcmeChallenge\""}, "_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""}, "_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""}, "_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""}, "selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""}, "selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""}, }, }, { desc: "multiple values with the same key", filename: "txt_record-multiple.txt", expected: RecordValue{ "@": []string{"\"v=spf1 a mx ~all\""}, "_acme-challenge": []string{"\"xxx\"", "\"yyy\""}, "_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""}, "_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""}, "_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""}, "selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""}, "selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""}, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() file, err := os.ReadFile(filepath.Join("fixtures", test.filename)) require.NoError(t, err) data := ParseRecordValue(string(file)) assert.Equal(t, test.expected, data) }) } } func Test_parseDomains(t *testing.T) { file, err := os.ReadFile(filepath.FromSlash("./fixtures/domains.txt")) require.NoError(t, err) domains, err := parseDomains(string(file)) require.NoError(t, err) expected := []string{"example.com", "example.org", "example.net"} assert.Equal(t, expected, domains) } ================================================ FILE: providers/dns/arvancloud/arvancloud.go ================================================ // Package arvancloud implements a DNS provider for solving the DNS-01 challenge using ArvanCloud DNS. package arvancloud import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/arvancloud/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "ARVANCLOUD_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 600 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for ArvanCloud. // Credentials must be passed in the environment variable: ARVANCLOUD_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("arvancloud: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ArvanCloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("arvancloud: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("arvancloud: credentials missing") } if config.TTL < minTTL { return nil, fmt.Errorf("arvancloud: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("arvancloud: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("arvancloud: %w", err) } record := internal.DNSRecord{ Type: "txt", Name: subDomain, Value: internal.TXTRecordValue{Text: info.Value}, TTL: d.config.TTL, UpstreamHTTPS: "default", IPFilterMode: &internal.IPFilterMode{ Count: "single", GeoFilter: "none", Order: "none", }, } newRecord, err := d.client.CreateRecord(context.Background(), authZone, record) if err != nil { return fmt.Errorf("arvancloud: failed to add TXT record: fqdn=%s: %w", info.EffectiveFQDN, err) } d.recordIDsMu.Lock() d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("arvancloud: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("arvancloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } if err := d.client.DeleteRecord(context.Background(), authZone, recordID); err != nil { return fmt.Errorf("arvancloud: failed to delete TXT record: id=%s: %w", recordID, err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/arvancloud/arvancloud.toml ================================================ Name = "ArvanCloud" Description = '''''' URL = "https://arvancloud.ir" Code = "arvancloud" Since = "v3.8.0" Example = ''' ARVANCLOUD_API_KEY="Apikey xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ lego --dns arvancloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ARVANCLOUD_API_KEY = "API key" [Configuration.Additional] ARVANCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ARVANCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" ARVANCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" ARVANCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.arvancloud.ir/docs/api/cdn/4.0" ================================================ FILE: providers/dns/arvancloud/arvancloud_test.go ================================================ package arvancloud import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", }, expected: "arvancloud: some credentials information are missing: ARVANCLOUD_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string ttl int expected string }{ { desc: "success", ttl: minTTL, apiKey: "123", }, { desc: "missing credentials", ttl: minTTL, expected: "arvancloud: credentials missing", }, { desc: "invalid TTL", apiKey: "123", ttl: 60, expected: "arvancloud: invalid TTL, TTL (60) must be greater than 600", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.TTL = test.ttl p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/arvancloud/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // defaultBaseURL represents the API endpoint to call. const defaultBaseURL = "https://napi.arvancloud.ir" const authorizationHeader = "Authorization" // Client the ArvanCloud client. type Client struct { apiKey string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetTxtRecord gets a TXT record. func (c *Client) GetTxtRecord(ctx context.Context, domain, name, value string) (*DNSRecord, error) { records, err := c.getRecords(ctx, domain, name) if err != nil { return nil, err } for _, record := range records { if equalsTXTRecord(record, name, value) { return &record, nil } } return nil, fmt.Errorf("could not find record: Domain: %s; Record: %s", domain, name) } // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.list func (c *Client) getRecords(ctx context.Context, domain, search string) ([]DNSRecord, error) { endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records") if search != "" { query := endpoint.Query() query.Set("search", strings.ReplaceAll(search, "_", "")) endpoint.RawQuery = query.Encode() } req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } response := &apiResponse[[]DNSRecord]{} err = c.do(req, http.StatusOK, response) if err != nil { return nil, fmt.Errorf("could not get records %s: Domain: %s: %w", search, domain, err) } return response.Data, nil } // CreateRecord creates a DNS record. // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.create func (c *Client) CreateRecord(ctx context.Context, domain string, record DNSRecord) (*DNSRecord, error) { endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } response := &apiResponse[*DNSRecord]{} err = c.do(req, http.StatusCreated, response) if err != nil { return nil, fmt.Errorf("could not create record; Domain: %s: %w", domain, err) } return response.Data, nil } // DeleteRecord deletes a DNS record. // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.remove func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error { endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records", id) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } err = c.do(req, http.StatusOK, nil) if err != nil { return fmt.Errorf("could not delete record %s; Domain: %s: %w", id, domain, err) } return nil } func (c *Client) do(req *http.Request, expectedStatus int, result any) error { req.Header.Set(authorizationHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != expectedStatus { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func equalsTXTRecord(record DNSRecord, name, value string) bool { if record.Type != "txt" { return false } if record.Name != name { return false } data, ok := record.Value.(map[string]any) if !ok { return false } return data["text"] == value } ================================================ FILE: providers/dns/arvancloud/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder(apiKey string) *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(apiKey) client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization(apiKey)) } func TestClient_GetTxtRecord(t *testing.T) { const apiKey = "myKeyA" const domain = "example.com" client := mockBuilder(apiKey). Route("GET /cdn/4.0/domains/"+domain+"/dns-records", servermock.ResponseFromFixture("get_txt_record.json"), servermock.CheckQueryParameter().With("search", "acme-challenge")). Build(t) _, err := client.GetTxtRecord(t.Context(), domain, "_acme-challenge", "txtxtxt") require.NoError(t, err) } func TestClient_CreateRecord(t *testing.T) { const apiKey = "myKeyB" const domain = "example.com" client := mockBuilder(apiKey). Route("POST /cdn/4.0/domains/"+domain+"/dns-records", servermock.ResponseFromFixture("create_txt_record.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := DNSRecord{ Name: "_acme-challenge", Type: "txt", Value: &TXTRecordValue{Text: "txtxtxt"}, TTL: 600, } newRecord, err := client.CreateRecord(t.Context(), domain, record) require.NoError(t, err) expected := &DNSRecord{ ID: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", Type: "txt", Value: map[string]any{"text": "txtxtxt"}, Name: "_acme-challenge", TTL: 120, UpstreamHTTPS: "default", IPFilterMode: &IPFilterMode{ Count: "single", Order: "none", GeoFilter: "none", }, } assert.Equal(t, expected, newRecord) } func TestClient_DeleteRecord(t *testing.T) { const apiKey = "myKeyC" const ( domain = "example.com" recordID = "recordId" ) client := mockBuilder(apiKey). Route("DELETE /cdn/4.0/domains/"+domain+"/dns-records/"+recordID, nil). Build(t) err := client.DeleteRecord(t.Context(), domain, recordID) require.NoError(t, err) } ================================================ FILE: providers/dns/arvancloud/internal/fixtures/create_record-request.json ================================================ { "type": "txt", "value": { "text": "txtxtxt" }, "name": "_acme-challenge", "ttl": 600 } ================================================ FILE: providers/dns/arvancloud/internal/fixtures/create_txt_record.json ================================================ { "data": { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "txt", "name": "_acme-challenge", "value": { "text": "txtxtxt" }, "ttl": 120, "cloud": false, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": true, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-27T23:57:02Z", "updated_at": "2020-05-27T23:57:02Z" }, "message": "DNS record created successfully" } ================================================ FILE: providers/dns/arvancloud/internal/fixtures/get_txt_record.json ================================================ { "data": [ { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "a", "name": "@", "value": [ { "ip": "xx.xxx.xxx.xxx", "port": null, "weight": 1, "country": "" } ], "ttl": 120, "cloud": true, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": true, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-19T15:05:12Z", "updated_at": "2020-05-23T22:06:00Z" }, { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "a", "name": "www", "value": [ { "ip": "xx.xxx.xxx.xxx", "port": null, "weight": 1, "country": "" } ], "ttl": 120, "cloud": true, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": true, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-19T15:05:12Z", "updated_at": "2020-05-23T22:05:55Z" }, { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "a", "name": "thatcher", "value": [ { "ip": "xx.xxx.xxx.xxx", "port": null, "weight": 100, "country": "" } ], "ttl": 120, "cloud": false, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": true, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-20T18:45:10Z", "updated_at": "2020-05-21T13:19:46Z" }, { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "a", "name": "api", "value": [ { "ip": "xx.xxx.xxx.xxx", "port": null, "weight": 100, "country": "" } ], "ttl": 120, "cloud": true, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": true, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-20T18:45:35Z", "updated_at": "2020-05-22T20:22:27Z" }, { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "a", "name": "rock", "value": [ { "ip": "xx.xxx.xxx.xxx", "port": null, "weight": 100, "country": "" } ], "ttl": 120, "cloud": true, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": true, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-22T10:29:27Z", "updated_at": "2020-05-22T13:35:26Z" }, { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "ns", "name": "@", "value": { "host": "z.ns.arvancdn.com." }, "ttl": 7200, "cloud": false, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": false, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-19T15:05:09Z", "updated_at": "2020-05-19T15:05:09Z" }, { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "ns", "name": "@", "value": { "host": "g.ns.arvancdn.com." }, "ttl": 7200, "cloud": false, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": false, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-19T15:05:12Z", "updated_at": "2020-05-19T15:05:12Z" }, { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "type": "txt", "name": "_acme-challenge", "value": { "text": "txtxtxt" }, "ttl": 120, "cloud": false, "upstream_https": "default", "ip_filter_mode": { "count": "single", "order": "none", "geo_filter": "none" }, "can_delete": true, "health_check_status": false, "health_check_setting": { "protocol": "http", "port": "", "uri": "" }, "created_at": "2020-05-27T20:53:54Z", "updated_at": "2020-05-27T20:53:54Z" } ], "links": { "first": "https://napi.arvancloud.ir/4.0/domains/example.ir/dns-records?page=1", "last": "https://napi.arvancloud.ir/4.0/domains/example.ir/dns-records?page=1", "prev": null, "next": null }, "meta": { "current_page": 1, "from": 1, "last_page": 1, "path": "https://napi.arvancloud.ir/4.0/domains/example.ir/dns-records", "per_page": 300, "to": 8, "total": 8 } } ================================================ FILE: providers/dns/arvancloud/internal/types.go ================================================ package internal type apiResponse[T any] struct { Message string `json:"message"` Data T `json:"data"` } // DNSRecord a DNS record. type DNSRecord struct { ID string `json:"id,omitempty"` Type string `json:"type"` Value any `json:"value,omitempty"` Name string `json:"name,omitempty"` TTL int `json:"ttl,omitempty"` UpstreamHTTPS string `json:"upstream_https,omitempty"` IPFilterMode *IPFilterMode `json:"ip_filter_mode,omitempty"` } // TXTRecordValue represents a TXT record value. type TXTRecordValue struct { Text string `json:"text,omitempty"` // only for TXT Record. } // IPFilterMode a DNS ip_filter_mode. type IPFilterMode struct { Count string `json:"count,omitempty"` Order string `json:"order,omitempty"` GeoFilter string `json:"geo_filter,omitempty"` } ================================================ FILE: providers/dns/auroradns/auroradns.go ================================================ // Package auroradns implements a DNS provider for solving the DNS-01 challenge using Aurora DNS. package auroradns import ( "errors" "fmt" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/miekg/dns" "github.com/nrdcg/auroradns" ) // Environment variables names. const ( envNamespace = "AURORA_" EnvAPIKey = envNamespace + "API_KEY" EnvSecret = envNamespace + "SECRET" EnvEndpoint = envNamespace + "ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) const defaultBaseURL = "https://api.auroradns.eu" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIKey string Secret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *auroradns.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for AuroraDNS. // Credentials must be passed in the environment variables: // AURORA_API_KEY and AURORA_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvSecret) if err != nil { return nil, fmt.Errorf("aurora: %w", err) } config := NewDefaultConfig() config.BaseURL = env.GetOrFile(EnvEndpoint) config.APIKey = values[EnvAPIKey] config.Secret = values[EnvSecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for AuroraDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("aurora: the configuration of the DNS provider is nil") } if config.APIKey == "" || config.Secret == "" { return nil, errors.New("aurora: some credentials information are missing") } if config.BaseURL == "" { config.BaseURL = defaultBaseURL } tr, err := auroradns.NewTokenTransport(config.APIKey, config.Secret) if err != nil { return nil, fmt.Errorf("aurora: %w", err) } client, err := auroradns.NewClient(clientdebug.Wrap(tr.Client()), auroradns.WithBaseURL(config.BaseURL)) if err != nil { return nil, fmt.Errorf("aurora: %w", err) } return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("aurora: could not find zone for domain %q: %w", domain, err) } // 1. Aurora will happily create the TXT record when it is provided a fqdn, // but it will only appear in the control panel and will not be // propagated to DNS servers. Extract and use subdomain instead. // 2. A trailing dot in the fqdn will cause Aurora to add a trailing dot to // the subdomain, resulting in _acme-challenge.. rather // than _acme-challenge. subdomain := info.EffectiveFQDN[0 : len(info.EffectiveFQDN)-len(authZone)-1] authZone = dns01.UnFqdn(authZone) zone, err := d.getZoneInformationByName(authZone) if err != nil { return fmt.Errorf("aurora: could not create record: %w", err) } record := auroradns.Record{ RecordType: "TXT", Name: subdomain, Content: info.Value, TTL: d.config.TTL, } newRecord, _, err := d.client.CreateRecord(zone.ID, record) if err != nil { return fmt.Errorf("aurora: could not create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes a given record that was generated by Present. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("aurora: unknown recordID for %q", info.EffectiveFQDN) } authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("aurora: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) zone, err := d.getZoneInformationByName(authZone) if err != nil { return fmt.Errorf("aurora: %w", err) } _, _, err = d.client.DeleteRecord(zone.ID, recordID) if err != nil { return fmt.Errorf("aurora: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } func (d *DNSProvider) getZoneInformationByName(name string) (auroradns.Zone, error) { zs, _, err := d.client.ListZones() if err != nil { return auroradns.Zone{}, err } for _, element := range zs { if element.Name == name { return element, nil } } return auroradns.Zone{}, errors.New("could not find Zone record") } ================================================ FILE: providers/dns/auroradns/auroradns.toml ================================================ Name = "Aurora DNS" Description = '''''' URL = "https://www.pcextreme.com/dns-health-checks" Code = "auroradns" Since = "v0.4.0" Example = ''' AURORA_API_KEY=xxxxx \ AURORA_SECRET=yyyyyy \ lego --dns auroradns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] AURORA_API_KEY = "API key or username to used" AURORA_SECRET = "Secret password to be used" [Configuration.Additional] AURORA_ENDPOINT = "API endpoint URL" AURORA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" AURORA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" AURORA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" [Links] API = "https://libcloud.readthedocs.io/en/latest/dns/drivers/auroradns.html#api-docs" GoClient = "https://github.com/nrdcg/auroradns" ================================================ FILE: providers/dns/auroradns/auroradns_test.go ================================================ package auroradns import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/nrdcg/auroradns" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret) func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = "asdf1234" config.Secret = "key" config.BaseURL = server.URL return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithContentType("application/json"). WithRegexp("Authorization", `AuroraDNSv1 .+`). WithRegexp("X-Auroradns-Date", `[0-9TZ]+`)) } func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvSecret: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvSecret: "", }, expected: "aurora: some credentials information are missing: AURORA_API_KEY,AURORA_SECRET", }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", EnvSecret: "456", }, expected: "aurora: some credentials information are missing: AURORA_API_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAPIKey: "123", EnvSecret: "", }, expected: "aurora: some credentials information are missing: AURORA_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string secret string expected string }{ { desc: "success", apiKey: "123", secret: "456", }, { desc: "missing credentials", apiKey: "", secret: "", expected: "aurora: some credentials information are missing", }, { desc: "missing user id", apiKey: "", secret: "456", expected: "aurora: some credentials information are missing", }, { desc: "missing key", apiKey: "123", secret: "", expected: "aurora: some credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Secret = test.secret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /zones", servermock.JSONEncode([]auroradns.Zone{{ ID: "c56a4180-65aa-42ec-a945-5fd21dec0538", Name: "example.com", }}). WithStatusCode(http.StatusCreated)). Route("POST /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", servermock.JSONEncode(auroradns.Record{ ID: "ec56a4180-65aa-42ec-a945-5fd21dec0538", RecordType: "TXT", Name: "_acme-challenge", TTL: 300, }). WithStatusCode(http.StatusCreated)). Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /zones", servermock.JSONEncode([]auroradns.Zone{{ ID: "c56a4180-65aa-42ec-a945-5fd21dec0538", Name: "example.com", }}). WithStatusCode(http.StatusCreated)). Route("POST /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", servermock.JSONEncode(auroradns.Record{ ID: "ec56a4180-65aa-42ec-a945-5fd21dec0538", RecordType: "TXT", Name: "_acme-challenge", TTL: 300, }). WithStatusCode(http.StatusCreated)). Route("DELETE /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records/ec56a4180-65aa-42ec-a945-5fd21dec0538", servermock.RawStringResponse("{}"). WithStatusCode(http.StatusCreated)). Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err) err = provider.CleanUp("example.com", "", "foobar") require.NoError(t, err) } ================================================ FILE: providers/dns/autodns/autodns.go ================================================ // Package autodns implements a DNS provider for solving the DNS-01 challenge using auto DNS. package autodns import ( "context" "errors" "fmt" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/autodns/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "AUTODNS_" EnvAPIUser = envNamespace + "API_USER" EnvAPIPassword = envNamespace + "API_PASSWORD" EnvAPIEndpoint = envNamespace + "ENDPOINT" EnvAPIEndpointContext = envNamespace + "CONTEXT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL Username string Password string Context int TTL int PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { endpoint, _ := url.Parse(env.GetOrDefaultString(EnvAPIEndpoint, internal.DefaultEndpoint)) return &Config{ Endpoint: endpoint, Context: env.GetOrDefaultInt(EnvAPIEndpointContext, internal.DefaultEndpointContext), TTL: env.GetOrDefaultInt(EnvTTL, 600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for autoDNS. // Credentials must be passed in the environment variables. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIUser, EnvAPIPassword) if err != nil { return nil, fmt.Errorf("autodns: %w", err) } config := NewDefaultConfig() config.Username = values[EnvAPIUser] config.Password = values[EnvAPIPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for autoDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("autodns: config is nil") } if config.Username == "" { return nil, errors.New("autodns: missing user") } if config.Password == "" { return nil, errors.New("autodns: missing password") } client := internal.NewClient(config.Username, config.Password, config.Context) if config.Endpoint != nil { client.BaseURL = config.Endpoint } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) records := []*internal.ResourceRecord{{ Name: info.EffectiveFQDN, TTL: int64(d.config.TTL), Type: "TXT", Value: info.Value, }} _, err := d.client.AddRecords(context.Background(), info.EffectiveFQDN, records) if err != nil { return fmt.Errorf("autodns: add record: %w", err) } return nil } // CleanUp removes the TXT record previously created. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) records := []*internal.ResourceRecord{{ Name: info.EffectiveFQDN, TTL: int64(d.config.TTL), Type: "TXT", Value: info.Value, }} _, err := d.client.RemoveRecords(context.Background(), info.EffectiveFQDN, records) if err != nil { return fmt.Errorf("autodns: remove record: %w", err) } return nil } ================================================ FILE: providers/dns/autodns/autodns.toml ================================================ Name = "Autodns" Description = '''''' URL = "https://www.internetx.com/domains/autodns/" Code = "autodns" Since = "v3.2.0" Example = ''' AUTODNS_API_USER=username \ AUTODNS_API_PASSWORD=supersecretpassword \ lego --dns autodns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] AUTODNS_API_USER = "Username" AUTODNS_API_PASSWORD = "User Password" [Configuration.Additional] AUTODNS_ENDPOINT = "API endpoint URL, defaults to https://api.autodns.com/v1/" AUTODNS_CONTEXT = "API context (4 for production, 1 for testing. Defaults to 4)" AUTODNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" AUTODNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" AUTODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" AUTODNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://help.internetx.com/display/APIJSONEN" ================================================ FILE: providers/dns/autodns/autodns_test.go ================================================ package autodns import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIEndpoint, EnvAPIUser, EnvAPIPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIUser: "123", EnvAPIPassword: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIUser: "", EnvAPIPassword: "", }, expected: "autodns: some credentials information are missing: AUTODNS_API_USER,AUTODNS_API_PASSWORD", }, { desc: "missing user id", envVars: map[string]string{ EnvAPIUser: "", EnvAPIPassword: "456", }, expected: "autodns: some credentials information are missing: AUTODNS_API_USER", }, { desc: "missing key", envVars: map[string]string{ EnvAPIUser: "123", EnvAPIPassword: "", }, expected: "autodns: some credentials information are missing: AUTODNS_API_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "123", password: "456", }, { desc: "missing credentials", username: "", password: "", expected: "autodns: missing user", }, { desc: "missing user id", username: "", password: "456", expected: "autodns: missing user", }, { desc: "missing key", username: "123", password: "", expected: "autodns: missing password", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/autodns/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultEndpoint default API endpoint. const DefaultEndpoint = "https://api.autodns.com/v1/" // DefaultEndpointContext default API endpoint context. const DefaultEndpointContext int = 4 // Client the Autodns API client. type Client struct { username string password string context int BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(username, password string, clientContext int) *Client { baseURL, _ := url.Parse(DefaultEndpoint) return &Client{ username: username, password: password, context: clientContext, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // AddRecords adds records. func (c *Client) AddRecords(ctx context.Context, domain string, records []*ResourceRecord) (*DataZoneResponse, error) { zoneStream := &ZoneStream{Adds: records} return c.updateZone(ctx, domain, zoneStream) } // RemoveRecords removes records. func (c *Client) RemoveRecords(ctx context.Context, domain string, records []*ResourceRecord) (*DataZoneResponse, error) { zoneStream := &ZoneStream{Removes: records} return c.updateZone(ctx, domain, zoneStream) } // https://github.com/InterNetX/domainrobot-api/blob/bdc8fe92a2f32fcbdb29e30bf6006ab446f81223/src/domainrobot.json#L21090 func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *ZoneStream) (*DataZoneResponse, error) { endpoint := c.BaseURL.JoinPath("zone", domain, "_stream") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zoneStream) if err != nil { return nil, err } var resp *DataZoneResponse if err := c.do(req, &resp); err != nil { return nil, err } return resp, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set("X-Domainrobot-Context", strconv.Itoa(c.context)) req.SetBasicAuth(c.username, c.password) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/autodns/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret", 123) client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithBasicAuth("user", "secret"). WithJSONHeaders()) } func TestClient_AddRecords(t *testing.T) { client := mockBuilder(). Route("POST /zone/example.com/_stream", servermock.ResponseFromFixture("add_record.json"), servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"), servermock.CheckHeader(). With("X-Domainrobot-Context", "123")). Build(t) records := []*ResourceRecord{{ Name: "example.com", TTL: 600, Type: "TXT", Value: "txtTXTtxt", }} resp, err := client.AddRecords(t.Context(), "example.com", records) require.NoError(t, err) expected := &DataZoneResponse{ STID: "20251121-appf4923-126284", CTID: "", Messages: []ResponseMessage{ { Text: "string", Messages: []string{ "string", }, Objects: []GenericObject{ { Type: "string", Value: "string", }, }, Code: "string", Status: "SUCCESS", }, }, Status: &ResponseStatus{ Code: "S0301", Text: "Zone was updated successfully on the name server.", Type: "SUCCESS", }, Object: nil, Data: []Zone{ { Name: "example.com", ResourceRecords: []ResourceRecord{ { Name: "example.com", TTL: 120, Type: "TXT", Value: "txt", Pref: 1, }, }, Action: "xxx", VirtualNameServer: "yyy", }, }, } assert.Equal(t, expected, resp) } func TestClient_AddRecords_error(t *testing.T) { client := mockBuilder(). Route("POST /zone/example.com/_stream", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) records := []*ResourceRecord{{ Name: "example.com", TTL: 600, Type: "TXT", Value: "txtTXTtxt", }} _, err := client.AddRecords(t.Context(), "example.com", records) require.EqualError(t, err, `STID: 20251121-appf4923-126284, status: code: E0202002, text: Zone konnte auf dem Nameserver nicht aktualisiert werden., type: ERROR, message: code: EF02022, text: Der Zusatzeintrag wurde doppelt eingetragen., status: ERROR, object: OURDOMAIN.TLD@nsa7.schlundtech.de/rr[17]: _acme-challenge.www.whoami.int.OURDOMAIN.TLD TXT "rK2SJb_ZcrYefbfCKU6jZEANfEAJeOtSh1Fv8hkUoVc"`) } func TestClient_RemoveRecords(t *testing.T) { client := mockBuilder(). Route("POST /zone/example.com/_stream", servermock.ResponseFromFixture("remove_record.json"), servermock.CheckRequestJSONBodyFromFixture("remove_record-request.json"), servermock.CheckHeader(). With("X-Domainrobot-Context", "123")). Build(t) records := []*ResourceRecord{{ Name: "example.com", TTL: 600, Type: "TXT", Value: "txtTXTtxt", }} resp, err := client.RemoveRecords(t.Context(), "example.com", records) require.NoError(t, err) expected := &DataZoneResponse{ STID: "20251121-appf4923-126284", CTID: "", Messages: []ResponseMessage{ { Text: "string", Messages: []string{ "string", }, Objects: []GenericObject{ { Type: "string", Value: "string", }, }, Code: "string", Status: "SUCCESS", }, }, Status: &ResponseStatus{ Code: "S0301", Text: "Zone was updated successfully on the name server.", Type: "SUCCESS", }, Object: nil, Data: []Zone{ { Name: "example.com", ResourceRecords: []ResourceRecord{ { Name: "example.com", TTL: 120, Type: "TXT", Value: "txt", Pref: 1, }, }, Action: "xxx", VirtualNameServer: "yyy", }, }, } assert.Equal(t, expected, resp) } ================================================ FILE: providers/dns/autodns/internal/fixtures/add_record-request.json ================================================ { "adds": [ { "name": "example.com", "ttl": 600, "type": "TXT", "value": "txtTXTtxt" } ], "rems": null } ================================================ FILE: providers/dns/autodns/internal/fixtures/add_record.json ================================================ { "stid": "20251121-appf4923-126284", "messages": [ { "text": "string", "notice": "string", "messages": [ "string" ], "objects": [ { "type": "string", "value": "string" } ], "code": "string", "status": "SUCCESS" } ], "status": { "code": "S0301", "text": "Zone was updated successfully on the name server.", "type": "SUCCESS" }, "data": [ { "origin": "example.com", "resourceRecords": [ { "name": "example.com", "ttl": 120, "type": "TXT", "value": "txt", "pref": 1 } ], "action": "xxx", "virtualNameServer": "yyy" } ] } ================================================ FILE: providers/dns/autodns/internal/fixtures/error.json ================================================ { "stid": "20251121-appf4923-126284", "messages": [ { "text": "Der Zusatzeintrag wurde doppelt eingetragen.", "objects": [ { "type": "OURDOMAIN.TLD@nsa7.schlundtech.de/rr[17]", "value": "_acme-challenge.www.whoami.int.OURDOMAIN.TLD TXT \"rK2SJb_ZcrYefbfCKU6jZEANfEAJeOtSh1Fv8hkUoVc\"" } ], "code": "EF02022", "status": "ERROR" } ], "status": { "code": "E0202002", "text": "Zone konnte auf dem Nameserver nicht aktualisiert werden.", "type": "ERROR" } } ================================================ FILE: providers/dns/autodns/internal/fixtures/remove_record-request.json ================================================ { "adds": null, "rems": [ { "name": "example.com", "ttl": 600, "type": "TXT", "value": "txtTXTtxt" } ] } ================================================ FILE: providers/dns/autodns/internal/fixtures/remove_record.json ================================================ { "stid": "20251121-appf4923-126284", "messages": [ { "text": "string", "notice": "string", "messages": [ "string" ], "objects": [ { "type": "string", "value": "string" } ], "code": "string", "status": "SUCCESS" } ], "status": { "code": "S0301", "text": "Zone was updated successfully on the name server.", "type": "SUCCESS" }, "data": [ { "origin": "example.com", "resourceRecords": [ { "name": "example.com", "ttl": 120, "type": "TXT", "value": "txt", "pref": 1 } ], "action": "xxx", "virtualNameServer": "yyy" } ] } ================================================ FILE: providers/dns/autodns/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type APIResponse[T any] struct { STID string `json:"stid"` CTID string `json:"ctid"` Messages []ResponseMessage `json:"messages"` Status *ResponseStatus `json:"status"` Object *ResponseObject `json:"object"` Data T `json:"data"` } type APIError APIResponse[any] func (a *APIError) Error() string { var parts []string if a.STID != "" { parts = append(parts, fmt.Sprintf("STID: %s", a.STID)) } if a.CTID != "" { parts = append(parts, fmt.Sprintf("CTID: %s", a.CTID)) } if a.Status != nil { parts = append(parts, "status: "+a.Status.String()) } for _, message := range a.Messages { parts = append(parts, "message: "+message.String()) } if a.Object != nil { parts = append(parts, "object: "+a.Object.String()) } return strings.Join(parts, ", ") } type DataZoneResponse APIResponse[[]Zone] type ResponseMessage struct { Text string `json:"text"` Code string `json:"code"` Status string `json:"status"` Messages []string `json:"messages"` Objects []GenericObject `json:"objects"` } func (r ResponseMessage) String() string { var parts []string if r.Code != "" { parts = append(parts, "code: "+r.Code) } if r.Text != "" { parts = append(parts, "text: "+r.Text) } if r.Status != "" { parts = append(parts, "status: "+r.Status) } if len(r.Messages) > 0 { parts = append(parts, "messages: "+strings.Join(r.Messages, ";")) } for _, object := range r.Objects { parts = append(parts, fmt.Sprintf("object: %s", object)) } return strings.Join(parts, ", ") } type GenericObject struct { Type string `json:"type"` Value string `json:"value"` } func (g GenericObject) String() string { return g.Type + ": " + g.Value } type ResponseStatus struct { Code string `json:"code"` Text string `json:"text"` Type string `json:"type"` // SUCCESS, ERROR, NOTIFY, NOTICE, NICCOM_NOTIFY } func (r ResponseStatus) String() string { return fmt.Sprintf("code: %s, text: %s, type: %s", r.Code, r.Text, r.Type) } type ResponseObject struct { Type string `json:"type"` Value string `json:"value"` Summary int32 `json:"summary"` Data *ResponseObjectData `json:"data"` } func (r ResponseObject) String() string { var parts []string if r.Type != "" { parts = append(parts, fmt.Sprintf("type: %s", r.Type)) } if r.Value != "" { parts = append(parts, fmt.Sprintf("value: %s", r.Value)) } if r.Summary != 0 { parts = append(parts, fmt.Sprintf("summary: %d", r.Summary)) } if r.Data != nil { parts = append(parts, fmt.Sprintf("data: %s", r.Data.Description)) } return strings.Join(parts, ", ") } type ResponseObjectData struct { Description string `json:"description"` } // ResourceRecord holds a resource record. // https://help.internetx.com/display/APIXMLEN/Resource+Record+Object type ResourceRecord struct { Name string `json:"name"` TTL int64 `json:"ttl"` Type string `json:"type"` Value string `json:"value"` Pref int32 `json:"pref,omitempty"` } // Zone is an autodns zone record with all for us relevant fields. // https://help.internetx.com/display/APIXMLEN/Zone+Object type Zone struct { Name string `json:"origin"` ResourceRecords []ResourceRecord `json:"resourceRecords"` Action string `json:"action"` VirtualNameServer string `json:"virtualNameServer"` } // ZoneStream body of the requests. // https://github.com/InterNetX/domainrobot-api/blob/bdc8fe92a2f32fcbdb29e30bf6006ab446f81223/src/domainrobot.json#L35914-L35932 type ZoneStream struct { Adds []*ResourceRecord `json:"adds"` Removes []*ResourceRecord `json:"rems"` } ================================================ FILE: providers/dns/axelname/axelname.go ================================================ // Package axelname implements a DNS provider for solving the DNS-01 challenge using Axelname. package axelname import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/axelname/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "AXELNAME_" EnvNickname = envNamespace + "NICKNAME" EnvToken = envNamespace + "TOKEN" 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 { Nickname string Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Axelname. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvNickname, EnvToken) if err != nil { return nil, fmt.Errorf("axelname: %w", err) } config := NewDefaultConfig() config.Nickname = values[EnvNickname] config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Axelname. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("axelname: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.Nickname, config.Token) if err != nil { return nil, fmt.Errorf("axelname: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("axelname: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("axelname: %w", err) } record := internal.Record{ Name: subDomain, Type: "TXT", Value: info.Value, } err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("axelname: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("axelname: could not find zone for domain %q: %w", domain, err) } records, err := d.client.ListRecords(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("axelname: list records: %w", err) } for _, record := range records { if record.Type != "TXT" || record.Value != info.Value { continue } err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("axelname: delete record: %w", err) } return nil } return errors.New("axelname: delete record: record not found") } // 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 } ================================================ FILE: providers/dns/axelname/axelname.toml ================================================ Name = "Axelname" Description = '''''' URL = "https://axelname.ru" Code = "axelname" Since = "v4.23.0" Example = ''' AXELNAME_NICKNAME="yyy" \ AXELNAME_TOKEN="xxx" \ lego --dns axelname -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] AXELNAME_NICKNAME = "Account nickname" AXELNAME_TOKEN = "API token" [Configuration.Additional] AXELNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" AXELNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" AXELNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" AXELNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://axelname.ru/static/content/files/axelname_api_rest_lite.pdf" ================================================ FILE: providers/dns/axelname/axelname_test.go ================================================ package axelname import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvNickname, EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvNickname: "user", EnvToken: "secret", }, }, { desc: "missing nickname", envVars: map[string]string{ EnvNickname: "", EnvToken: "secret", }, expected: "axelname: some credentials information are missing: AXELNAME_NICKNAME", }, { desc: "missing token", envVars: map[string]string{ EnvNickname: "user", EnvToken: "", }, expected: "axelname: some credentials information are missing: AXELNAME_TOKEN", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "axelname: some credentials information are missing: AXELNAME_NICKNAME,AXELNAME_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string nickname string expected string }{ { desc: "success", nickname: "user", token: "secret", }, { desc: "missing nickname", expected: "axelname: credentials missing", }, { desc: "missing token", expected: "axelname: credentials missing", }, { desc: "missing credentials", expected: "axelname: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token config.Nickname = test.nickname p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/axelname/internal/client.go ================================================ package internal import ( "context" "encoding/json" "errors" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const statusSuccess = "success" const defaultBaseURL = "https://my.axelname.ru/rest/" // Client the Axelname API client. type Client struct { nickname string token string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(nickname, token string) (*Client, error) { if token == "" || nickname == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ nickname: nickname, token: token, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) ListRecords(ctx context.Context, domain string) ([]Record, error) { endpoint := c.baseURL.JoinPath("dns_list") query := endpoint.Query() query.Set("domain", domain) endpoint.RawQuery = query.Encode() req, err := c.newRequest(ctx, endpoint) if err != nil { return nil, err } var results ListResponse err = c.do(req, &results) if err != nil { return nil, err } if results.Result != statusSuccess { return nil, &results.APIError } return results.List, nil } func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { endpoint := c.baseURL.JoinPath("dns_delete") values, err := querystring.Values(record) if err != nil { return err } values.Set("domain", domain) endpoint.RawQuery = values.Encode() req, err := c.newRequest(ctx, endpoint) if err != nil { return err } var results APIResponse err = c.do(req, &results) if err != nil { return err } if results.Result != statusSuccess { return &results.APIError } return nil } func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { endpoint := c.baseURL.JoinPath("dns_add") values, err := querystring.Values(record) if err != nil { return err } values.Set("domain", domain) endpoint.RawQuery = values.Encode() req, err := c.newRequest(ctx, endpoint) if err != nil { return err } var results APIResponse err = c.do(req, &results) if err != nil { return err } if results.Result != statusSuccess { return &results.APIError } return nil } func (c *Client) newRequest(ctx context.Context, endpoint *url.URL) (*http.Request, error) { query := endpoint.Query() query.Set("token", c.token) query.Set("nichdl", c.nickname) endpoint.RawQuery = query.Encode() return http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/axelname/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil } func TestClient_ListRecords(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /dns_list", servermock.ResponseFromFixture("dns_list.json"), servermock.CheckQueryParameter().Strict(). With("domain", "example.com"). With("nichdl", "user"). With("token", "secret")). Build(t) records, err := client.ListRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ {ID: "74749", Name: "example.com", Type: "A", Value: "46.161.54.22"}, {ID: "417", Name: "example.com", Type: "MX", Value: "mx.yandex.ru.", Prio: "10"}, {ID: "419", Name: "mail.example.com", Type: "CNAME", Value: "mail.yandex.ru."}, {ID: "74750", Name: "www.example.com", Type: "A", Value: "46.161.54.22"}, } assert.Equal(t, expected, records) } func TestClient_ListRecords_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /dns_list", servermock.ResponseFromFixture("dns_list_error.json"). WithStatusCode(http.StatusNotFound)). Build(t) _, err := client.ListRecords(t.Context(), "example.com") require.EqualError(t, err, "error: Domain not found (1)") } func TestClient_DeleteRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /dns_delete", servermock.ResponseFromFixture("dns_delete.json"), servermock.CheckQueryParameter().Strict(). With("id", "74749"). With("domain", "example.com"). With("nichdl", "user"). With("token", "secret")). Build(t) record := Record{ID: "74749"} err := client.DeleteRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /dns_delete", servermock.ResponseFromFixture("dns_delete_error.json"). WithStatusCode(http.StatusNotFound)). Build(t) record := Record{ID: "74749"} err := client.DeleteRecord(t.Context(), "example.com", record) require.EqualError(t, err, "error: Domain not found (1)") } func TestClient_AddRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /dns_add", servermock.ResponseFromFixture("dns_add.json"), servermock.CheckQueryParameter().Strict(). With("id", "74749"). With("domain", "example.com"). With("nichdl", "user"). With("token", "secret")). Build(t) record := Record{ID: "74749"} err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /dns_add", servermock.ResponseFromFixture("dns_add_error.json"). WithStatusCode(http.StatusNotFound)). Build(t) record := Record{ID: "74749"} err := client.AddRecord(t.Context(), "example.com", record) require.EqualError(t, err, "error: Domain not found (1)") } ================================================ FILE: providers/dns/axelname/internal/fixtures/dns_add.json ================================================ { "code": "OK", "message": "DNS record added", "result": "success" } ================================================ FILE: providers/dns/axelname/internal/fixtures/dns_add_error.json ================================================ { "error_code": "1", "error_text": "Domain not found", "result": "error" } ================================================ FILE: providers/dns/axelname/internal/fixtures/dns_delete.json ================================================ { "code": "OK", "message": "DNS record deleted", "result": "success" } ================================================ FILE: providers/dns/axelname/internal/fixtures/dns_delete_error.json ================================================ { "error_code": "1", "error_text": "Domain not found", "result": "error" } ================================================ FILE: providers/dns/axelname/internal/fixtures/dns_list.json ================================================ { "code": "OK", "message": "DNS-records", "count": 4, "result": "success", "list": [ { "id": "74749", "name": "example.com", "type": "A", "value": "46.161.54.22" }, { "id": "417", "name": "example.com", "type": "MX", "value": "mx.yandex.ru.", "prio": "10" }, { "id": "419", "name": "mail.example.com", "type": "CNAME", "value": "mail.yandex.ru." }, { "id": "74750", "name": "www.example.com", "type": "A", "value": "46.161.54.22" } ] } ================================================ FILE: providers/dns/axelname/internal/fixtures/dns_list_error.json ================================================ { "error_code": "1", "error_text": "Domain not found", "result": "error" } ================================================ FILE: providers/dns/axelname/internal/types.go ================================================ package internal import "fmt" type APIError struct { ErrorCode string `json:"error_code,omitempty"` ErrorText string `json:"error_text,omitempty"` Result string `json:"result,omitempty"` } func (a *APIError) Error() string { return fmt.Sprintf("%s: %s (%s)", a.Result, a.ErrorText, a.ErrorCode) } type APIResponse struct { APIError Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` } type ListResponse struct { APIResponse Count int `json:"count,omitempty"` List []Record `json:"list,omitempty"` } type Record struct { ID string `json:"id,omitempty" url:"id,omitempty"` Name string `json:"name,omitempty" url:"name,omitempty"` Type string `json:"type,omitempty" url:"type,omitempty"` Value string `json:"value,omitempty" url:"value,omitempty"` Prio string `json:"prio,omitempty" url:"prio,omitempty"` } ================================================ FILE: providers/dns/azion/azion.go ================================================ // Package azion implements a DNS provider for solving the DNS-01 challenge using Azion Edge DNS. package azion import ( "context" "errors" "fmt" "net/http" "time" "github.com/aziontech/azionapi-go-sdk/idns" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "AZION_" EnvPersonalToken = envNamespace + "PERSONAL_TOKEN" EnvPageSize = envNamespace + "PAGE_SIZE" EnvTTL = envNamespace + "TTL" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { PersonalToken string PageSize int PollingInterval time.Duration PropagationTimeout time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PageSize: env.GetOrDefaultInt(EnvPageSize, 50), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *idns.APIClient } // NewDNSProvider returns a DNSProvider instance configured for Azion. // Credentials must be passed in the environment variable: AZION_PERSONAL_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvPersonalToken) if err != nil { return nil, fmt.Errorf("azion: %w", err) } config := NewDefaultConfig() config.PersonalToken = values[EnvPersonalToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Azion. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("azion: the configuration of the DNS provider is nil") } if config.PersonalToken == "" { return nil, errors.New("azion: missing credentials") } clientConfig := idns.NewConfiguration() clientConfig.AddDefaultHeader("Accept", "application/json; version=3") clientConfig.UserAgent = "lego-dns/azion" if config.HTTPClient != nil { clientConfig.HTTPClient = config.HTTPClient } clientConfig.HTTPClient = clientdebug.Wrap(clientConfig.HTTPClient) client := idns.NewAPIClient(clientConfig) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctxAuth := authContext(context.Background(), d.config.PersonalToken) zone, err := d.findZone(ctxAuth, info.EffectiveFQDN) if err != nil { return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err) } subDomain, err := extractSubDomain(info, zone) if err != nil { return fmt.Errorf("azion: %w", err) } // Check if a TXT record with the same name already exists existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain) if err != nil { return fmt.Errorf("azion: check existing records: %w", err) } record := idns.NewRecordPostOrPut() record.SetEntry(subDomain) record.SetRecordType("TXT") record.SetTtl(int32(d.config.TTL)) var resp *idns.PostOrPutRecordResponse if existingRecord != nil { // Update existing record by adding the new value to the existing ones record.SetAnswersList(append(existingRecord.GetAnswersList(), info.Value)) // Use PUT to update the existing record resp, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute() if err != nil { return fmt.Errorf("azion: update existing record: %w", err) } } else { // Create a new record record.SetAnswersList([]string{info.Value}) resp, _, err = d.client.RecordsAPI.PostZoneRecord(ctxAuth, zone.GetId()).RecordPostOrPut(*record).Execute() if err != nil { return fmt.Errorf("azion: create new zone record: %w", err) } } if resp == nil || resp.Results == nil { return errors.New("azion: create zone record error") } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctxAuth := authContext(context.Background(), d.config.PersonalToken) zone, err := d.findZone(ctxAuth, info.EffectiveFQDN) if err != nil { return fmt.Errorf("azion: could not find zone for domain %q: %w", domain, err) } subDomain, err := extractSubDomain(info, zone) if err != nil { return fmt.Errorf("azion: %w", err) } existingRecord, err := d.findExistingTXTRecord(ctxAuth, zone.GetId(), subDomain) if err != nil { return fmt.Errorf("azion: find existing record: %w", err) } if existingRecord == nil { return nil } currentAnswers := existingRecord.GetAnswersList() var updatedAnswers []string for _, answer := range currentAnswers { if answer != info.Value { updatedAnswers = append(updatedAnswers, answer) } } // If no answers remain, delete the entire record if len(updatedAnswers) == 0 { _, resp, errDelete := d.client.RecordsAPI.DeleteZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).Execute() if errDelete != nil { // If a record doesn't exist (404), consider cleanup successful if resp != nil && resp.StatusCode == http.StatusNotFound { return nil } return fmt.Errorf("azion: delete record: %w", errDelete) } return nil } // Update the record with remaining answers record := idns.NewRecordPostOrPut() record.SetEntry(subDomain) record.SetRecordType("TXT") record.SetAnswersList(updatedAnswers) record.SetTtl(existingRecord.GetTtl()) _, _, err = d.client.RecordsAPI.PutZoneRecord(ctxAuth, zone.GetId(), existingRecord.GetRecordId()).RecordPostOrPut(*record).Execute() if err != nil { return fmt.Errorf("azion: update record: %w", err) } return nil } func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*idns.Zone, error) { resp, _, err := d.client.ZonesAPI.GetZones(ctx).Execute() if err != nil { return nil, fmt.Errorf("get zones: %w", err) } if resp == nil { return nil, errors.New("get zones: no results") } for domain := range dns01.UnFqdnDomainsSeq(fqdn) { for _, zone := range resp.GetResults() { if zone.GetDomain() == domain { return &zone, nil } } } return nil, fmt.Errorf("zone not found (fqdn: %q)", fqdn) } // findExistingTXTRecord searches for an existing TXT record with the given name in the specified zone. // It handles pagination to search through all pages of results. func (d *DNSProvider) findExistingTXTRecord(ctx context.Context, zoneID int32, recordName string) (*idns.RecordGet, error) { var page int64 = 1 for { resp, _, err := d.client.RecordsAPI.GetZoneRecords(ctx, zoneID).Page(page).PageSize(int64(d.config.PageSize)).Execute() if err != nil { return nil, fmt.Errorf("get zone records (page %d): %w", page, err) } if resp == nil { return nil, errors.New("get zone records: no results") } results, ok := resp.GetResultsOk() if !ok || results == nil { return nil, errors.New("get zone records: empty") } // Search for existing TXT record with the same name in current page for _, record := range results.GetRecords() { if record.GetRecordType() == "TXT" && record.GetEntry() == recordName { return &record, nil } } // Check if there are more pages to search if page >= int64(resp.GetTotalPages()) { break } page++ } // No existing record found in any page return nil, nil } func authContext(ctx context.Context, key string) context.Context { return context.WithValue(ctx, idns.ContextAPIKeys, map[string]idns.APIKey{ "tokenAuth": { Key: key, Prefix: "Token", }, }) } func extractSubDomain(info dns01.ChallengeInfo, zone *idns.Zone) (string, error) { subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.GetName()) if err != nil { return "", err } if subDomain != "" { return subDomain, nil } return "@", nil } ================================================ FILE: providers/dns/azion/azion.toml ================================================ Name = "Azion" Description = '''''' Code = "azion" Since = "v4.24.0" URL = "https://www.azion.com/en/products/edge-dns/" Example = ''' AZION_PERSONAL_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns azion -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] AZION_PERSONAL_TOKEN = "Your Azion personal token." [Configuration.Additional] AZION_PAGE_SIZE = "The page size for the API request (Default: 50)" AZION_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" AZION_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" AZION_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" AZION_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.azion.com/" GoClient = "https://github.com/aziontech/azionapi-go-sdk" ================================================ FILE: providers/dns/azion/azion_test.go ================================================ package azion import ( "context" "net/http/httptest" "testing" "github.com/aziontech/azionapi-go-sdk/idns" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvPersonalToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvPersonalToken: "token", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvPersonalToken: "", }, expected: "azion: some credentials information are missing: AZION_PERSONAL_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "token", }, { desc: "missing credentials", expected: "azion: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.PersonalToken = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestDNSProvider_findZone(t *testing.T) { provider := mockBuilder(). Route("GET /intelligent_dns", servermock.ResponseFromFixture("zones.json")). Build(t) testCases := []struct { desc string fqdn string expected *idns.Zone }{ { desc: "apex", fqdn: "example.com.", expected: &idns.Zone{ Id: idns.PtrInt32(1), Domain: idns.PtrString("example.com"), }, }, { desc: "sub domain", fqdn: "sub.example.com.", expected: &idns.Zone{ Id: idns.PtrInt32(2), Domain: idns.PtrString("sub.example.com"), }, }, { desc: "long sub domain", fqdn: "_acme-challenge.api.sub.example.com.", expected: &idns.Zone{ Id: idns.PtrInt32(2), Domain: idns.PtrString("sub.example.com"), }, }, { desc: "long sub domain, apex", fqdn: "_acme-challenge.test.example.com.", expected: &idns.Zone{ Id: idns.PtrInt32(1), Domain: idns.PtrString("example.com"), }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { zone, err := provider.findZone(context.Background(), test.fqdn) require.NoError(t, err) assert.Equal(t, test.expected, zone) }) } } func TestDNSProvider_findZone_error(t *testing.T) { testCases := []struct { desc string fqdn string response string expected string }{ { desc: "no parent zone found", fqdn: "_acme-challenge.example.org.", response: "zones.json", expected: `zone not found (fqdn: "_acme-challenge.example.org.")`, }, { desc: "empty zones list", fqdn: "example.com.", response: "zones_empty.json", expected: `zone not found (fqdn: "example.com.")`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { provider := mockBuilder(). Route("GET /intelligent_dns", servermock.ResponseFromFixture(test.response)). Build(t) zone, err := provider.findZone(context.Background(), test.fqdn) require.EqualError(t, err, test.expected) assert.Nil(t, zone) }) } } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.PersonalToken = "secret" provider, err := NewDNSProviderConfig(config) if err != nil { return nil, err } clientConfig := provider.client.GetConfig() clientConfig.HTTPClient = server.Client() clientConfig.Servers = idns.ServerConfigurations{{ URL: server.URL, Description: "Production", }} return provider, nil }, ) } ================================================ FILE: providers/dns/azion/fixtures/zones.json ================================================ { "count": 2, "links": { "previous": null, "next": null }, "total_pages": 1, "results": [ { "id": 1, "domain": "example.com" }, { "id": 2, "domain": "sub.example.com" } ], "schema_version": 3 } ================================================ FILE: providers/dns/azion/fixtures/zones_empty.json ================================================ { "count": 0, "links": { "previous": null, "next": null }, "total_pages": 0, "results": null, "schema_version": 3 } ================================================ FILE: providers/dns/azure/azure.go ================================================ // Package azure implements a DNS provider for solving the DNS-01 challenge using azure DNS. // Azure doesn't like trailing dots on domain names, most of the acme code does. package azure import ( "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/Azure/go-autorest/autorest" aazure "github.com/Azure/go-autorest/autorest/azure" "github.com/Azure/go-autorest/autorest/azure/auth" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // Environment variables names. const ( envNamespace = "AZURE_" EnvEnvironment = envNamespace + "ENVIRONMENT" EnvMetadataEndpoint = envNamespace + "METADATA_ENDPOINT" EnvSubscriptionID = envNamespace + "SUBSCRIPTION_ID" EnvResourceGroup = envNamespace + "RESOURCE_GROUP" EnvTenantID = envNamespace + "TENANT_ID" EnvClientID = envNamespace + "CLIENT_ID" EnvClientSecret = envNamespace + "CLIENT_SECRET" EnvZoneName = envNamespace + "ZONE_NAME" EnvPrivateZone = envNamespace + "PRIVATE_ZONE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) const EnvLegoAzureBypassDeprecation = "LEGO_AZURE_BYPASS_DEPRECATION" const defaultMetadataEndpoint = "http://169.254.169.254" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ZoneName string // optional if using instance metadata service ClientID string ClientSecret string TenantID string SubscriptionID string ResourceGroup string PrivateZone bool MetadataEndpoint string ResourceManagerEndpoint string ActiveDirectoryEndpoint string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), MetadataEndpoint: env.GetOrFile(EnvMetadataEndpoint), ResourceManagerEndpoint: aazure.PublicCloud.ResourceManagerEndpoint, ActiveDirectoryEndpoint: aazure.PublicCloud.ActiveDirectoryEndpoint, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { provider challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for azure. // Credentials can be passed in the environment variables: // AZURE_ENVIRONMENT, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, // AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, AZURE_RESOURCE_GROUP // If the credentials are _not_ set via the environment, // then it will attempt to get a bearer token via the instance metadata service. // see: https://github.com/Azure/go-autorest/blob/v10.14.0/autorest/azure/auth/auth.go#L38-L42 // // Deprecated: use azuredns instead. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() environmentName := env.GetOrFile(EnvEnvironment) if environmentName != "" { var environment aazure.Environment switch environmentName { case "china": environment = aazure.ChinaCloud case "german": environment = aazure.GermanCloud case "public": environment = aazure.PublicCloud case "usgovernment": environment = aazure.USGovernmentCloud default: return nil, fmt.Errorf("azure: unknown environment %s", environmentName) } config.ResourceManagerEndpoint = environment.ResourceManagerEndpoint config.ActiveDirectoryEndpoint = environment.ActiveDirectoryEndpoint } config.SubscriptionID = env.GetOrFile(EnvSubscriptionID) config.ResourceGroup = env.GetOrFile(EnvResourceGroup) config.ClientSecret = env.GetOrFile(EnvClientSecret) config.ClientID = env.GetOrFile(EnvClientID) config.TenantID = env.GetOrFile(EnvTenantID) config.PrivateZone = env.GetOrDefaultBool(EnvPrivateZone, false) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Azure. // // Deprecated: use azuredns instead. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("azure: the configuration of the DNS provider is nil") } if !env.GetOrDefaultBool(EnvLegoAzureBypassDeprecation, false) { var msg strings.Builder msg.WriteString("azure: ") msg.WriteString("The `azure` provider has been deprecated since 2023, and replaced by `azuredns` provider. ") msg.WriteString("It can be TEMPORARILY reactivated by using the environment variable `LEGO_AZURE_BYPASS_DEPRECATION=true`. ") msg.WriteString("The `azure` provider will be removed in a future release, please migrate to the `azuredns` provider. ") msg.WriteString("The documentation of the `azuredns` provider can be found at https://go-acme.github.io/lego/dns/azuredns/") return nil, errors.New(msg.String()) } if config.HTTPClient == nil { config.HTTPClient = &http.Client{Timeout: 5 * time.Second} } authorizer, err := getAuthorizer(config) if err != nil { return nil, err } if config.SubscriptionID == "" { subsID, err := getMetadata(config, "subscriptionId") if err != nil { return nil, fmt.Errorf("azure: %w", err) } if subsID == "" { return nil, errors.New("azure: SubscriptionID is missing") } config.SubscriptionID = subsID } if config.ResourceGroup == "" { resGroup, err := getMetadata(config, "resourceGroupName") if err != nil { return nil, fmt.Errorf("azure: %w", err) } if resGroup == "" { return nil, errors.New("azure: ResourceGroup is missing") } config.ResourceGroup = resGroup } if config.PrivateZone { return &DNSProvider{provider: &dnsProviderPrivate{config: config, authorizer: authorizer}}, nil } return &DNSProvider{provider: &dnsProviderPublic{config: config, authorizer: authorizer}}, 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.provider.Timeout() } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { return d.provider.Present(domain, token, keyAuth) } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return d.provider.CleanUp(domain, token, keyAuth) } func getAuthorizer(config *Config) (autorest.Authorizer, error) { if config.ClientID != "" && config.ClientSecret != "" && config.TenantID != "" { credentialsConfig := auth.ClientCredentialsConfig{ ClientID: config.ClientID, ClientSecret: config.ClientSecret, TenantID: config.TenantID, Resource: config.ResourceManagerEndpoint, AADEndpoint: config.ActiveDirectoryEndpoint, } spToken, err := credentialsConfig.ServicePrincipalToken() if err != nil { return nil, fmt.Errorf("failed to get oauth token from client credentials: %w", err) } spToken.SetSender(config.HTTPClient) return autorest.NewBearerAuthorizer(spToken), nil } return auth.NewAuthorizerFromEnvironment() } // Fetches metadata from environment or the instance metadata service. // borrowed from https://github.com/Microsoft/azureimds/blob/master/imdssample.go func getMetadata(config *Config, field string) (string, error) { metadataEndpoint := config.MetadataEndpoint if metadataEndpoint == "" { metadataEndpoint = defaultMetadataEndpoint } endpoint, err := url.JoinPath(metadataEndpoint, "metadata", "instance", "compute", field) if err != nil { return "", err } req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { return "", err } req.Header.Set("Metadata", "True") q := req.URL.Query() q.Add("format", "text") q.Add("api-version", "2017-12-01") req.URL.RawQuery = q.Encode() resp, err := config.HTTPClient.Do(req) if err != nil { return "", errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return "", errutils.NewReadResponseError(req, resp.StatusCode, err) } return string(raw), nil } ================================================ FILE: providers/dns/azure/azure.toml ================================================ Name = "Azure (deprecated)" Description = '''''' URL = "https://azure.microsoft.com/services/dns/" Code = "azure" Since = "v0.4.0" Example = '''''' [Configuration] [Configuration.Credentials] AZURE_ENVIRONMENT = "Azure environment, one of: public, usgovernment, german, and china" AZURE_CLIENT_ID = "Client ID" AZURE_CLIENT_SECRET = "Client secret" AZURE_SUBSCRIPTION_ID = "Subscription ID" AZURE_TENANT_ID = "Tenant ID" AZURE_RESOURCE_GROUP = "Resource group" 'instance metadata service' = "If the credentials are **not** set via the environment, then it will attempt to get a bearer token via the [instance metadata service](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service)." [Configuration.Additional] AZURE_METADATA_ENDPOINT = "Metadata Service endpoint URL" AZURE_PRIVATE_ZONE = "Set to true to use Azure Private DNS Zones and not public" AZURE_ZONE_NAME = "Zone name to use inside Azure DNS service to add the TXT record in" AZURE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" AZURE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" [Links] API = "https://docs.microsoft.com/en-us/go/azure/" GoClient = "https://github.com/Azure/azure-sdk-for-go" ================================================ FILE: providers/dns/azure/azure_test.go ================================================ package azure import ( "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvLegoAzureBypassDeprecation, EnvEnvironment, EnvClientID, EnvClientSecret, EnvSubscriptionID, EnvTenantID, EnvResourceGroup). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvClientID: "A", EnvClientSecret: "B", EnvTenantID: "C", EnvSubscriptionID: "D", EnvResourceGroup: "E", }, }, { desc: "missing client ID", envVars: map[string]string{ EnvClientID: "", EnvClientSecret: "B", EnvTenantID: "C", EnvSubscriptionID: "D", EnvResourceGroup: "E", }, expected: "failed to get SPT from client credentials: parameter 'clientID' cannot be empty", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() test.envVars[EnvLegoAzureBypassDeprecation] = "true" envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected != "" { require.EqualError(t, err, test.expected) return } require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.provider) assert.IsType(t, p.provider, new(dnsProviderPublic)) }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string clientID string clientSecret string subscriptionID string tenantID string resourceGroup string privateZone bool handler func(w http.ResponseWriter, r *http.Request) expected string }{ { desc: "success (public)", clientID: "A", clientSecret: "B", tenantID: "C", subscriptionID: "D", resourceGroup: "E", privateZone: false, }, { desc: "success (private)", clientID: "A", clientSecret: "B", tenantID: "C", subscriptionID: "D", resourceGroup: "E", privateZone: true, }, { desc: "SubscriptionID missing", clientID: "A", clientSecret: "B", tenantID: "C", subscriptionID: "", resourceGroup: "", expected: "azure: SubscriptionID is missing", }, { desc: "ResourceGroup missing", clientID: "A", clientSecret: "B", tenantID: "C", subscriptionID: "D", resourceGroup: "", expected: "azure: ResourceGroup is missing", }, { desc: "use metadata", clientID: "A", clientSecret: "B", tenantID: "C", subscriptionID: "", resourceGroup: "", handler: func(w http.ResponseWriter, _ *http.Request) { _, err := w.Write([]byte("foo")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } }, }, } defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(map[string]string{EnvLegoAzureBypassDeprecation: "true"}) for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.ClientID = test.clientID config.ClientSecret = test.clientSecret config.SubscriptionID = test.subscriptionID config.TenantID = test.tenantID config.ResourceGroup = test.resourceGroup config.PrivateZone = test.privateZone mux := http.NewServeMux() server := httptest.NewServer(mux) t.Cleanup(server.Close) if test.handler == nil { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {}) } else { mux.HandleFunc("/", test.handler) } config.MetadataEndpoint = server.URL p, err := NewDNSProviderConfig(config) if test.expected != "" { require.EqualError(t, err, test.expected) return } require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.provider) if test.privateZone { assert.IsType(t, p.provider, new(dnsProviderPrivate)) } else { assert.IsType(t, p.provider, new(dnsProviderPublic)) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/azure/private.go ================================================ package azure import ( "context" "errors" "fmt" "net/http" "time" "github.com/Azure/azure-sdk-for-go/profiles/latest/privatedns/mgmt/privatedns" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/to" "github.com/go-acme/lego/v4/challenge/dns01" ) // dnsProviderPrivate implements the challenge.Provider interface for Azure Private Zone DNS. type dnsProviderPrivate struct { config *Config authorizer autorest.Authorizer } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *dnsProviderPrivate) 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 *dnsProviderPrivate) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("azure: %w", err) } rsc := privatedns.NewRecordSetsClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) rsc.Authorizer = d.authorizer subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("azure: %w", err) } // Get existing record set rset, err := rsc.Get(ctx, d.config.ResourceGroup, zone, privatedns.TXT, subDomain) if err != nil { var detailed autorest.DetailedError if !errors.As(err, &detailed) || detailed.StatusCode != http.StatusNotFound { return fmt.Errorf("azure: %w", err) } } // Construct unique TXT records using map uniqRecords := map[string]struct{}{info.Value: {}} if rset.RecordSetProperties != nil && rset.TxtRecords != nil { for _, txtRecord := range *rset.TxtRecords { // Assume Value doesn't contain multiple strings values := to.StringSlice(txtRecord.Value) if len(values) > 0 { uniqRecords[values[0]] = struct{}{} } } } var txtRecords []privatedns.TxtRecord for txt := range uniqRecords { txtRecords = append(txtRecords, privatedns.TxtRecord{Value: &[]string{txt}}) } rec := privatedns.RecordSet{ Name: &subDomain, RecordSetProperties: &privatedns.RecordSetProperties{ TTL: to.Int64Ptr(int64(d.config.TTL)), TxtRecords: &txtRecords, }, } _, err = rsc.CreateOrUpdate(ctx, d.config.ResourceGroup, zone, privatedns.TXT, subDomain, rec, "", "") if err != nil { return fmt.Errorf("azure: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *dnsProviderPrivate) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("azure: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("azure: %w", err) } rsc := privatedns.NewRecordSetsClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) rsc.Authorizer = d.authorizer _, err = rsc.Delete(ctx, d.config.ResourceGroup, zone, privatedns.TXT, subDomain, "") if err != nil { return fmt.Errorf("azure: %w", err) } return nil } // Checks that azure has a zone for this domain name. func (d *dnsProviderPrivate) getHostedZoneID(ctx context.Context, fqdn string) (string, error) { if d.config.ZoneName != "" { return d.config.ZoneName, nil } authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone: %w", err) } dc := privatedns.NewPrivateZonesClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) dc.Authorizer = d.authorizer zone, err := dc.Get(ctx, d.config.ResourceGroup, dns01.UnFqdn(authZone)) if err != nil { return "", err } // zone.Name shouldn't have a trailing dot(.) return to.String(zone.Name), nil } ================================================ FILE: providers/dns/azure/public.go ================================================ package azure import ( "context" "errors" "fmt" "net/http" "time" "github.com/Azure/azure-sdk-for-go/profiles/latest/dns/mgmt/dns" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/to" "github.com/go-acme/lego/v4/challenge/dns01" ) // dnsProviderPublic implements the challenge.Provider interface for Azure Public Zone DNS. type dnsProviderPublic struct { config *Config authorizer autorest.Authorizer } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *dnsProviderPublic) 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 *dnsProviderPublic) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("azure: %w", err) } rsc := dns.NewRecordSetsClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) rsc.Authorizer = d.authorizer subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("azure: %w", err) } // Get existing record set rset, err := rsc.Get(ctx, d.config.ResourceGroup, zone, subDomain, dns.TXT) if err != nil { var detailed autorest.DetailedError if !errors.As(err, &detailed) || detailed.StatusCode != http.StatusNotFound { return fmt.Errorf("azure: %w", err) } } // Construct unique TXT records using map uniqRecords := map[string]struct{}{info.Value: {}} if rset.RecordSetProperties != nil && rset.TxtRecords != nil { for _, txtRecord := range *rset.TxtRecords { // Assume Value doesn't contain multiple strings values := to.StringSlice(txtRecord.Value) if len(values) > 0 { uniqRecords[values[0]] = struct{}{} } } } var txtRecords []dns.TxtRecord for txt := range uniqRecords { txtRecords = append(txtRecords, dns.TxtRecord{Value: &[]string{txt}}) } rec := dns.RecordSet{ Name: &subDomain, RecordSetProperties: &dns.RecordSetProperties{ TTL: to.Int64Ptr(int64(d.config.TTL)), TxtRecords: &txtRecords, }, } _, err = rsc.CreateOrUpdate(ctx, d.config.ResourceGroup, zone, subDomain, dns.TXT, rec, "", "") if err != nil { return fmt.Errorf("azure: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *dnsProviderPublic) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("azure: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("azure: %w", err) } rsc := dns.NewRecordSetsClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) rsc.Authorizer = d.authorizer _, err = rsc.Delete(ctx, d.config.ResourceGroup, zone, subDomain, dns.TXT, "") if err != nil { return fmt.Errorf("azure: %w", err) } return nil } // Checks that azure has a zone for this domain name. func (d *dnsProviderPublic) getHostedZoneID(ctx context.Context, fqdn string) (string, error) { if d.config.ZoneName != "" { return d.config.ZoneName, nil } authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone: %w", err) } dc := dns.NewZonesClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID) dc.Authorizer = d.authorizer zone, err := dc.Get(ctx, d.config.ResourceGroup, dns01.UnFqdn(authZone)) if err != nil { return "", err } // zone.Name shouldn't have a trailing dot(.) return to.String(zone.Name), nil } ================================================ FILE: providers/dns/azuredns/azuredns.go ================================================ // Package azuredns implements a DNS provider for solving the DNS-01 challenge using azure DNS. // Azure doesn't like trailing dots on domain names, most of the acme code does. package azuredns import ( "errors" "fmt" "net/http" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "AZURE_" EnvEnvironment = envNamespace + "ENVIRONMENT" EnvSubscriptionID = envNamespace + "SUBSCRIPTION_ID" EnvResourceGroup = envNamespace + "RESOURCE_GROUP" EnvZoneName = envNamespace + "ZONE_NAME" EnvPrivateZone = envNamespace + "PRIVATE_ZONE" EnvTenantID = envNamespace + "TENANT_ID" EnvClientID = envNamespace + "CLIENT_ID" EnvClientSecret = envNamespace + "CLIENT_SECRET" EnvOIDCToken = envNamespace + "OIDC_TOKEN" EnvOIDCTokenFilePath = envNamespace + "OIDC_TOKEN_FILE_PATH" EnvOIDCRequestURL = envNamespace + "OIDC_REQUEST_URL" EnvGitHubOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL" altEnvArmOIDCRequestURL = "ARM_OIDC_REQUEST_URL" EnvOIDCRequestToken = envNamespace + "OIDC_REQUEST_TOKEN" EnvGitHubOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" altEnvArmOIDCRequestToken = "ARM_OIDC_REQUEST_TOKEN" EnvServiceConnectionID = envNamespace + "SERVICE_CONNECTION_ID" altEnvServiceConnectionID = "SERVICE_CONNECTION_ID" altEnvArmAdoPipelineServiceConnectionID = "ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID" altEnvArmOIDCAzureServiceConnectionID = "ARM_OIDC_AZURE_SERVICE_CONNECTION_ID" EnvSystemAccessToken = envNamespace + "SYSTEM_ACCESS_TOKEN" altEnvSystemAccessToken = "SYSTEM_ACCESSTOKEN" EnvAuthMethod = envNamespace + "AUTH_METHOD" EnvAuthMSITimeout = envNamespace + "AUTH_MSI_TIMEOUT" EnvServiceDiscoveryFilter = envNamespace + "SERVICEDISCOVERY_FILTER" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ZoneName string SubscriptionID string ResourceGroup string PrivateZone bool Environment cloud.Configuration // optional if using default Azure credentials ClientID string ClientSecret string TenantID string OIDCToken string OIDCTokenFilePath string OIDCRequestURL string OIDCRequestToken string ServiceConnectionID string SystemAccessToken string AuthMethod string AuthMSITimeout time.Duration PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client ServiceDiscoveryFilter string } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), Environment: cloud.AzurePublic, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { provider challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for azuredns. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() environmentName := env.GetOrFile(EnvEnvironment) if environmentName != "" { switch environmentName { case "china": config.Environment = cloud.AzureChina case "public": config.Environment = cloud.AzurePublic case "usgovernment": config.Environment = cloud.AzureGovernment default: return nil, fmt.Errorf("azuredns: unknown environment %s", environmentName) } } else { config.Environment = cloud.AzurePublic } config.SubscriptionID = env.GetOrFile(EnvSubscriptionID) config.ResourceGroup = env.GetOrFile(EnvResourceGroup) config.PrivateZone = env.GetOrDefaultBool(EnvPrivateZone, false) config.ClientID = env.GetOrFile(EnvClientID) config.ClientSecret = env.GetOrFile(EnvClientSecret) config.TenantID = env.GetOrFile(EnvTenantID) config.OIDCToken = env.GetOrFile(EnvOIDCToken) config.OIDCTokenFilePath = env.GetOrFile(EnvOIDCTokenFilePath) config.ServiceDiscoveryFilter = env.GetOrFile(EnvServiceDiscoveryFilter) oidcValues, _ := env.GetWithFallback( []string{EnvOIDCRequestURL, EnvGitHubOIDCRequestURL, altEnvArmOIDCRequestURL}, []string{EnvOIDCRequestToken, EnvGitHubOIDCRequestToken, altEnvArmOIDCRequestToken}, ) config.OIDCRequestURL = oidcValues[EnvOIDCRequestURL] config.OIDCRequestToken = oidcValues[EnvOIDCRequestToken] // https://registry.terraform.io/providers/hashicorp/Azurerm/latest/docs/guides/service_principal_oidc pipelineValues, _ := env.GetWithFallback( []string{EnvServiceConnectionID, altEnvServiceConnectionID, altEnvArmAdoPipelineServiceConnectionID, altEnvArmOIDCAzureServiceConnectionID}, []string{EnvSystemAccessToken, altEnvArmOIDCRequestToken, altEnvSystemAccessToken}, ) config.ServiceConnectionID = pipelineValues[EnvServiceConnectionID] config.SystemAccessToken = pipelineValues[EnvSystemAccessToken] config.AuthMethod = env.GetOrFile(EnvAuthMethod) config.AuthMSITimeout = env.GetOrDefaultSecond(EnvAuthMSITimeout, 2*time.Second) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Azure. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("azuredns: the configuration of the DNS provider is nil") } if config.HTTPClient == nil { config.HTTPClient = &http.Client{Timeout: 5 * time.Second} } config.HTTPClient = clientdebug.Wrap(config.HTTPClient) credentials, err := getCredentials(config) if err != nil { return nil, fmt.Errorf("azuredns: Unable to retrieve valid credentials: %w", err) } var dnsProvider challenge.ProviderTimeout if config.PrivateZone { dnsProvider, err = NewDNSProviderPrivate(config, credentials) if err != nil { return nil, fmt.Errorf("azuredns: %w", err) } } else { dnsProvider, err = NewDNSProviderPublic(config, credentials) if err != nil { return nil, fmt.Errorf("azuredns: %w", err) } } return &DNSProvider{provider: dnsProvider}, 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.provider.Timeout() } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { return d.provider.Present(domain, token, keyAuth) } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return d.provider.CleanUp(domain, token, keyAuth) } ================================================ FILE: providers/dns/azuredns/azuredns.toml ================================================ Name = "Azure DNS" Description = '''''' URL = "https://azure.microsoft.com/services/dns/" Code = "azuredns" Since = "v4.13.0" Example = ''' ### Using client secret AZURE_CLIENT_ID= \ AZURE_TENANT_ID= \ AZURE_CLIENT_SECRET= \ lego --dns azuredns -d '*.example.com' -d example.com run ### Using client certificate AZURE_CLIENT_ID= \ AZURE_TENANT_ID= \ AZURE_CLIENT_CERTIFICATE_PATH= \ lego --dns azuredns -d '*.example.com' -d example.com run ### Using Azure CLI az login \ lego --dns azuredns -d '*.example.com' -d example.com run ### Using Managed Identity (Azure VM) AZURE_TENANT_ID= \ AZURE_RESOURCE_GROUP= \ lego --dns azuredns -d '*.example.com' -d example.com run ### Using Managed Identity (Azure Arc) AZURE_TENANT_ID= \ IMDS_ENDPOINT=http://localhost:40342 \ IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token \ lego --dns azuredns -d '*.example.com' -d example.com run ''' Additional = ''' ## Description Several authentication methods can be used to authenticate against Azure DNS API. ### Default Azure Credentials (default option) Default Azure Credentials automatically detects in the following locations and prioritized in the following order: 1. Environment variables for client secret: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET` 2. Environment variables for client certificate: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_CERTIFICATE_PATH` 3. Workload identity for resources hosted in Azure environment (see below) 4. Shared credentials (defaults to `~/.azure` folder), used by Azure CLI Link: - [Azure Authentication](https://learn.microsoft.com/en-us/azure/developer/go/azure-sdk-authentication) ### Environment variables #### Service Discovery Lego automatically finds all visible Azure (private) DNS zones using [Azure ResourceGraph query](https://learn.microsoft.com/en-us/azure/governance/resource-graph/). This can be limited by specifying environment variable `AZURE_SUBSCRIPTION_ID` and/or `AZURE_RESOURCE_GROUP` which limits the DNS zones to only a subscription or to one resourceGroup. Additionally environment variable `AZURE_SERVICEDISCOVERY_FILTER` can be used to filter DNS zones with an addition Kusto filter eg: ``` resources | where type =~ "microsoft.network/dnszones" | ${AZURE_SERVICEDISCOVERY_FILTER} | project subscriptionId, resourceGroup, name ``` #### Client secret The Azure Credentials can be configured using the following environment variables: * AZURE_CLIENT_ID = "Client ID" * AZURE_CLIENT_SECRET = "Client secret" * AZURE_TENANT_ID = "Tenant ID" This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `env`. #### Client certificate The Azure Credentials can be configured using the following environment variables: * AZURE_CLIENT_ID = "Client ID" * AZURE_CLIENT_CERTIFICATE_PATH = "Client certificate path" * AZURE_TENANT_ID = "Tenant ID" This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `env`. ### Workload identity Workload identity allows workloads running Azure Kubernetes Services (AKS) clusters to authenticate as an Azure AD application identity using federated credentials. This must be configured in kubernetes workload deployment in one hand and on the Azure AD application registration in the other hand. Here is a summary of the steps to follow to use it : * create a `ServiceAccount` resource, add following annotations to reference the targeted Azure AD application registration : `azure.workload.identity/client-id` and `azure.workload.identity/tenant-id`. * on the `Deployment` resource you must reference the previous `ServiceAccount` and add the following label : `azure.workload.identity/use: "true"`. * create a federated credentials of type `Kubernetes accessing Azure resources`, add the cluster issuer URL and add the namespace and name of your kubernetes service account. Link : - [Azure AD Workload identity](https://azure.github.io/azure-workload-identity/docs/topics/service-account-labels-and-annotations.html) This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `wli`. ### Azure Managed Identity #### Azure Managed Identity (with Azure workload) The Azure Managed Identity service allows linking Azure AD identities to Azure resources, without needing to manually manage client IDs and secrets. Workloads with a Managed Identity can manage their own certificates, with permissions on specific domain names set using IAM assignments. For this to work, the Managed Identity requires the **Reader** role on the target DNS Zone, and the **DNS Zone Contributor** on the relevant `_acme-challenge` TXT records. For example, to allow a Managed Identity to create a certificate for "fw01.lab.example.com", using Azure CLI: ```bash export AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" export AZURE_RESOURCE_GROUP="rg1" export SERVICE_PRINCIPAL_ID="00000000-0000-0000-0000-000000000000" export AZURE_DNS_ZONE="lab.example.com" export AZ_HOSTNAME="fw01" export AZ_RECORD_SET="_acme-challenge.${AZ_HOSTNAME}" az role assignment create \ --assignee "${SERVICE_PRINCIPAL_ID}" \ --role "Reader" \ --scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${AZURE_DNS_ZONE}" az role assignment create \ --assignee "${SERVICE_PRINCIPAL_ID}" \ --role "DNS Zone Contributor" \ --scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Network/dnszones/${AZURE_DNS_ZONE}/TXT/${AZ_RECORD_SET}" ``` A timeout wrapper is configured for this authentication method. The duration can be configured by setting the `AZURE_AUTH_MSI_TIMEOUT`. The default timeout is 2 seconds. This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `msi`. #### Azure Managed Identity (with Azure Arc) The Azure Arc agent provides the ability to use a Managed Identity on resources hosted outside of Azure (such as on-prem virtual machines, or VMs in another cloud provider). While the upstream `azidentity` SDK will try to automatically identify and use the Azure Arc metadata service, if you get `azuredns: DefaultAzureCredential: failed to acquire a token.` error messages, you may need to set the environment variables: * `IMDS_ENDPOINT=http://localhost:40342` * `IDENTITY_ENDPOINT=http://localhost:40342/metadata/identity/oauth2/token` A timeout wrapper is configured for this authentication method. The duration can be configured by setting the `AZURE_AUTH_MSI_TIMEOUT`. The default timeout is 2 seconds. This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `msi`. ### Azure CLI The Azure CLI is a command-line tool provided by Microsoft to interact with Azure resources. It provides an easy way to authenticate by simply running `az login` command. The generated token will be cached by default in the `~/.azure` folder. This authentication method can be specifically used by setting the `AZURE_AUTH_METHOD` environment variable to `cli`. ### Open ID Connect Open ID Connect is a mechanism that establish a trust relationship between a running environment and the Azure AD identity provider. It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oidc`. ### Azure DevOps Pipelines It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `pipeline`. ''' [Configuration] [Configuration.Credentials] AZURE_CLIENT_ID = "Client ID" AZURE_CLIENT_SECRET = "Client secret" AZURE_TENANT_ID = "Tenant ID" AZURE_CLIENT_CERTIFICATE_PATH = "Client certificate path" [Configuration.Additional] AZURE_ENVIRONMENT = "Azure environment, one of: public, usgovernment, and china" AZURE_SUBSCRIPTION_ID = "DNS zone subscription ID" AZURE_RESOURCE_GROUP = "DNS zone resource group" AZURE_SERVICEDISCOVERY_FILTER = "Advanced ServiceDiscovery filter using Kusto query condition" AZURE_PRIVATE_ZONE = "Set to true to use Azure Private DNS Zones and not public" AZURE_ZONE_NAME = "Zone name to use inside Azure DNS service to add the TXT record in" AZURE_AUTH_METHOD = "Specify which authentication method to use" AZURE_AUTH_MSI_TIMEOUT = "Managed Identity timeout duration" AZURE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" AZURE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" AZURE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" [Links] API = "https://docs.microsoft.com/en-us/go/azure/" GoClient = "https://github.com/Azure/azure-sdk-for-go" ================================================ FILE: providers/dns/azuredns/azuredns_test.go ================================================ package azuredns import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvEnvironment, EnvSubscriptionID, EnvResourceGroup). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "unknown environment", envVars: map[string]string{ EnvEnvironment: "test", }, expected: "azuredns: unknown environment test", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected != "" { require.EqualError(t, err, test.expected) return } require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.provider) assert.IsType(t, p.provider, new(DNSProviderPublic)) }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/azuredns/credentials.go ================================================ package azuredns import ( "context" "errors" "fmt" "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/go-acme/lego/v4/challenge/dns01" ) const ( authMethodEnv = "env" authMethodWLI = "wli" authMethodMSI = "msi" authMethodCLI = "cli" authMethodOIDC = "oidc" authMethodPipeline = "pipeline" ) //nolint:gocyclo // The complexity is related to the number of possible configurations. func getCredentials(config *Config) (azcore.TokenCredential, error) { clientOptions := azcore.ClientOptions{Cloud: config.Environment} switch strings.ToLower(config.AuthMethod) { case authMethodEnv: if config.ClientID != "" && config.ClientSecret != "" && config.TenantID != "" { return azidentity.NewClientSecretCredential(config.TenantID, config.ClientID, config.ClientSecret, &azidentity.ClientSecretCredentialOptions{ClientOptions: clientOptions}) } return azidentity.NewEnvironmentCredential(&azidentity.EnvironmentCredentialOptions{ClientOptions: clientOptions}) case authMethodWLI: return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ClientOptions: clientOptions}) case authMethodMSI: cred, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ClientOptions: clientOptions}) if err != nil { return nil, err } return &timeoutTokenCredential{cred: cred, timeout: config.AuthMSITimeout}, nil case authMethodCLI: var credOptions *azidentity.AzureCLICredentialOptions if config.TenantID != "" { credOptions = &azidentity.AzureCLICredentialOptions{TenantID: config.TenantID} } return azidentity.NewAzureCLICredential(credOptions) case authMethodOIDC: err := checkOIDCConfig(config) if err != nil { return nil, err } return azidentity.NewClientAssertionCredential(config.TenantID, config.ClientID, getOIDCAssertion(config), &azidentity.ClientAssertionCredentialOptions{ClientOptions: clientOptions}) case authMethodPipeline: err := checkPipelineConfig(config) if err != nil { return nil, err } // Uses the env var `SYSTEM_OIDCREQUESTURI`, // but the constant is not exported, // and there is no way to set it programmatically. // https://github.com/Azure/azure-sdk-for-go/blob/aae2fb75ffccafc669db72bebc3c1a66332f48d7/sdk/azidentity/azure_pipelines_credential.go#L22 // https://github.com/Azure/azure-sdk-for-go/blob/aae2fb75ffccafc669db72bebc3c1a66332f48d7/sdk/azidentity/azure_pipelines_credential.go#L79 return azidentity.NewAzurePipelinesCredential(config.TenantID, config.ClientID, config.ServiceConnectionID, config.SystemAccessToken, &azidentity.AzurePipelinesCredentialOptions{ClientOptions: clientOptions}) default: return azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ClientOptions: clientOptions}) } } // timeoutTokenCredential wraps a TokenCredential to add a timeout. type timeoutTokenCredential struct { cred azcore.TokenCredential timeout time.Duration } // GetToken implements the azcore.TokenCredential interface. func (w *timeoutTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { if w.timeout <= 0 { return w.cred.GetToken(ctx, opts) } ctxTimeout, cancel := context.WithTimeout(ctx, w.timeout) defer cancel() tk, err := w.cred.GetToken(ctxTimeout, opts) if ce := ctxTimeout.Err(); errors.Is(ce, context.DeadlineExceeded) { return tk, azidentity.NewCredentialUnavailableError("managed identity timed out") } w.timeout = 0 return tk, err } func getZoneName(config *Config, fqdn string) (string, error) { if config.ZoneName != "" { return config.ZoneName, nil } authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) } if authZone == "" { return "", errors.New("empty zone name") } return authZone, nil } func checkPipelineConfig(config *Config) error { if config.ServiceConnectionID == "" { return errors.New("azuredns: ServiceConnectionID is missing") } if config.SystemAccessToken == "" { return errors.New("azuredns: SystemAccessToken is missing") } return nil } ================================================ FILE: providers/dns/azuredns/oidc.go ================================================ package azuredns import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" ) func checkOIDCConfig(config *Config) error { if config.TenantID == "" { return errors.New("azuredns: TenantID is missing") } if config.ClientID == "" { return errors.New("azuredns: ClientID is missing") } if config.OIDCToken == "" && config.OIDCTokenFilePath == "" && (config.OIDCRequestURL == "" || config.OIDCRequestToken == "") { return errors.New("azuredns: OIDCToken, OIDCTokenFilePath or OIDCRequestURL and OIDCRequestToken must be set") } return nil } func getOIDCAssertion(config *Config) func(ctx context.Context) (string, error) { return func(ctx context.Context) (string, error) { var token string if config.OIDCToken != "" { token = strings.TrimSpace(config.OIDCToken) } if config.OIDCTokenFilePath != "" { fileTokenRaw, err := os.ReadFile(config.OIDCTokenFilePath) if err != nil { return "", fmt.Errorf("azuredns: error retrieving token file with path %s: %w", config.OIDCTokenFilePath, err) } fileToken := strings.TrimSpace(string(fileTokenRaw)) if config.OIDCToken != fileToken { return "", fmt.Errorf("azuredns: token file with path %s does not match token from environment variable", config.OIDCTokenFilePath) } token = fileToken } if token == "" && config.OIDCRequestURL != "" && config.OIDCRequestToken != "" { return getOIDCToken(config) } return token, nil } } func getOIDCToken(config *Config) (string, error) { req, err := http.NewRequest(http.MethodGet, config.OIDCRequestURL, http.NoBody) if err != nil { return "", fmt.Errorf("azuredns: failed to build OIDC request: %w", err) } query, err := url.ParseQuery(req.URL.RawQuery) if err != nil { return "", errors.New("azuredns: cannot parse OIDC request URL query") } if query.Get("audience") == "" { query.Set("audience", "api://AzureADTokenExchange") req.URL.RawQuery = query.Encode() } req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.OIDCRequestToken)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := config.HTTPClient.Do(req) if err != nil { return "", fmt.Errorf("azuredns: cannot request OIDC token: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", fmt.Errorf("azuredns: cannot parse OIDC token response: %w", err) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusNoContent { return "", fmt.Errorf("azuredns: OIDC token request received HTTP status %d with response: %s", resp.StatusCode, body) } var returnedToken struct { Count int `json:"count"` Value string `json:"value"` } if err := json.Unmarshal(body, &returnedToken); err != nil { return "", fmt.Errorf("azuredns: cannot unmarshal OIDC token response: %w", err) } return returnedToken.Value, nil } ================================================ FILE: providers/dns/azuredns/private.go ================================================ package azuredns import ( "context" "errors" "fmt" "net/http" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) var _ challenge.ProviderTimeout = (*DNSProviderPrivate)(nil) // DNSProviderPrivate implements the challenge.Provider interface for Azure Private Zone DNS. type DNSProviderPrivate struct { config *Config credentials azcore.TokenCredential serviceDiscoveryZones map[string]ServiceDiscoveryZone } // NewDNSProviderPrivate creates a DNSProviderPrivate structure. func NewDNSProviderPrivate(config *Config, credentials azcore.TokenCredential) (*DNSProviderPrivate, error) { zones, err := discoverDNSZones(context.Background(), config, credentials) if err != nil { return nil, fmt.Errorf("discover DNS zones: %w", err) } return &DNSProviderPrivate{ config: config, credentials: credentials, serviceDiscoveryZones: zones, }, 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 *DNSProviderPrivate) 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 *DNSProviderPrivate) Present(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("azuredns: %w", err) } client, err := newPrivateZoneClient(zone, d.credentials, d.config.Environment) if err != nil { return fmt.Errorf("azuredns: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) if err != nil { return fmt.Errorf("azuredns: %w", err) } // Get existing record set resp, err := client.Get(ctx, subDomain) if err != nil { var respErr *azcore.ResponseError if !errors.As(err, &respErr) || respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("azuredns: %w", err) } } // Construct unique TXT records using map uniqRecords := privateUniqueRecords(resp.RecordSet, info.Value) var txtRecords []*armprivatedns.TxtRecord for txt := range uniqRecords { txtRecords = append(txtRecords, &armprivatedns.TxtRecord{Value: to.SliceOfPtrs(txt)}) } rec := armprivatedns.RecordSet{ Name: &subDomain, Properties: &armprivatedns.RecordSetProperties{ TTL: to.Ptr(int64(d.config.TTL)), TxtRecords: txtRecords, }, } _, err = client.CreateOrUpdate(ctx, subDomain, rec) if err != nil { return fmt.Errorf("azuredns: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProviderPrivate) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("azuredns: %w", err) } client, err := newPrivateZoneClient(zone, d.credentials, d.config.Environment) if err != nil { return fmt.Errorf("azuredns: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) if err != nil { return fmt.Errorf("azuredns: %w", err) } _, err = client.Delete(ctx, subDomain) if err != nil { return fmt.Errorf("azuredns: %w", err) } return nil } // Checks that azure has a zone for this domain name. func (d *DNSProviderPrivate) getHostedZone(fqdn string) (ServiceDiscoveryZone, error) { authZone, err := getZoneName(d.config, fqdn) if err != nil { return ServiceDiscoveryZone{}, err } azureZone, exists := d.serviceDiscoveryZones[dns01.UnFqdn(authZone)] if !exists { return ServiceDiscoveryZone{}, fmt.Errorf("could not find zone (from discovery): %s", authZone) } return azureZone, nil } // privateZoneClient provides Azure client for one DNS zone. type privateZoneClient struct { zone ServiceDiscoveryZone recordClient *armprivatedns.RecordSetsClient } // newPrivateZoneClient creates privateZoneClient structure with initialized Azure client. func newPrivateZoneClient(zone ServiceDiscoveryZone, credential azcore.TokenCredential, environment cloud.Configuration) (*privateZoneClient, error) { options := &arm.ClientOptions{ ClientOptions: azcore.ClientOptions{ Cloud: environment, }, } recordClient, err := armprivatedns.NewRecordSetsClient(zone.SubscriptionID, credential, options) if err != nil { return nil, err } return &privateZoneClient{ zone: zone, recordClient: recordClient, }, nil } func (c privateZoneClient) Get(ctx context.Context, subDomain string) (armprivatedns.RecordSetsClientGetResponse, error) { return c.recordClient.Get(ctx, c.zone.ResourceGroup, c.zone.Name, armprivatedns.RecordTypeTXT, subDomain, nil) } func (c privateZoneClient) CreateOrUpdate(ctx context.Context, subDomain string, rec armprivatedns.RecordSet) (armprivatedns.RecordSetsClientCreateOrUpdateResponse, error) { return c.recordClient.CreateOrUpdate(ctx, c.zone.ResourceGroup, c.zone.Name, armprivatedns.RecordTypeTXT, subDomain, rec, nil) } func (c privateZoneClient) Delete(ctx context.Context, subDomain string) (armprivatedns.RecordSetsClientDeleteResponse, error) { return c.recordClient.Delete(ctx, c.zone.ResourceGroup, c.zone.Name, armprivatedns.RecordTypeTXT, subDomain, nil) } func privateUniqueRecords(recordSet armprivatedns.RecordSet, value string) map[string]struct{} { uniqRecords := map[string]struct{}{value: {}} if recordSet.Properties != nil && recordSet.Properties.TxtRecords != nil { for _, txtRecord := range recordSet.Properties.TxtRecords { // Assume Value doesn't contain multiple strings if len(txtRecord.Value) > 0 { uniqRecords[ptr.Deref(txtRecord.Value[0])] = struct{}{} } } } return uniqRecords } ================================================ FILE: providers/dns/azuredns/public.go ================================================ package azuredns import ( "context" "errors" "fmt" "net/http" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) var _ challenge.ProviderTimeout = (*DNSProviderPublic)(nil) // DNSProviderPublic implements the challenge.Provider interface for Azure Public Zone DNS. type DNSProviderPublic struct { config *Config credentials azcore.TokenCredential serviceDiscoveryZones map[string]ServiceDiscoveryZone } // NewDNSProviderPublic creates a DNSProviderPublic structure. func NewDNSProviderPublic(config *Config, credentials azcore.TokenCredential) (*DNSProviderPublic, error) { zones, err := discoverDNSZones(context.Background(), config, credentials) if err != nil { return nil, fmt.Errorf("discover DNS zones: %w", err) } return &DNSProviderPublic{ config: config, credentials: credentials, serviceDiscoveryZones: zones, }, 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 *DNSProviderPublic) 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 *DNSProviderPublic) Present(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("azuredns: %w", err) } client, err := newPublicZoneClient(zone, d.credentials, d.config.Environment) if err != nil { return fmt.Errorf("azuredns: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) if err != nil { return fmt.Errorf("azuredns: %w", err) } // Get existing record set resp, err := client.Get(ctx, subDomain) if err != nil { var respErr *azcore.ResponseError if !errors.As(err, &respErr) || respErr.StatusCode != http.StatusNotFound { return fmt.Errorf("azuredns: %w", err) } } uniqRecords := publicUniqueRecords(resp.RecordSet, info.Value) var txtRecords []*armdns.TxtRecord for txt := range uniqRecords { txtRecords = append(txtRecords, &armdns.TxtRecord{Value: to.SliceOfPtrs(txt)}) } rec := armdns.RecordSet{ Name: &subDomain, Properties: &armdns.RecordSetProperties{ TTL: to.Ptr(int64(d.config.TTL)), TxtRecords: txtRecords, }, } _, err = client.CreateOrUpdate(ctx, subDomain, rec) if err != nil { return fmt.Errorf("azuredns: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProviderPublic) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("azuredns: %w", err) } client, err := newPublicZoneClient(zone, d.credentials, d.config.Environment) if err != nil { return fmt.Errorf("azuredns: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) if err != nil { return fmt.Errorf("azuredns: %w", err) } _, err = client.Delete(ctx, subDomain) if err != nil { return fmt.Errorf("azuredns: %w", err) } return nil } // Checks that azure has a zone for this domain name. func (d *DNSProviderPublic) getHostedZone(fqdn string) (ServiceDiscoveryZone, error) { authZone, err := getZoneName(d.config, fqdn) if err != nil { return ServiceDiscoveryZone{}, err } azureZone, exists := d.serviceDiscoveryZones[dns01.UnFqdn(authZone)] if !exists { return ServiceDiscoveryZone{}, fmt.Errorf("could not find zone (from discovery): %s", authZone) } return azureZone, nil } type publicZoneClient struct { zone ServiceDiscoveryZone recordClient *armdns.RecordSetsClient } // newPublicZoneClient creates publicZoneClient structure with initialized Azure client. func newPublicZoneClient(zone ServiceDiscoveryZone, credential azcore.TokenCredential, environment cloud.Configuration) (*publicZoneClient, error) { options := &arm.ClientOptions{ ClientOptions: azcore.ClientOptions{ Cloud: environment, }, } recordClient, err := armdns.NewRecordSetsClient(zone.SubscriptionID, credential, options) if err != nil { return nil, err } return &publicZoneClient{ zone: zone, recordClient: recordClient, }, nil } func (c publicZoneClient) Get(ctx context.Context, subDomain string) (armdns.RecordSetsClientGetResponse, error) { return c.recordClient.Get(ctx, c.zone.ResourceGroup, c.zone.Name, subDomain, armdns.RecordTypeTXT, nil) } func (c publicZoneClient) CreateOrUpdate(ctx context.Context, subDomain string, rec armdns.RecordSet) (armdns.RecordSetsClientCreateOrUpdateResponse, error) { return c.recordClient.CreateOrUpdate(ctx, c.zone.ResourceGroup, c.zone.Name, subDomain, armdns.RecordTypeTXT, rec, nil) } func (c publicZoneClient) Delete(ctx context.Context, subDomain string) (armdns.RecordSetsClientDeleteResponse, error) { return c.recordClient.Delete(ctx, c.zone.ResourceGroup, c.zone.Name, subDomain, armdns.RecordTypeTXT, nil) } func publicUniqueRecords(recordSet armdns.RecordSet, value string) map[string]struct{} { uniqRecords := map[string]struct{}{value: {}} if recordSet.Properties != nil && recordSet.Properties.TxtRecords != nil { for _, txtRecord := range recordSet.Properties.TxtRecords { // Assume Value doesn't contain multiple strings if len(txtRecord.Value) > 0 { uniqRecords[ptr.Deref(txtRecord.Value[0])] = struct{}{} } } } return uniqRecords } ================================================ FILE: providers/dns/azuredns/servicediscovery.go ================================================ package azuredns import ( "bytes" "context" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) type ServiceDiscoveryZone struct { Name string SubscriptionID string ResourceGroup string } const ( ResourceGraphTypePublicDNSZone = "microsoft.network/dnszones" ResourceGraphTypePrivateDNSZone = "microsoft.network/privatednszones" ) const ResourceGraphQueryOptionsTop int32 = 1000 // discoverDNSZones finds all visible Azure DNS zones based on optional subscriptionID, resourceGroup and serviceDiscovery filter using Kusto query. func discoverDNSZones(ctx context.Context, config *Config, credentials azcore.TokenCredential) (map[string]ServiceDiscoveryZone, error) { options := &arm.ClientOptions{ ClientOptions: azcore.ClientOptions{ Cloud: config.Environment, }, } client, err := armresourcegraph.NewClient(credentials, options) if err != nil { return nil, err } // Set options requestOptions := &armresourcegraph.QueryRequestOptions{ ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray), Top: to.Ptr(ResourceGraphQueryOptionsTop), Skip: to.Ptr[int32](0), } zones := map[string]ServiceDiscoveryZone{} for { // create the query request request := armresourcegraph.QueryRequest{ Query: to.Ptr(createGraphQuery(config)), Options: requestOptions, } result, err := client.Resources(ctx, request, nil) if err != nil { return zones, err } resultList, ok := result.Data.([]any) if !ok { // got invalid or empty data, skipping break } for _, row := range resultList { rowData, ok := row.(map[string]any) if !ok { continue } zoneName, ok := rowData["name"].(string) if !ok { continue } if _, exists := zones[zoneName]; exists { return zones, fmt.Errorf(`found duplicate dns zone "%s"`, zoneName) } zones[zoneName] = ServiceDiscoveryZone{ Name: zoneName, ResourceGroup: rowData["resourceGroup"].(string), SubscriptionID: rowData["subscriptionId"].(string), } } *requestOptions.Skip += ResourceGraphQueryOptionsTop if result.TotalRecords != nil { if int64(ptr.Deref(requestOptions.Skip)) >= ptr.Deref(result.TotalRecords) { break } } } return zones, nil } func createGraphQuery(config *Config) string { buf := new(bytes.Buffer) buf.WriteString("\nresources\n") resourceType := ResourceGraphTypePublicDNSZone if config.PrivateZone { resourceType = ResourceGraphTypePrivateDNSZone } _, _ = fmt.Fprintf(buf, "| where type =~ %q\n", resourceType) if config.SubscriptionID != "" { _, _ = fmt.Fprintf(buf, "| where subscriptionId =~ %q\n", config.SubscriptionID) } if config.ResourceGroup != "" { _, _ = fmt.Fprintf(buf, "| where resourceGroup =~ %q\n", config.ResourceGroup) } if config.ServiceDiscoveryFilter != "" { _, _ = fmt.Fprintf(buf, "| %s\n", config.ServiceDiscoveryFilter) } buf.WriteString("| project subscriptionId, resourceGroup, name") return buf.String() } ================================================ FILE: providers/dns/azuredns/servicediscovery_test.go ================================================ package azuredns import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func Test_createGraphQuery(t *testing.T) { testCases := []struct { desc string cfg *Config expected string }{ { desc: "empty configuration (public)", cfg: &Config{}, expected: ` resources | where type =~ "microsoft.network/dnszones" | project subscriptionId, resourceGroup, name`, }, { desc: "SubscriptionID (public)", cfg: &Config{ SubscriptionID: "123", }, expected: ` resources | where type =~ "microsoft.network/dnszones" | where subscriptionId =~ "123" | project subscriptionId, resourceGroup, name`, }, { desc: "ResourceGroup (public)", cfg: &Config{ ResourceGroup: "123", }, expected: ` resources | where type =~ "microsoft.network/dnszones" | where resourceGroup =~ "123" | project subscriptionId, resourceGroup, name`, }, { desc: "ServiceDiscoveryFilter (public)", cfg: &Config{ ServiceDiscoveryFilter: "123", }, expected: ` resources | where type =~ "microsoft.network/dnszones" | 123 | project subscriptionId, resourceGroup, name`, }, { desc: "empty configuration (private)", cfg: &Config{ PrivateZone: true, }, expected: ` resources | where type =~ "microsoft.network/privatednszones" | project subscriptionId, resourceGroup, name`, }, { desc: "SubscriptionID (private)", cfg: &Config{ SubscriptionID: "123", PrivateZone: true, }, expected: ` resources | where type =~ "microsoft.network/privatednszones" | where subscriptionId =~ "123" | project subscriptionId, resourceGroup, name`, }, { desc: "ResourceGroup (private)", cfg: &Config{ ResourceGroup: "123", PrivateZone: true, }, expected: ` resources | where type =~ "microsoft.network/privatednszones" | where resourceGroup =~ "123" | project subscriptionId, resourceGroup, name`, }, { desc: "ServiceDiscoveryFilter (private)", cfg: &Config{ ServiceDiscoveryFilter: "123", PrivateZone: true, }, expected: ` resources | where type =~ "microsoft.network/privatednszones" | 123 | project subscriptionId, resourceGroup, name`, }, { desc: "all (private)", cfg: &Config{ SubscriptionID: "123", ResourceGroup: "456", ServiceDiscoveryFilter: "789", PrivateZone: true, }, expected: ` resources | where type =~ "microsoft.network/privatednszones" | where subscriptionId =~ "123" | where resourceGroup =~ "456" | 789 | project subscriptionId, resourceGroup, name`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() query := createGraphQuery(test.cfg) assert.Equal(t, strings.ReplaceAll(test.expected, "\r", ""), query) }) } } ================================================ FILE: providers/dns/baiducloud/baiducloud.go ================================================ // Package baiducloud implements a DNS provider for solving the DNS-01 challenge using Baidu Cloud. package baiducloud import ( "errors" "fmt" "time" baidudns "github.com/baidubce/bce-sdk-go/services/dns" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) // Environment variables names. const ( envNamespace = "BAIDUCLOUD_" EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) // 300 is the minimum TTL for free users. const defaultTTL = 300 // Config is used to configure the creation of the DNSProvider. type Config struct { AccessKeyID string SecretAccessKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *baidudns.Client } // NewDNSProvider returns a DNSProvider instance configured for Baidu Cloud. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey) if err != nil { return nil, fmt.Errorf("baiducloud: %w", err) } config := NewDefaultConfig() config.AccessKeyID = values[EnvAccessKeyID] config.SecretAccessKey = values[EnvSecretAccessKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Baidu Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("baiducloud: the configuration of the DNS provider is nil") } if config.AccessKeyID == "" && config.SecretAccessKey == "" { return nil, errors.New("baiducloud: credentials missing") } client, err := baidudns.NewClient(config.AccessKeyID, config.SecretAccessKey, "") if err != nil { return nil, fmt.Errorf("baiducloud: %w", err) } return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("baiducloud: %w", err) } crr := &baidudns.CreateRecordRequest{ Description: ptr.Pointer("lego"), Rr: subDomain, Type: "TXT", Value: info.Value, Ttl: ptr.Pointer(int32(d.config.TTL)), } err = d.client.CreateRecord(dns01.UnFqdn(authZone), crr, "") if err != nil { return fmt.Errorf("baiducloud: create record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err) } recordID, err := d.findRecordID(dns01.UnFqdn(authZone), info.Value) if err != nil { return fmt.Errorf("baiducloud: find record: %w", err) } err = d.client.DeleteRecord(dns01.UnFqdn(authZone), recordID, "") if err != nil { return fmt.Errorf("baiducloud: delete record: %w", err) } return nil } func (d *DNSProvider) findRecordID(zoneName, tokenValue string) (string, error) { lrr := &baidudns.ListRecordRequest{} for { recordResponse, err := d.client.ListRecord(zoneName, lrr) if err != nil { return "", fmt.Errorf("baiducloud: list record: %w", err) } for _, record := range recordResponse.Records { if record.Type == "TXT" && record.Value == tokenValue { return record.Id, nil } } if !recordResponse.IsTruncated { break } lrr.Marker = recordResponse.NextMarker } return "", errors.New("record not found") } // 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 } ================================================ FILE: providers/dns/baiducloud/baiducloud.toml ================================================ Name = "Baidu Cloud" Description = '''''' URL = "https://cloud.baidu.com" Code = "baiducloud" Since = "v4.23.0" Example = ''' BAIDUCLOUD_ACCESS_KEY_ID="xxx" \ BAIDUCLOUD_SECRET_ACCESS_KEY="yyy" \ lego --dns baiducloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BAIDUCLOUD_ACCESS_KEY_ID = "Access key" BAIDUCLOUD_SECRET_ACCESS_KEY = "Secret access key" [Configuration.Additional] BAIDUCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" BAIDUCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" BAIDUCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" [Links] API = "https://cloud.baidu.com/doc/DNS/s/El4s7lssr" GoClient = "https://github.com/baidubce/bce-sdk-go" ================================================ FILE: providers/dns/baiducloud/baiducloud_test.go ================================================ package baiducloud import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAccessKeyID, EnvSecretAccessKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessKeyID: "key", EnvSecretAccessKey: "secret", }, }, { desc: "missing access key ID", envVars: map[string]string{ EnvAccessKeyID: "key", }, expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_SECRET_ACCESS_KEY", }, { desc: "missing secret access key", envVars: map[string]string{ EnvSecretAccessKey: "secret", }, expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_ACCESS_KEY_ID", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "baiducloud: some credentials information are missing: BAIDUCLOUD_ACCESS_KEY_ID,BAIDUCLOUD_SECRET_ACCESS_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accessKeyID string secretAccessKey string expected string }{ { desc: "success", accessKeyID: "key", secretAccessKey: "secret", }, { desc: "missing access key ID", accessKeyID: "", secretAccessKey: "secret", expected: "baiducloud: accessKeyId should not be empty", }, { desc: "missing secret access key", accessKeyID: "key", secretAccessKey: "", expected: "baiducloud: secretKey should not be empty", }, { desc: "missing credentials", expected: "baiducloud: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessKeyID = test.accessKeyID config.SecretAccessKey = test.secretAccessKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/beget/beget.go ================================================ // Package beget implements a DNS provider for solving the DNS-01 challenge using beget.com DNS. package beget import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/beget/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "BEGET_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for beget.com. // Credentials must be passed in the environment variables: // BEGET_USERNAME and BEGET_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("beget: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for beget.com. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("beget: the configuration of the DNS provider is nil") } if config.Username == "" || config.Password == "" { return nil, errors.New("beget: incomplete credentials, missing username and/or password") } client := internal.NewClient(config.Username, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) records, err := d.client.GetTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("beget: get TXT records: %w", err) } records = append(records, internal.Record{ Value: info.Value, Data: "", // NOTE: there are 2 fields in the API for the same thing. Priority: 10, TTL: d.config.TTL, }) err = d.client.ChangeTXTRecord(ctx, dns01.UnFqdn(info.EffectiveFQDN), records) if err != nil { return fmt.Errorf("beget: failed to create TXT records [domain: %s]: %w", dns01.UnFqdn(info.EffectiveFQDN), err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) records, err := d.client.GetTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("beget: get TXT records: %w", err) } if len(records) == 0 { return nil } var updatedRecords []internal.Record for _, record := range records { if record.Data == info.Value { continue } updatedRecords = append(updatedRecords, record) } err = d.client.ChangeTXTRecord(ctx, dns01.UnFqdn(info.EffectiveFQDN), updatedRecords) if err != nil { return fmt.Errorf("beget: failed to remove TXT records [domain: %s]: %w", dns01.UnFqdn(info.EffectiveFQDN), err) } return nil } ================================================ FILE: providers/dns/beget/beget.toml ================================================ Name = "Beget.com" Description = '''''' URL = "https://beget.com/" Code = "beget" Since = "v4.27.0" Example = ''' BEGET_USERNAME=xxxxxx \ BEGET_PASSWORD=yyyyyy \ lego --dns beget -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BEGET_USERNAME = "API username" BEGET_PASSWORD = "API password" [Configuration.Additional] BEGET_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" BEGET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" BEGET_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" BEGET_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://beget.com/ru/kb/api/funkczii-upravleniya-dns" ================================================ FILE: providers/dns/beget/beget_test.go ================================================ package beget import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvUsername: "", EnvPassword: "", }, expected: "beget: some credentials information are missing: BEGET_USERNAME,BEGET_PASSWORD", }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "456", }, expected: "beget: some credentials information are missing: BEGET_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "", }, expected: "beget: some credentials information are missing: BEGET_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "123", password: "456", }, { desc: "missing credentials", username: "", password: "", expected: "beget: incomplete credentials, missing username and/or password", }, { desc: "missing username", username: "", password: "456", expected: "beget: incomplete credentials, missing username and/or password", }, { desc: "missing password", username: "123", password: "", expected: "beget: incomplete credentials, missing username and/or password", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() assert.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") assert.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() assert.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") assert.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Username = "user" config.Password = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckQueryParameter(). With("login", "user"). With("passwd", "secret"). With("input_format", "json"). With("output_format", "json"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /dns/getData", servermock.ResponseFromInternal("getData-real.json"), servermock.CheckQueryParameter(). With("input_data", `{"fqdn":"_acme-challenge.example.com"}`), ). Route("GET /dns/changeRecords", servermock.ResponseFromInternal("changeRecords-doc.json"), servermock.CheckQueryParameter(). With("input_data", `{"fqdn":"_acme-challenge.example.com","records":{"TXT":[{"txtdata":"v=spf1 redirect=beget.com","ttl":300},{"value":"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY","priority":10,"ttl":300}]}}`), ). Build(t) err := provider.Present("example.com", "", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /dns/getData", servermock.ResponseFromInternal("getData.json"), servermock.CheckQueryParameter(). With("input_data", `{"fqdn":"_acme-challenge.example.com"}`), ). Route("GET /dns/changeRecords", servermock.ResponseFromInternal("changeRecords-doc.json"), servermock.CheckQueryParameter(). With("input_data", `{"fqdn":"_acme-challenge.example.com","records":{"TXT":[{"txtdata":"foo","ttl":300}]}}`), ). Build(t) err := provider.CleanUp("example.com", "", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp_empty(t *testing.T) { provider := mockBuilder(). Route("GET /dns/getData", servermock.ResponseFromInternal("getData_empty.json"), servermock.CheckQueryParameter(). With("input_data", `{"fqdn":"_acme-challenge.example.com"}`), ). Route("/", servermock.Noop().WithStatusCode(http.StatusInternalServerError)). Build(t) err := provider.CleanUp("example.com", "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/beget/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.beget.com/api/" // Client the beget.com client. type Client struct { login string password string BaseURL *url.URL HTTPClient *http.Client } // NewClient Creates a beget.com client. func NewClient(login, password string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ login: login, password: password, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetTXTRecords returns TXT records. // https://beget.com/ru/kb/api/funkczii-upravleniya-dns#getdata func (c *Client) GetTXTRecords(ctx context.Context, domain string) ([]Record, error) { request := GetRecordsRequest{Fqdn: domain} resp, err := c.doRequest(ctx, request, "dns", "getData") if err != nil { return nil, err } err = resp.HasError() if err != nil { return nil, err } result := GetRecordsResult{} err = json.Unmarshal(resp.Answer.Result, &result) if err != nil { return nil, fmt.Errorf("unmarshal result: %s: %w", string(resp.Answer.Result), err) } return result.Records.TXT, nil } // ChangeTXTRecord changes TXT records. // https://beget.com/ru/kb/api/funkczii-upravleniya-dns#changerecords func (c *Client) ChangeTXTRecord(ctx context.Context, domain string, records []Record) error { request := ChangeRecordsRequest{ Fqdn: domain, Records: RecordList{TXT: records}, } resp, err := c.doRequest(ctx, request, "dns", "changeRecords") if err != nil { return err } return resp.HasError() } func (c *Client) doRequest(ctx context.Context, data any, fragments ...string) (*APIResponse, error) { endpoint := c.BaseURL.JoinPath(fragments...) inputData, err := json.Marshal(data) if err != nil { return nil, fmt.Errorf("failed to mashall input data: %w", err) } query := endpoint.Query() query.Add("input_data", string(inputData)) query.Add("login", c.login) query.Add("passwd", c.password) query.Add("input_format", "json") query.Add("output_format", "json") endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return nil, parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var apiResp APIResponse err = json.Unmarshal(raw, &apiResp) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &apiResp, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var apiResp APIResponse err := json.Unmarshal(raw, &apiResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, apiResp) } ================================================ FILE: providers/dns/beget/internal/client_test.go ================================================ package internal import ( "context" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckQueryParameter(). With("login", "user"). With("passwd", "secret"). With("input_format", "json"). With("output_format", "json"), ) } func TestClient_GetTXTRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/getData", servermock.ResponseFromFixture("getData-real.json"), servermock.CheckQueryParameter(). With("input_data", `{"fqdn":"example.com"}`), ). Build(t) data, err := client.GetTXTRecords(context.Background(), "example.com") require.NoError(t, err) expected := []Record{{Data: "v=spf1 redirect=beget.com", TTL: 300}} assert.Equal(t, expected, data) } func TestClient_ChangeTXTRecord(t *testing.T) { client := mockBuilder(). Route("GET /dns/changeRecords", servermock.ResponseFromFixture("changeRecords-doc.json"), servermock.CheckQueryParameter(). With("input_data", `{"fqdn":"sub.example.com","records":{"TXT":[{"value":"txtTXTtxt","priority":10,"ttl":300}]}}`), ). Build(t) records := []Record{{Value: "txtTXTtxt", TTL: 300, Priority: 10}} err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records) require.NoError(t, err) } func TestClient_ChangeTXTRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /dns/changeRecords", servermock.ResponseFromFixture("error.json")). Build(t) records := []Record{{Data: "txtTXTtxt", TTL: 300}} err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records) require.Error(t, err) require.EqualError(t, err, "API error: NO_SUCH_METHOD: No such method") } func TestClient_ChangeTXTRecord_answer_error(t *testing.T) { client := mockBuilder(). Route("GET /dns/changeRecords", servermock.ResponseFromFixture("answer_error.json")). Build(t) records := []Record{{Data: "txtTXTtxt", TTL: 300}} err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records) require.Error(t, err) require.EqualError(t, err, "API answer error: INVALID_DATA: Login length cannot be greater than 12 characters") } func TestClient_ChangeTXTRecord_remove(t *testing.T) { client := mockBuilder(). Route("GET /dns/changeRecords", servermock.ResponseFromFixture("changeRecords-doc.json"), servermock.CheckQueryParameter(). With("input_data", `{"fqdn":"sub.example.com","records":{}}`), ). Build(t) err := client.ChangeTXTRecord(context.Background(), "sub.example.com", nil) require.NoError(t, err) } ================================================ FILE: providers/dns/beget/internal/fixtures/answer_error.json ================================================ { "status": "success", "answer": { "status": "error", "errors": [ { "error_code": "INVALID_DATA", "error_text": "Login length cannot be greater than 12 characters" } ] } } ================================================ FILE: providers/dns/beget/internal/fixtures/changeRecords-doc.json ================================================ { "status": "success", "answer": { "status": "success", "result": { "A": [ { "priority": 10, "value": "127.0.0.1" } ], "MX": [ { "priority": 10, "value": "mx1.beget.ru" }, { "priority": 20, "value": "mx2.beget.ru" } ], "TXT": [ { "priority": 10, "value": "TXT record" } ] } } } ================================================ FILE: providers/dns/beget/internal/fixtures/error.json ================================================ { "status": "error", "error_text": "No such method", "error_code": "NO_SUCH_METHOD" } ================================================ FILE: providers/dns/beget/internal/fixtures/getData-doc.json ================================================ { "status": "success", "answer": { "status": "success", "result": { "is_under_control": 1, "is_beget_dns": 1, "is_subdomain": 0, "fqdn": "beget.ru", "records": { "DNS": [ { "value": "ns1.beget.ru", "priority": 10 }, { "value": "ns2.beget.ru", "priority": 20 } ], "DNS_IP": [ { "value": null, "priority": 10 }, { "value": null, "priority": 20 } ], "A": [ { "value": "91.106.201.65", "priority": "0" } ], "MX": [ { "value": "mx1.beget.ru", "priority": "10" }, { "value": "mx2.beget.ru", "priority": "20" } ], "TXT": [ { "value": "", "priority": 0 } ] }, "set_type": 1 } } } ================================================ FILE: providers/dns/beget/internal/fixtures/getData-real.json ================================================ { "status": "success", "answer": { "status": "success", "result": { "is_under_control": true, "is_beget_dns": true, "is_subdomain": false, "fqdn": "example.com", "records": { "MX": [ { "ttl": 300, "exchange": "mx2.beget.com.", "preference": 20 }, { "ttl": 300, "exchange": "mx1.beget.com.", "preference": 10 } ], "TXT": [ { "ttl": 300, "txtdata": "v=spf1 redirect=beget.com" } ], "A": [ { "ttl": 300, "address": "1.2.3.4" } ], "DNS": [ { "value": "ns1.beget.pro" }, { "value": "ns2.beget.pro" }, { "value": "ns1.beget.com" }, { "value": "ns2.beget.com" } ], "DNS_IP": [ { "value": "" }, { "value": "" }, { "value": "" }, { "value": "" } ] }, "set_type": 1 } } } ================================================ FILE: providers/dns/beget/internal/fixtures/getData.json ================================================ { "status": "success", "answer": { "status": "success", "result": { "is_under_control": true, "is_beget_dns": true, "is_subdomain": false, "fqdn": "_acme-challenge.example.com", "records": { "MX": [ { "ttl": 300, "exchange": "mx2.beget.com.", "preference": 20 }, { "ttl": 300, "exchange": "mx1.beget.com.", "preference": 10 } ], "TXT": [ { "ttl": 300, "txtdata": "foo" } ], "A": [ { "ttl": 300, "address": "1.2.3.4" } ], "DNS": [ { "value": "ns1.beget.pro" }, { "value": "ns2.beget.pro" }, { "value": "ns1.beget.com" }, { "value": "ns2.beget.com" } ], "DNS_IP": [ { "value": "" }, { "value": "" }, { "value": "" }, { "value": "" } ] }, "set_type": 1 } } } ================================================ FILE: providers/dns/beget/internal/fixtures/getData_empty.json ================================================ { "status": "success", "answer": { "status": "success", "result": { "is_under_control": true, "is_beget_dns": true, "is_subdomain": false, "fqdn": "_acme-challenge.example.com", "set_type": 1 } } } ================================================ FILE: providers/dns/beget/internal/types.go ================================================ package internal import ( "encoding/json" "fmt" "strings" ) const successResult = "success" // APIResponse is the representation of an API response. type APIResponse struct { Status string `json:"status"` Answer *Answer `json:"answer,omitempty"` ErrorCode string `json:"error_code,omitempty"` ErrorText string `json:"error_text,omitempty"` } func (a APIResponse) Error() string { return fmt.Sprintf("API %s: %s: %s", a.Status, a.ErrorCode, a.ErrorText) } // HasError returns an error is the response contains an error. func (a APIResponse) HasError() error { if a.Status != successResult { return a } if a.Answer == nil || a.Status != successResult || a.Answer.Status != successResult { return a.Answer } return nil } // Answer is the representation of an API response answer. type Answer struct { Status string `json:"status,omitempty"` Result json.RawMessage `json:"result,omitempty"` Errors []AnswerError `json:"errors,omitempty"` ErrorCode string `json:"error_code,omitempty"` ErrorText string `json:"error_text,omitempty"` } type AnswerError struct { ErrorCode string `json:"error_code,omitempty"` ErrorText string `json:"error_text,omitempty"` } func (a Answer) Error() string { parts := []string{fmt.Sprintf("API answer %s", a.Status)} if a.ErrorCode != "" { parts = append(parts, a.ErrorCode) } if a.ErrorText != "" { parts = append(parts, a.ErrorText) } if len(a.Errors) > 0 { for _, e := range a.Errors { parts = append(parts, e.ErrorCode, e.ErrorText) } } return strings.Join(parts, ": ") } // GetRecordsRequest data representation for data get request. type GetRecordsRequest struct { Fqdn string `json:"fqdn,omitempty"` } // ChangeRecordsRequest data representation for data change request. type ChangeRecordsRequest struct { Fqdn string `json:"fqdn,omitempty"` Records RecordList `json:"records"` } // RecordList List of entries (in this case only described TXT). type RecordList struct { TXT []Record `json:"TXT,omitempty"` } // Record data representation for TXT record. type Record struct { Value string `json:"value,omitempty"` Data string `json:"txtdata,omitempty"` Priority int `json:"priority,omitempty"` TTL int `json:"ttl,omitempty"` } type GetRecordsResult struct { Fqdn string `json:"fqdn"` Records RecordList `json:"records"` } ================================================ FILE: providers/dns/binarylane/binarylane.go ================================================ // Package binarylane implements a DNS provider for solving the DNS-01 challenge using Binary Lane. package binarylane import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/binarylane/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "BINARYLANE_" EnvAPIToken = envNamespace + "API_TOKEN" 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 { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]int64 recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Binary Lane. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("binarylane: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Binary Lane. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("binarylane: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIToken) if err != nil { return nil, fmt.Errorf("binarylane: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int64), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("binarylane: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("binarylane: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Data: info.Value, TTL: d.config.TTL, } response, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("binarylane: create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = response.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("binarylane: could not find zone for domain %q: %w", domain, err) } // get the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("binarylane: unknown record ID for '%s'", info.EffectiveFQDN) } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) if err != nil { return fmt.Errorf("binarylane: delete record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/binarylane/binarylane.toml ================================================ Name = "Binary Lane" Description = '''''' URL = "https://www.binarylane.com.au/" Code = "binarylane" Since = "v4.26.0" Example = ''' BINARYLANE_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns binarylane -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BINARYLANE_API_TOKEN = "API token" [Configuration.Additional] BINARYLANE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" BINARYLANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" BINARYLANE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" BINARYLANE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.binarylane.com.au/reference/#tag/Domains" ================================================ FILE: providers/dns/binarylane/binarylane_test.go ================================================ package binarylane import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "secret", }, }, { desc: "missing API token", envVars: map[string]string{ EnvAPIToken: "", }, expected: "binarylane: some credentials information are missing: BINARYLANE_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "secret", }, { desc: "missing API token", expected: "binarylane: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/binarylane/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.binarylane.com.au/v2/" const authorizationHeader = "Authorization" // Client the Binary Lane API client. type Client struct { apiToken string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiToken string) (*Client, error) { if apiToken == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiToken: apiToken, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // CreateRecord Creates a new domain record. // https://api.binarylane.com.au/reference/#tag/Domains/paths/~1v2~1domains~1%7Bdomain_name%7D~1records/post func (c *Client) CreateRecord(ctx context.Context, domain string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("domains", domain, "records") if record.Name == "" { record.Name = "@" } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } var result APIResponse err = c.do(req, &result) if err != nil { return nil, err } return result.DomainRecord, nil } // DeleteRecord Deletes an existing domain record. // https://api.binarylane.com.au/reference/#tag/Domains/paths/~1v2~1domains~1%7Bdomain_name%7D~1records~1%7Brecord_id%7D/delete func (c *Client) DeleteRecord(ctx context.Context, domainName string, recordID int64) error { endpoint := c.baseURL.JoinPath("domains", domainName, "records", strconv.FormatInt(recordID, 10)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, "Bearer "+c.apiToken) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/binarylane/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/records", servermock.ResponseFromFixture("create_record.json"), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := Record{ Type: "TXT", Name: "foo", Data: "txtTXTtxt", TTL: 300, } rec, err := client.CreateRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Record{ ID: 123, Type: "TXT", Name: "foo", Data: "txtTXTtxt", TTL: 300, } require.Equal(t, expected, rec) } func TestClient_CreateRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) record := Record{ Type: "TXT", Name: "foo", Data: "txtTXTtxt", TTL: 300, } _, err := client.CreateRecord(t.Context(), "example.com", record) require.EqualError(t, err, "400: type: title: detail: instance: property1: a") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/records/123", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.DeleteRecord(t.Context(), "example.com", 123) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/records/123", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) err := client.DeleteRecord(t.Context(), "example.com", 123) require.EqualError(t, err, "400: type: title: detail: instance: property1: a") } ================================================ FILE: providers/dns/binarylane/internal/fixtures/create_record-request.json ================================================ { "type": "TXT", "name": "foo", "data": "txtTXTtxt", "ttl": 300 } ================================================ FILE: providers/dns/binarylane/internal/fixtures/create_record.json ================================================ { "domain_record": { "id": 123, "type": "TXT", "name": "foo", "data": "txtTXTtxt", "ttl": 300 } } ================================================ FILE: providers/dns/binarylane/internal/fixtures/error.json ================================================ { "type": "type", "title": "title", "status": 400, "detail": "detail", "instance": "instance", "errors": { "property1": [ "a" ] }, "property1": null, "property2": null } ================================================ FILE: providers/dns/binarylane/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type APIError struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail"` Instance string `json:"instance"` Errors map[string][]string `json:"errors"` } func (a *APIError) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance) for s, values := range a.Errors { _, _ = fmt.Fprintf(msg, ": %s: %s", s, strings.Join(values, ", ")) } return msg.String() } type Record struct { ID int64 `json:"id,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Data string `json:"data,omitempty"` Priority int `json:"priority,omitempty"` Port int `json:"port,omitempty"` TTL int `json:"ttl,omitempty"` Weight int `json:"weight,omitempty"` Flags int `json:"flags,omitempty"` Tag string `json:"tag,omitempty"` } type APIResponse struct { DomainRecord *Record `json:"domain_record"` } ================================================ FILE: providers/dns/bindman/bindman.go ================================================ // Package bindman implements a DNS provider for solving the DNS-01 challenge. package bindman import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" bindman "github.com/labbsr0x/bindman-dns-webhook/src/client" ) // Environment variables names. const ( envNamespace = "BINDMAN_" EnvManagerAddress = envNamespace + "MANAGER_ADDRESS" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { PropagationTimeout time.Duration PollingInterval time.Duration BaseURL string HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *bindman.DNSWebhookClient } // NewDNSProvider returns a DNSProvider instance configured for Bindman. // BINDMAN_MANAGER_ADDRESS should have the scheme, hostname, and port (if required) of the authoritative Bindman Manager server. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvManagerAddress) if err != nil { return nil, fmt.Errorf("bindman: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvManagerAddress] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Bindman. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("bindman: the configuration of the DNS provider is nil") } if config.BaseURL == "" { return nil, errors.New("bindman: bindman manager address missing") } // Because the client.New uses the http.DefaultClient. if config.HTTPClient == nil { config.HTTPClient = &http.Client{Timeout: time.Minute} } client, err := bindman.New(config.BaseURL, clientdebug.Wrap(config.HTTPClient)) if err != nil { return nil, fmt.Errorf("bindman: %w", err) } return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record using the specified parameters. // This will *not* create a subzone to contain the TXT record, // so make sure the FQDN specified is within an extant zone. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) if err := d.client.AddRecord(info.EffectiveFQDN, "TXT", info.Value); err != nil { return fmt.Errorf("bindman: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) if err := d.client.RemoveRecord(info.EffectiveFQDN, "TXT"); err != nil { return fmt.Errorf("bindman: %w", err) } return 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 } ================================================ FILE: providers/dns/bindman/bindman.toml ================================================ Name = "Bindman" Description = '''''' URL = "https://github.com/labbsr0x/bindman-dns-webhook" Code = "bindman" Since = "v2.6.0" Example = ''' BINDMAN_MANAGER_ADDRESS= \ lego --dns bindman -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BINDMAN_MANAGER_ADDRESS = "The server URL, should have scheme, hostname, and port (if required) of the Bindman-DNS Manager server" [Configuration.Additional] BINDMAN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" BINDMAN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" BINDMAN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://gitlab.isc.org/isc-projects/bind9" GoClient = "https://github.com/labbsr0x/bindman-dns-webhook" ================================================ FILE: providers/dns/bindman/bindman_test.go ================================================ package bindman import ( "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvManagerAddress).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvManagerAddress: "http://localhost", }, }, { desc: "missing bindman manager address", envVars: map[string]string{ EnvManagerAddress: "", }, expected: "bindman: some credentials information are missing: BINDMAN_MANAGER_ADDRESS", }, { desc: "empty bindman manager address", envVars: map[string]string{ EnvManagerAddress: " ", }, expected: "bindman: managerAddress parameter must be a non-empty string", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string config *Config expected string }{ { desc: "success", config: &Config{BaseURL: "http://localhost"}, }, { desc: "missing base URL", config: &Config{BaseURL: ""}, expected: "bindman: bindman manager address missing", }, { desc: "missing base URL", config: &Config{BaseURL: " "}, expected: "bindman: managerAddress parameter must be a non-empty string", }, { desc: "missing config", expected: "bindman: the configuration of the DNS provider is nil", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p, err := NewDNSProviderConfig(test.config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.BaseURL = server.URL config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithJSONHeaders(). With("User-Agent", "bindman-dns-webhook-client")) } func TestDNSProvider_Present(t *testing.T) { testCases := []struct { name string mock *servermock.Builder[*DNSProvider] domain string token string keyAuth string expectError bool }{ { name: "success when add record function return no error", mock: mockBuilder(). Route("POST /records", servermock.Noop().WithStatusCode(http.StatusNoContent), servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"), ), domain: "example.com", keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw", expectError: false, }, { name: "error when add record function return an error", mock: mockBuilder(). Route("POST /records", servermock.ResponseFromFixture("error.json"), ), domain: "example.com", keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw", expectError: true, }, } for _, test := range testCases { t.Run(test.name, func(t *testing.T) { provider := test.mock.Build(t) err := provider.Present(test.domain, test.token, test.keyAuth) if test.expectError { require.Error(t, err) } else { require.NoError(t, err) } }) } } func TestDNSProvider_CleanUp(t *testing.T) { testCases := []struct { name string mock *servermock.Builder[*DNSProvider] domain string token string keyAuth string expectError bool }{ { name: "success when remove record function return no error", mock: mockBuilder(). Route("DELETE /records/_acme-challenge.example.com./TXT", servermock.Noop().WithStatusCode(http.StatusNoContent), ), domain: "example.com", keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw", expectError: false, }, { name: "error when remove record function return an error", mock: mockBuilder(). Route("DELETE /records/_acme-challenge.example.com./TXT", servermock.ResponseFromFixture("error.json"), ), domain: "example.com", keyAuth: "szDTG4zmM0GsKG91QAGO2M4UYOJMwU8oFpWOP7eTjCw", expectError: true, }, } for _, test := range testCases { t.Run(test.name, func(t *testing.T) { provider := test.mock.Build(t) err := provider.CleanUp(test.domain, test.token, test.keyAuth) if test.expectError { require.ErrorContains(t, err, "bindman: ERROR (400): bar; ") } else { require.NoError(t, err) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/bindman/fixtures/add_record-request.json ================================================ { "name": "_acme-challenge.example.com.", "value": "_EYMkjukXEMcXbnvpT6WLESzfYhxH190NKTBo3cpu-E", "type": "TXT" } ================================================ FILE: providers/dns/bindman/fixtures/error.json ================================================ { "message": "bar", "code": 400, "details": ["foo"] } ================================================ FILE: providers/dns/bluecat/bluecat.go ================================================ // Package bluecat implements a DNS provider for solving the DNS-01 challenge using a self-hosted Bluecat Address Manager. package bluecat import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/bluecat/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "BLUECAT_" EnvServerURL = envNamespace + "SERVER_URL" EnvUserName = envNamespace + "USER_NAME" EnvPassword = envNamespace + "PASSWORD" EnvConfigName = envNamespace + "CONFIG_NAME" EnvDNSView = envNamespace + "DNS_VIEW" EnvDebug = envNamespace + "DEBUG" EnvSkipDeploy = envNamespace + "SKIP_DEPLOY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string UserName string Password string ConfigName string DNSView string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client Debug bool SkipDeploy bool } // 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), }, Debug: env.GetOrDefaultBool(EnvDebug, false), SkipDeploy: env.GetOrDefaultBool(EnvSkipDeploy, false), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Bluecat DNS. // Credentials must be passed in the environment variables: // - BLUECAT_SERVER_URL // It should have the scheme, hostname, and port (if required) of the authoritative Bluecat BAM server. // The REST endpoint will be appended. // - BLUECAT_USER_NAME and BLUECAT_PASSWORD // - BLUECAT_CONFIG_NAME (the Configuration name) // - BLUECAT_DNS_VIEW (external DNS View Name) func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvServerURL, EnvUserName, EnvPassword, EnvConfigName, EnvDNSView) if err != nil { return nil, fmt.Errorf("bluecat: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvServerURL] config.UserName = values[EnvUserName] config.Password = values[EnvPassword] config.ConfigName = values[EnvConfigName] config.DNSView = values[EnvDNSView] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Bluecat DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("bluecat: the configuration of the DNS provider is nil") } if config.BaseURL == "" || config.UserName == "" || config.Password == "" || config.ConfigName == "" || config.DNSView == "" { return nil, errors.New("bluecat: credentials missing") } client := internal.NewClient(config.BaseURL, config.UserName, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record using the specified parameters // This will *not* create a sub-zone to contain the TXT record, // so make sure the FQDN specified is within an existent zone. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("bluecat: login: %w", err) } viewID, err := d.client.LookupViewID(ctx, d.config.ConfigName, d.config.DNSView) if err != nil { return fmt.Errorf("bluecat: lookupViewID: %w", err) } parentZoneID, name, err := d.client.LookupParentZoneID(ctx, viewID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("bluecat: lookupParentZoneID: %w", err) } if d.config.Debug { log.Infof("fqdn: %s; viewID: %d; ZoneID: %d; zone: %s", info.EffectiveFQDN, viewID, parentZoneID, name) } txtRecord := internal.Entity{ Name: name, Type: internal.TXTType, Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", d.config.TTL, info.EffectiveFQDN, info.Value), } _, err = d.client.AddEntity(ctx, parentZoneID, txtRecord) if err != nil { return fmt.Errorf("bluecat: add TXT record: %w", err) } if !d.config.SkipDeploy { err = d.client.Deploy(ctx, parentZoneID) if err != nil { return fmt.Errorf("bluecat: deploy: %w", err) } } err = d.client.Logout(ctx) if err != nil { return fmt.Errorf("bluecat: logout: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("bluecat: login: %w", err) } viewID, err := d.client.LookupViewID(ctx, d.config.ConfigName, d.config.DNSView) if err != nil { return fmt.Errorf("bluecat: lookupViewID: %w", err) } parentZoneID, name, err := d.client.LookupParentZoneID(ctx, viewID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("bluecat: lookupParentZoneID: %w", err) } txtRecord, err := d.client.GetEntityByName(ctx, parentZoneID, name, internal.TXTType) if err != nil { return fmt.Errorf("bluecat: get TXT record: %w", err) } err = d.client.Delete(ctx, txtRecord.ID) if err != nil { return fmt.Errorf("bluecat: delete TXT record: %w", err) } if !d.config.SkipDeploy { err = d.client.Deploy(ctx, parentZoneID) if err != nil { return fmt.Errorf("bluecat: deploy: %w", err) } } err = d.client.Logout(ctx) if err != nil { return fmt.Errorf("bluecat: logout: %w", err) } return 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 } ================================================ FILE: providers/dns/bluecat/bluecat.toml ================================================ Name = "Bluecat" Description = '''''' URL = "https://www.bluecatnetworks.com" Code = "bluecat" Since = "v0.5.0" Example = ''' BLUECAT_PASSWORD=mypassword \ BLUECAT_DNS_VIEW=myview \ BLUECAT_USER_NAME=myusername \ BLUECAT_CONFIG_NAME=myconfig \ BLUECAT_SERVER_URL=https://bam.example.com \ BLUECAT_TTL=30 \ lego --dns bluecat -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BLUECAT_SERVER_URL = "The server URL, should have scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve" BLUECAT_USER_NAME = "API username" BLUECAT_PASSWORD = "API password" BLUECAT_CONFIG_NAME = "Configuration name" BLUECAT_DNS_VIEW = "External DNS View Name" [Configuration.Additional] BLUECAT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" BLUECAT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" BLUECAT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" BLUECAT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" BLUECAT_SKIP_DEPLOY = "Skip deployements" [Links] API = "https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/REST-API/9.1.0" ================================================ FILE: providers/dns/bluecat/bluecat_test.go ================================================ package bluecat import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvServerURL, EnvUserName, EnvPassword, EnvConfigName, EnvDNSView). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvServerURL: "http://localhost", EnvUserName: "A", EnvPassword: "B", EnvConfigName: "C", EnvDNSView: "D", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvServerURL: "", EnvUserName: "", EnvPassword: "", EnvConfigName: "", EnvDNSView: "", }, expected: "bluecat: some credentials information are missing: BLUECAT_SERVER_URL,BLUECAT_USER_NAME,BLUECAT_PASSWORD,BLUECAT_CONFIG_NAME,BLUECAT_DNS_VIEW", }, { desc: "missing server url", envVars: map[string]string{ EnvServerURL: "", EnvUserName: "A", EnvPassword: "B", EnvConfigName: "C", EnvDNSView: "D", }, expected: "bluecat: some credentials information are missing: BLUECAT_SERVER_URL", }, { desc: "missing username", envVars: map[string]string{ EnvServerURL: "http://localhost", EnvUserName: "", EnvPassword: "B", EnvConfigName: "C", EnvDNSView: "D", }, expected: "bluecat: some credentials information are missing: BLUECAT_USER_NAME", }, { desc: "missing password", envVars: map[string]string{ EnvServerURL: "http://localhost", EnvUserName: "A", EnvPassword: "", EnvConfigName: "C", EnvDNSView: "D", }, expected: "bluecat: some credentials information are missing: BLUECAT_PASSWORD", }, { desc: "missing config name", envVars: map[string]string{ EnvServerURL: "http://localhost", EnvUserName: "A", EnvPassword: "B", EnvConfigName: "", EnvDNSView: "D", }, expected: "bluecat: some credentials information are missing: BLUECAT_CONFIG_NAME", }, { desc: "missing DNS view", envVars: map[string]string{ EnvServerURL: "http://localhost", EnvUserName: "A", EnvPassword: "B", EnvConfigName: "C", EnvDNSView: "", }, expected: "bluecat: some credentials information are missing: BLUECAT_DNS_VIEW", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string baseURL string userName string password string configName string dnsView string expected string }{ { desc: "success", baseURL: "http://localhost", userName: "A", password: "B", configName: "C", dnsView: "D", }, { desc: "missing credentials", expected: "bluecat: credentials missing", }, { desc: "missing base URL", baseURL: "", userName: "A", password: "B", configName: "C", dnsView: "D", expected: "bluecat: credentials missing", }, { desc: "missing username", baseURL: "http://localhost", userName: "", password: "B", configName: "C", dnsView: "D", expected: "bluecat: credentials missing", }, { desc: "missing password", baseURL: "http://localhost", userName: "A", password: "", configName: "C", dnsView: "D", expected: "bluecat: credentials missing", }, { desc: "missing config name", baseURL: "http://localhost", userName: "A", password: "B", configName: "", dnsView: "D", expected: "bluecat: credentials missing", }, { desc: "missing DNS view", baseURL: "http://localhost", userName: "A", password: "B", configName: "C", dnsView: "", expected: "bluecat: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.BaseURL = test.baseURL config.UserName = test.userName config.Password = test.password config.ConfigName = test.configName config.DNSView = test.dnsView p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(time.Second * 1) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/bluecat/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // Object types. const ( ConfigType = "Configuration" ViewType = "View" ZoneType = "Zone" TXTType = "TXTRecord" ) const authorizationHeader = "Authorization" type Client struct { username string password string tokenExp *regexp.Regexp baseURL *url.URL HTTPClient *http.Client } func NewClient(baseURL, username, password string) *Client { bu, _ := url.Parse(baseURL) return &Client{ username: username, password: password, tokenExp: regexp.MustCompile("BAMAuthToken: [^ ]+"), baseURL: bu, HTTPClient: &http.Client{Timeout: 30 * time.Second}, } } // Deploy the DNS config for the specified entity to the authoritative servers. // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/quickDeploy/9.5.0 func (c *Client) Deploy(ctx context.Context, entityID uint) error { endpoint := c.createEndpoint("quickDeploy") q := endpoint.Query() q.Set("entityId", strconv.FormatUint(uint64(entityID), 10)) endpoint.RawQuery = q.Encode() req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil) if err != nil { return err } resp, err := c.doAuthenticated(ctx, req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() // The API doc says that 201 is expected but in the reality 200 is return. if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } // AddEntity A generic method for adding configurations, DNS zones, and DNS resource records. // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/addEntity/9.5.0 func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (uint64, error) { endpoint := c.createEndpoint("addEntity") q := endpoint.Query() q.Set("parentId", strconv.FormatUint(uint64(parentID), 10)) endpoint.RawQuery = q.Encode() req, err := newJSONRequest(ctx, http.MethodPost, endpoint, entity) if err != nil { return 0, err } resp, err := c.doAuthenticated(ctx, req) if err != nil { return 0, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return 0, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, _ := io.ReadAll(resp.Body) // addEntity responds only with body text containing the ID of the created record addTxtResp := string(raw) id, err := strconv.ParseUint(addTxtResp, 10, 64) if err != nil { return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp) } return id, nil } // GetEntityByName Returns objects from the database referenced by their database ID and with its properties fields populated. // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/getEntityById/9.5.0 func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objType string) (*EntityResponse, error) { endpoint := c.createEndpoint("getEntityByName") q := endpoint.Query() q.Set("parentId", strconv.FormatUint(uint64(parentID), 10)) q.Set("name", name) q.Set("type", objType) endpoint.RawQuery = q.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } resp, err := c.doAuthenticated(ctx, req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var entity EntityResponse err = json.Unmarshal(raw, &entity) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &entity, nil } // Delete Deletes an object using the generic delete method. // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/DELETE/v1/delete/9.5.0 func (c *Client) Delete(ctx context.Context, objectID uint) error { endpoint := c.createEndpoint("delete") q := endpoint.Query() q.Set("objectId", strconv.FormatUint(uint64(objectID), 10)) endpoint.RawQuery = q.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } resp, err := c.doAuthenticated(ctx, req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() // The API doc says that 204 is expected but in the reality 200 is returned. if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } // LookupViewID Find the DNS view with the given name within. func (c *Client) LookupViewID(ctx context.Context, configName, viewName string) (uint, error) { // Lookup the entity ID of the configuration named in our properties. conf, err := c.GetEntityByName(ctx, 0, configName, ConfigType) if err != nil { return 0, err } view, err := c.GetEntityByName(ctx, conf.ID, viewName, ViewType) if err != nil { return 0, err } return view.ID, nil } // LookupParentZoneID returns the entityId of the parent zone by iterating through the root labels. // Also return the simple name of the host. func (c *Client) LookupParentZoneID(ctx context.Context, viewID uint, fqdn string) (uint, string, error) { if fqdn == "" { return viewID, "", nil } zones := strings.Split(strings.Trim(fqdn, "."), ".") name := zones[0] parentViewID := viewID for i := len(zones) - 1; i > -1; i-- { zone, err := c.GetEntityByName(ctx, parentViewID, zones[i], ZoneType) if err != nil { return 0, "", fmt.Errorf("could not find zone named %s: %w", name, err) } if zone == nil || zone.ID == 0 { break } if i > 0 { name = strings.Join(zones[0:i], ".") } parentViewID = zone.ID } return parentViewID, name, nil } func (c *Client) createEndpoint(resource string) *url.URL { return c.baseURL.JoinPath("Services", "REST", "v1", resource) } func (c *Client) doAuthenticated(ctx context.Context, req *http.Request) (*http.Response, error) { tok := getToken(ctx) if tok != "" { req.Header.Set(authorizationHeader, tok) } return c.HTTPClient.Do(req) } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/bluecat/internal/client_test.go ================================================ package internal import ( "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient(server.URL, "user", "secret") client.HTTPClient = server.Client() return client, nil } func TestClient_LookupParentZoneID(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /Services/REST/v1/getEntityByName", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { query := req.URL.Query() if query.Get("name") == "com" { _ = json.NewEncoder(rw).Encode(EntityResponse{ ID: 2, Name: "com", Type: ZoneType, Properties: "test", }) return } _, _ = rw.Write([]byte(`{}`)) })). Build(t) parentID, name, err := client.LookupParentZoneID(t.Context(), 2, "foo.example.com") require.NoError(t, err) assert.EqualValues(t, 2, parentID) assert.Equal(t, "foo.example", name) } ================================================ FILE: providers/dns/bluecat/internal/identity.go ================================================ package internal import ( "context" "fmt" "io" "net/http" "strings" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) type token string const tokenKey token = "token" // login Logs in as API user. // Authenticates and receives a token to be used in for subsequent requests. // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/login/9.5.0 func (c *Client) login(ctx context.Context) (string, error) { endpoint := c.createEndpoint("login") q := endpoint.Query() q.Set("username", c.username) q.Set("password", c.password) endpoint.RawQuery = q.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", err } resp, err := c.HTTPClient.Do(req) if err != nil { return "", errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return "", errutils.NewReadResponseError(req, resp.StatusCode, err) } authResp := string(raw) if strings.Contains(authResp, "Authentication Error") { return "", fmt.Errorf("request failed: %s", strings.Trim(authResp, `"`)) } // Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username" tok := c.tokenExp.FindString(authResp) return tok, nil } // Logout Logs out of the current API session. // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/logout/9.5.0 func (c *Client) Logout(ctx context.Context) error { if getToken(ctx) == "" { // nothing to do return nil } endpoint := c.createEndpoint("logout") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } resp, err := c.doAuthenticated(ctx, req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } authResp := string(raw) if !strings.Contains(authResp, "successfully") { return fmt.Errorf("request failed to delete session: %s", strings.Trim(authResp, `"`)) } return nil } func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { tok, err := c.login(ctx) if err != nil { return nil, err } return context.WithValue(ctx, tokenKey, tok), nil } func getToken(ctx context.Context) string { tok, ok := ctx.Value(tokenKey).(string) if !ok { return "" } return tok } ================================================ FILE: providers/dns/bluecat/internal/identity_test.go ================================================ package internal import ( "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const fakeToken = "BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM=" func TestClient_CreateAuthenticatedContext(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /Services/REST/v1/login", servermock.RawStringResponse(fakeToken), servermock.CheckQueryParameter(). With("username", "user"). With("password", "secret")). Route("DELETE /Services/REST/v1/delete", nil, servermock.CheckHeader(). WithAuthorization(fakeToken)). Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) at := getToken(ctx) assert.Equal(t, fakeToken, at) err = client.Delete(ctx, 123) require.NoError(t, err) } ================================================ FILE: providers/dns/bluecat/internal/types.go ================================================ package internal // Entity JSON body for Bluecat entity requests. type Entity struct { ID string `json:"id,omitempty"` Name string `json:"name"` Type string `json:"type"` Properties string `json:"properties"` } // EntityResponse JSON body for Bluecat entity responses. type EntityResponse struct { ID uint `json:"id"` Name string `json:"name"` Type string `json:"type"` Properties string `json:"properties"` } ================================================ FILE: providers/dns/bluecatv2/bluecatv2.go ================================================ // Package bluecatv2 implements a DNS provider for solving the DNS-01 challenge using Bluecat v2. package bluecatv2 import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "BLUECATV2_" EnvServerURL = envNamespace + "SERVER_URL" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvConfigName = envNamespace + "CONFIG_NAME" EnvViewName = envNamespace + "VIEW_NAME" EnvSkipDeploy = envNamespace + "SKIP_DEPLOY" 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 { ServerURL string Username string Password string ConfigName string ViewName string SkipDeploy bool PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ SkipDeploy: env.GetOrDefaultBool(EnvSkipDeploy, false), 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 { config *Config client *internal.Client zoneIDs map[string]int64 recordIDs map[string]int64 recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Bluecat v2. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword, EnvConfigName, EnvViewName) if err != nil { return nil, fmt.Errorf("bluecatv2: %w", err) } config := NewDefaultConfig() config.ServerURL = values[EnvServerURL] config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.ConfigName = values[EnvConfigName] config.ViewName = values[EnvViewName] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Bluecat v2. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("bluecatv2: the configuration of the DNS provider is nil") } if config.ServerURL == "" { return nil, errors.New("bluecatv2: missing server URL") } if config.ConfigName == "" { return nil, errors.New("bluecatv2: missing configuration name") } if config.ViewName == "" { return nil, errors.New("bluecatv2: missing view name") } client, err := internal.NewClient(config.ServerURL, config.Username, config.Password) if err != nil { return nil, fmt.Errorf("bluecatv2: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int64), zoneIDs: make(map[string]int64), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("bluecatv2: %w", err) } zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("bluecatv2: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.AbsoluteName) if err != nil { return fmt.Errorf("bluecatv2: %w", err) } record := internal.RecordTXT{ CommonResource: internal.CommonResource{ Type: "TXTRecord", Name: subDomain, }, Text: info.Value, TTL: d.config.TTL, RecordType: "TXT", } newRecord, err := d.client.CreateZoneResourceRecord(ctx, zone.ID, record) if err != nil { return fmt.Errorf("bluecatv2: create resource record: %w", err) } d.recordIDsMu.Lock() d.zoneIDs[token] = zone.ID d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() if d.config.SkipDeploy { return nil } _, err = d.client.CreateZoneDeployment(ctx, zone.ID) if err != nil { return fmt.Errorf("bluecat: deploy zone: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() recordID, recordOK := d.recordIDs[token] zoneID, zoneOK := d.zoneIDs[token] d.recordIDsMu.Unlock() if !recordOK { return fmt.Errorf("bluecatv2: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } if !zoneOK { return fmt.Errorf("bluecatv2: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("bluecatv2: %w", err) } err = d.client.DeleteResourceRecord(ctx, recordID) if err != nil { return fmt.Errorf("bluecatv2: delete resource record: %w", err) } if d.config.SkipDeploy { return nil } _, err = d.client.CreateZoneDeployment(ctx, zoneID) if err != nil { return fmt.Errorf("bluecat: deploy zone: %w", err) } return 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 } func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.ZoneResource, error) { for name := range dns01.UnFqdnDomainsSeq(fqdn) { opts := &internal.CollectionOptions{ Fields: "id,absoluteName,configuration.id,configuration.name,view.id,view.name", Filter: internal.And( internal.Eq("absoluteName", name), internal.Eq("configuration.name", d.config.ConfigName), internal.Eq("view.name", d.config.ViewName), ).String(), } zones, err := d.client.RetrieveZones(ctx, opts) if err != nil { // TODO(ldez) maybe add a log in v5. continue } for _, zone := range zones { if zone.AbsoluteName == name { return &zone, nil } } } return nil, fmt.Errorf("no zone found for fqdn: %s", fqdn) } ================================================ FILE: providers/dns/bluecatv2/bluecatv2.toml ================================================ Name = "Bluecat v2" Description = '''''' URL = "https://www.bluecatnetworks.com" Code = "bluecatv2" Since = "v4.32.0" Example = ''' BLUECATV2_SERVER_URL="https://example.com" \ BLUECATV2_USERNAME="xxx" \ BLUECATV2_PASSWORD="yyy" \ BLUECATV2_CONFIG_NAME="myConfiguration" \ BLUECATV2_VIEW_NAME="myView" \ lego --dns bluecatv2 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BLUECAT_SERVER_URL = "The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve" BLUECATV2_USERNAME = "API username" BLUECATV2_PASSWORD = "API password" BLUECATV2_CONFIG_NAME = "Configuration name" BLUECATV2_VIEW_NAME = "DNS View Name" [Configuration.Additional] BLUECATV2_SKIP_DEPLOY = "Skip quick deployements" BLUECATV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" BLUECATV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" BLUECATV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" BLUECATV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0" Swagger = "http://{Address_Manager_IP}/api/openapi.json" SwaggerDump = "https://github.com/go-acme/lego/discussions/2218#discussioncomment-13060545" ================================================ FILE: providers/dns/bluecatv2/bluecatv2_test.go ================================================ package bluecatv2 import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvServerURL, EnvUsername, EnvPassword, EnvConfigName, EnvViewName, EnvSkipDeploy, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvServerURL: "https://example.com/", EnvUsername: "userA", EnvPassword: "secret", EnvConfigName: "myConfig", EnvViewName: "myView", }, }, { desc: "missing server URL", envVars: map[string]string{ EnvServerURL: "", EnvUsername: "userA", EnvPassword: "secret", EnvConfigName: "myConfig", EnvViewName: "myView", }, expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL", }, { desc: "missing username", envVars: map[string]string{ EnvServerURL: "https://example.com/", EnvUsername: "", EnvPassword: "secret", EnvConfigName: "myConfig", EnvViewName: "myView", }, expected: "bluecatv2: some credentials information are missing: BLUECATV2_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvServerURL: "https://example.com/", EnvUsername: "userA", EnvPassword: "", EnvConfigName: "myConfig", EnvViewName: "myView", }, expected: "bluecatv2: some credentials information are missing: BLUECATV2_PASSWORD", }, { desc: "missing configuration name", envVars: map[string]string{ EnvServerURL: "https://example.com/", EnvUsername: "userA", EnvPassword: "secret", EnvConfigName: "", EnvViewName: "myView", }, expected: "bluecatv2: some credentials information are missing: BLUECATV2_CONFIG_NAME", }, { desc: "missing view name", envVars: map[string]string{ EnvServerURL: "https://example.com/", EnvUsername: "userA", EnvPassword: "secret", EnvConfigName: "myConfig", EnvViewName: "", }, expected: "bluecatv2: some credentials information are missing: BLUECATV2_VIEW_NAME", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL,BLUECATV2_USERNAME,BLUECATV2_PASSWORD,BLUECATV2_CONFIG_NAME,BLUECATV2_VIEW_NAME", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string serverURL string username string password string configName string viewName string expected string }{ { desc: "success", serverURL: "https://example.com/", username: "userA", password: "secret", configName: "myConfig", viewName: "myView", }, { desc: "missing server URL", username: "userA", password: "secret", configName: "myConfig", viewName: "myView", expected: "bluecatv2: missing server URL", }, { desc: "missing username", serverURL: "https://example.com/", password: "secret", configName: "myConfig", viewName: "myView", expected: "bluecatv2: credentials missing", }, { desc: "missing password", serverURL: "https://example.com/", username: "userA", configName: "myConfig", viewName: "myView", expected: "bluecatv2: credentials missing", }, { desc: "missing configuration name", serverURL: "https://example.com/", username: "userA", password: "secret", viewName: "myView", expected: "bluecatv2: missing configuration name", }, { desc: "missing view name", serverURL: "https://example.com/", username: "userA", password: "secret", configName: "myConfig", expected: "bluecatv2: missing view name", }, { desc: "missing credentials", expected: "bluecatv2: missing server URL", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.ServerURL = test.serverURL config.Username = test.username config.Password = test.password config.ConfigName = test.configName config.ViewName = test.viewName p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.ServerURL = server.URL config.Username = "userA" config.Password = "secret" config.ConfigName = "myConfiguration" config.ViewName = "myView" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } return p, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /api/v2/sessions", servermock.ResponseFromInternal("postSession.json"), servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), ). Route("GET /api/v2/configurations", servermock.ResponseFromInternal("configurations.json"), servermock.CheckQueryParameter().Strict(). With("filter", "name:eq('myConfiguration')"), ). Route("GET /api/v2/configurations/12345/views", servermock.ResponseFromInternal("views.json"), servermock.CheckQueryParameter().Strict(). With("filter", "name:eq('myView')"), ). Route("GET /api/v2/zones", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { filter := req.URL.Query().Get("filter") if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) { servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req) return } servermock.ResponseFromInternal("error.json"). WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req) }), ). Route("POST /api/v2/zones/12345/resourceRecords", servermock.ResponseFromInternal("postZoneResourceRecord.json"), servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"), ). Route("POST /api/v2/zones/12345/deployments", servermock.ResponseFromInternal("postZoneDeployment.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_Present_skipDeploy(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(map[string]string{ EnvSkipDeploy: "true", }) provider := mockBuilder(). Route("POST /api/v2/sessions", servermock.ResponseFromInternal("postSession.json"), servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), ). Route("GET /api/v2/configurations", servermock.ResponseFromInternal("configurations.json"), servermock.CheckQueryParameter().Strict(). With("filter", "name:eq('myConfiguration')"), ). Route("GET /api/v2/configurations/12345/views", servermock.ResponseFromInternal("views.json"), servermock.CheckQueryParameter().Strict(). With("filter", "name:eq('myView')"), ). Route("GET /api/v2/zones", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { filter := req.URL.Query().Get("filter") if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) { servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req) return } servermock.ResponseFromInternal("error.json"). WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req) }), ). Route("POST /api/v2/zones/12345/resourceRecords", servermock.ResponseFromInternal("postZoneResourceRecord.json"), servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"), ). Route("POST /api/v2/zones/456789/deployments", servermock.Noop(). WithStatusCode(http.StatusUnauthorized), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("POST /api/v2/sessions", servermock.ResponseFromInternal("postSession.json"), servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), ). Route("DELETE /api/v2/resourceRecords/12345", servermock.ResponseFromInternal("deleteResourceRecord.json"), ). Route("POST /api/v2/zones/456789/deployments", servermock.ResponseFromInternal("postZoneDeployment.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"), ). Build(t) provider.zoneIDs["abc"] = 456789 provider.recordIDs["abc"] = 12345 err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp_skipDeploy(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(map[string]string{ EnvSkipDeploy: "true", }) provider := mockBuilder(). Route("POST /api/v2/sessions", servermock.ResponseFromInternal("postSession.json"), servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), ). Route("DELETE /api/v2/resourceRecords/12345", servermock.ResponseFromInternal("deleteResourceRecord.json"), ). Route("POST /api/v2/zones/456789/deployments", servermock.Noop(). WithStatusCode(http.StatusUnauthorized), ). Build(t) provider.zoneIDs["abc"] = 456789 provider.recordIDs["abc"] = 12345 err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/bluecatv2/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" querystring "github.com/google/go-querystring/query" ) // Client the Bluecat v2 API client. type Client struct { username string password string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(serverURL, username, password string) (*Client, error) { if serverURL == "" { return nil, errors.New("server URL missing") } if username == "" || password == "" { return nil, errors.New("credentials missing") } baseURL, err := url.Parse(serverURL) if err != nil { return nil, err } return &Client{ username: username, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // RetrieveZones retrieves all zones. func (c *Client) RetrieveZones(ctx context.Context, opts *CollectionOptions) ([]ZoneResource, error) { endpoint := c.baseURL.JoinPath("api", "v2", "zones") collection, err := retrieveCollection[ZoneResource](ctx, c, endpoint, opts) if err != nil { return nil, err } return collection.Data, nil } // RetrieveZoneDeployments retrieves all deployments for a zone. func (c *Client) RetrieveZoneDeployments(ctx context.Context, zoneID int64, opts *CollectionOptions) ([]QuickDeployment, error) { endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments") collection, err := retrieveCollection[QuickDeployment](ctx, c, endpoint, opts) if err != nil { return nil, err } return collection.Data, nil } // CreateZoneDeployment creates a new deployment for a zone. func (c *Client) CreateZoneDeployment(ctx context.Context, zoneID int64) (*QuickDeployment, error) { endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments") payload := CommonResource{ Type: "QuickDeployment", } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return nil, err } result := new(QuickDeployment) err = c.doAuthenticated(ctx, req, result) if err != nil { return nil, err } return result, nil } // CreateZoneResourceRecord creates a new TXT record in a zone. func (c *Client) CreateZoneResourceRecord(ctx context.Context, zoneID int64, record RecordTXT) (*RecordTXT, error) { endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "resourceRecords") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } result := new(RecordTXT) err = c.doAuthenticated(ctx, req, result) if err != nil { return nil, err } return result, nil } // DeleteResourceRecord deletes a resource record. func (c *Client) DeleteResourceRecord(ctx context.Context, recordID int64) error { endpoint := c.baseURL.JoinPath("api", "v2", "resourceRecords", strconv.FormatInt(recordID, 10)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.doAuthenticated(ctx, req, nil) } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func retrieveCollection[T any](ctx context.Context, client *Client, endpoint *url.URL, opts *CollectionOptions) (*Collection[T], error) { if opts != nil { values, err := querystring.Values(opts) if err != nil { return nil, err } endpoint.RawQuery = values.Encode() } req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := &Collection[T]{} err = client.doAuthenticated(ctx, req, result) if err != nil { return nil, err } return result, nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/bluecatv2/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilderAuthenticated() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "userA", "secret") if err != nil { return nil, err } client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(), servermock.CheckHeader(). WithAuthorization("Basic secretToken"), ) } func TestClient_RetrieveZones(t *testing.T) { client := mockBuilderAuthenticated(). Route("GET /api/v2/zones", servermock.ResponseFromFixture("zones.json"), servermock.CheckQueryParameter().Strict(). With( "filter", "absoluteName:eq('example.com') and configuration.name:eq('myConfiguration') and view.name:eq('myView')", ), ). Build(t) opts := &CollectionOptions{ Filter: And( Eq("absoluteName", "example.com"), Eq("configuration.name", "myConfiguration"), Eq("view.name", "myView"), ).String(), } result, err := client.RetrieveZones(mockToken(t.Context()), opts) require.NoError(t, err) expected := []ZoneResource{ { CommonResource: CommonResource{ID: 12345, Type: "ENUMZone", Name: "5678"}, AbsoluteName: "string", }, { CommonResource: CommonResource{ID: 12345, Type: "ExternalHostsZone", Name: "name"}, }, { CommonResource: CommonResource{ID: 12345, Type: "InternalRootZone", Name: "name"}, }, { CommonResource: CommonResource{ID: 12345, Type: "ResponsePolicyZone", Name: "name"}, }, { CommonResource: CommonResource{ID: 12345, Type: "Zone", Name: "example.com"}, AbsoluteName: "example.com", }, } assert.Equal(t, expected, result) } func TestClient_RetrieveZones_error(t *testing.T) { client := mockBuilderAuthenticated(). Route("GET /api/v2/zones", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized), ). Build(t) opts := &CollectionOptions{ Filter: And( Eq("absoluteName", "example.com"), Eq("configuration.name", "myConfiguration"), Eq("view.name", "myView"), ).String(), } _, err := client.RetrieveZones(mockToken(t.Context()), opts) require.EqualError(t, err, "401: Unauthorized: InvalidAuthorizationToken: The provided authorization token is invalid") } func TestClient_RetrieveZoneDeployments(t *testing.T) { client := mockBuilderAuthenticated(). Route("GET /api/v2/zones/456789/deployments", servermock.ResponseFromFixture("getZoneDeployments.json"), servermock.CheckQueryParameter().Strict(). With("filter", "id:eq('12345')"), ). Build(t) opts := &CollectionOptions{ Filter: Eq("id", "12345").String(), } result, err := client.RetrieveZoneDeployments(mockToken(t.Context()), 456789, opts) require.NoError(t, err) expected := []QuickDeployment{ { CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment", Name: ""}, State: "PENDING", Status: "CANCEL", Message: "string", PercentComplete: 50, CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC), StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC), CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC), Method: "SCHEDULED", }, } assert.Equal(t, expected, result) } func TestClient_CreateZoneDeployment(t *testing.T) { client := mockBuilderAuthenticated(). Route("POST /api/v2/zones/12345/deployments", servermock.ResponseFromFixture("postZoneDeployment.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromFixture("postZoneDeployment-request.json"), ). Build(t) quickDeployment, err := client.CreateZoneDeployment(mockToken(t.Context()), 12345) require.NoError(t, err) expected := &QuickDeployment{ CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment"}, State: "PENDING", Status: "CANCEL", Message: "string", PercentComplete: 50, CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC), StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC), CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC), Method: "SCHEDULED", } assert.Equal(t, expected, quickDeployment) } func TestClient_CreateZoneResourceRecord(t *testing.T) { client := mockBuilderAuthenticated(). Route("POST /api/v2/zones/12345/resourceRecords", servermock.ResponseFromFixture("postZoneResourceRecord.json"), servermock.CheckRequestJSONBodyFromFixture("postZoneResourceRecord-request.json"), ). Build(t) record := RecordTXT{ CommonResource: CommonResource{ Type: "TXTRecord", Name: "_acme-challenge", }, Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, RecordType: "TXT", } result, err := client.CreateZoneResourceRecord(mockToken(t.Context()), 12345, record) require.NoError(t, err) expected := &RecordTXT{ CommonResource: CommonResource{ ID: 12345, Type: "ResourceRecord", Name: "name", }, TTL: 3600, AbsoluteName: "host1.example.com", Comment: "Sample comment.", Dynamic: true, RecordType: "CNAME", Text: "", } assert.Equal(t, expected, result) } func TestClient_DeleteResourceRecord(t *testing.T) { client := mockBuilderAuthenticated(). Route("DELETE /api/v2/resourceRecords/12345", servermock.ResponseFromFixture("deleteResourceRecord.json"), ). Build(t) err := client.DeleteResourceRecord(mockToken(t.Context()), 12345) require.NoError(t, err) } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json ================================================ { "id": 12345, "type": "WorkflowRequest", "state": "APPROVED", "operation": "ADD_ALIAS_RECORD", "creator": { "id": 103307, "type": "User", "name": "admin", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "authenticator": { "id": 12345, "type": "Authenticator", "name": "LDAP authenticator" }, "email": "user@example.com", "phoneNumber": "555-1234", "securityPrivilege": "NO_ACCESS", "historyPrivilege": "HIDE", "accessType": "GUI", "passwordResetRequired": true, "accountLocked": true, "x509Required": true, "administrativeAccessRights": [ { "resourceType": "Event", "accessLevel": "HIDE" } ] }, "resourceId": 0, "resourceType": "ACL", "fieldUpdates": [ { "name": "string", "value": {}, "previousValue": {} } ], "dependentRequest": "string", "modifier": { "id": 103307, "type": "User", "name": "admin", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "authenticator": { "id": 12345, "type": "Authenticator", "name": "LDAP authenticator" }, "email": "user@example.com", "phoneNumber": "555-1234", "securityPrivilege": "NO_ACCESS", "historyPrivilege": "HIDE", "accessType": "GUI", "passwordResetRequired": true, "accountLocked": true, "x509Required": true, "administrativeAccessRights": [ { "resourceType": "Event", "accessLevel": "HIDE" } ] }, "creationDateTime": "2022-10-17T19:11:45Z", "modificationDateTime": "2022-10-18T19:11:45Z", "comment": "Sample comment." } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/error.json ================================================ { "status": 401, "reason": "Unauthorized", "code": "InvalidAuthorizationToken", "message": "The provided authorization token is invalid" } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json ================================================ { "count": 0, "totalCount": 0, "data": [ { "id": 12345, "type": "QuickDeployment", "state": "PENDING", "status": "CANCEL", "message": "string", "percentComplete": 50, "creationDateTime": "2022-11-23T02:53:00Z", "startDateTime": "2022-11-23T02:53:03Z", "completionDateTime": "2022-11-23T02:54:05Z", "user": { "id": 103307, "type": "User", "name": "admin", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "authenticator": { "id": 12345, "type": "Authenticator", "name": "LDAP authenticator" }, "email": "user@example.com", "phoneNumber": "555-1234", "securityPrivilege": "NO_ACCESS", "historyPrivilege": "HIDE", "accessType": "GUI", "passwordResetRequired": true, "accountLocked": true, "x509Required": true, "administrativeAccessRights": [ { "resourceType": "Event", "accessLevel": "HIDE" } ] }, "method": "SCHEDULED" } ] } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/postSession-request.json ================================================ { "username": "userA", "password": "secret" } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/postSession.json ================================================ { "id": 12345, "type": "UserSession", "apiToken": "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez", "apiTokenExpirationDateTime": "2022-09-15T17:52:07Z", "basicAuthenticationCredentials": "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", "remoteAddress": "192.168.1.1", "readOnly": true, "loginDateTime": "2022-09-14T17:45:03Z", "logoutDateTime": "2022-09-14T19:45:03Z", "state": "LOGGED_IN", "response": "Authentication Error: Ensure that your username and password are correct.", "user": { "id": 103307, "type": "User", "name": "admin", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "authenticator": { "id": 12345, "type": "Authenticator", "name": "LDAP authenticator" }, "email": "user@example.com", "phoneNumber": "555-1234", "securityPrivilege": "NO_ACCESS", "historyPrivilege": "HIDE", "accessType": "GUI", "passwordResetRequired": true, "accountLocked": true, "x509Required": true, "administrativeAccessRights": [ { "resourceType": "Event", "accessLevel": "HIDE" } ] }, "authenticator": { "id": 12345, "type": "Authenticator", "name": "LDAP authenticator", "userDefinedFields": { "udf1": "value1", "udf2": "value2" } } } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json ================================================ { "type": "QuickDeployment" } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json ================================================ { "id": 12345, "type": "QuickDeployment", "state": "PENDING", "status": "CANCEL", "message": "string", "percentComplete": 50, "creationDateTime": "2022-11-23T02:53:00Z", "startDateTime": "2022-11-23T02:53:03Z", "completionDateTime": "2022-11-23T02:54:05Z", "user": { "id": 103307, "type": "User", "name": "admin", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "authenticator": { "id": 12345, "type": "Authenticator", "name": "LDAP authenticator" }, "email": "user@example.com", "phoneNumber": "555-1234", "securityPrivilege": "NO_ACCESS", "historyPrivilege": "HIDE", "accessType": "GUI", "passwordResetRequired": true, "accountLocked": true, "x509Required": true, "administrativeAccessRights": [ { "resourceType": "Event", "accessLevel": "HIDE" } ] }, "method": "SCHEDULED" } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json ================================================ { "type": "TXTRecord", "name": "_acme-challenge", "ttl": 120, "recordType": "TXT", "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json ================================================ { "id": 12345, "type": "ResourceRecord", "name": "name", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "ttl": 3600, "absoluteName": "host1.example.com", "comment": "Sample comment.", "dynamic": true, "recordType": "CNAME", "linkedRecord": { "id": 12345, "type": "ResourceRecord", "name": "name", "absoluteName": "host1.example.com" } } ================================================ FILE: providers/dns/bluecatv2/internal/fixtures/zones.json ================================================ { "count": 0, "totalCount": 0, "data": [ { "id": 12345, "type": "ENUMZone", "name": "5678", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "view": { "id": 12345, "type": "View", "name": "default", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "deviceRegistrationEnabled": true, "deviceRegistrationPortalAddress": "10.10.10.10" }, "deploymentEnabled": true, "absoluteName": "string" }, { "id": 12345, "type": "ExternalHostsZone", "name": "name", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "view": { "id": 12345, "type": "View", "name": "default", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "deviceRegistrationEnabled": true, "deviceRegistrationPortalAddress": "10.10.10.10" } }, { "id": 12345, "type": "InternalRootZone", "name": "name", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "view": { "id": 12345, "type": "View", "name": "default", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "deviceRegistrationEnabled": true, "deviceRegistrationPortalAddress": "10.10.10.10" }, "deploymentEnabled": true }, { "id": 12345, "type": "ResponsePolicyZone", "name": "name", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "view": { "id": 12345, "type": "View", "name": "default", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "deviceRegistrationEnabled": true, "deviceRegistrationPortalAddress": "10.10.10.10" }, "responsePolicyZoneType": "LOCAL", "responsePolicy": { "id": 12345, "type": "ResponsePolicy", "name": "Block Response Policy" }, "overridePolicyType": "ALLOWLIST", "overrideRefreshTime": "string", "redirectTarget": "string", "feedCategories": [ "string" ] }, { "id": 12345, "type": "Zone", "name": "example.com", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "view": { "id": 12345, "type": "View", "name": "default", "userDefinedFields": { "udf1": "value1", "udf2": "value2" }, "configuration": { "id": 12345, "type": "Configuration", "name": "name" }, "deviceRegistrationEnabled": true, "deviceRegistrationPortalAddress": "10.10.10.10" }, "deploymentEnabled": true, "dynamicUpdateEnabled": true, "template": { "id": 12345, "type": "ZoneTemplate", "name": "name" }, "signed": true, "signingPolicy": { "id": 12345, "type": "DNSSECSigningPolicy", "name": "name" }, "absoluteName": "example.com" } ] } ================================================ FILE: providers/dns/bluecatv2/internal/identity.go ================================================ package internal import ( "context" "fmt" "net/http" ) type token string const tokenKey token = "token" const authorizationHeader = "Authorization" // CreateSession creates a new session. func (c *Client) CreateSession(ctx context.Context, info LoginInfo) (*Session, error) { endpoint := c.baseURL.JoinPath("api", "v2", "sessions") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, info) if err != nil { return nil, err } result := new(Session) err = c.do(req, result) if err != nil { return nil, err } return result, nil } // CreateAuthenticatedContext creates a new authenticated context. func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { tok, err := c.CreateSession(ctx, LoginInfo{Username: c.username, Password: c.password}) if err != nil { return nil, fmt.Errorf("create session: %w", err) } return context.WithValue(ctx, tokenKey, tok.BasicAuthenticationCredentials), nil } func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result any) error { tok := getToken(ctx) if tok != "" { req.Header.Set(authorizationHeader, "Basic "+tok) } return c.do(req, result) } func getToken(ctx context.Context) string { tok, ok := ctx.Value(tokenKey).(string) if !ok { return "" } return tok } ================================================ FILE: providers/dns/bluecatv2/internal/identity_test.go ================================================ package internal import ( "context" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "userA", "secret") if err != nil { return nil, err } client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func mockToken(ctx context.Context) context.Context { return context.WithValue(ctx, tokenKey, "secretToken") } func TestClient_CreateSession(t *testing.T) { client := mockBuilder(). Route("POST /api/v2/sessions", servermock.ResponseFromFixture("postSession.json"), servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"), ). Build(t) info := LoginInfo{ Username: "userA", Password: "secret", } result, err := client.CreateSession(mockToken(t.Context()), info) require.NoError(t, err) expected := &Session{ ID: 12345, Type: "UserSession", APIToken: "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez", APITokenExpirationDateTime: time.Date(2022, time.September, 15, 17, 52, 7, 0, time.UTC), BasicAuthenticationCredentials: "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", RemoteAddress: "192.168.1.1", ReadOnly: true, LoginDateTime: time.Date(2022, time.September, 14, 17, 45, 3, 0, time.UTC), LogoutDateTime: time.Date(2022, time.September, 14, 19, 45, 3, 0, time.UTC), State: "LOGGED_IN", Response: "Authentication Error: Ensure that your username and password are correct.", } assert.Equal(t, expected, result) } func TestClient_CreateAuthenticatedContext(t *testing.T) { client := mockBuilder(). Route("POST /api/v2/sessions", servermock.ResponseFromFixture("postSession.json"), servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"), ). Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) assert.Equal(t, "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", getToken(ctx)) } ================================================ FILE: providers/dns/bluecatv2/internal/predicates.go ================================================ package internal import ( "fmt" "strings" ) type Predicate struct { field string operator string values []string } func (p *Predicate) String() string { var values []string for _, v := range p.values { values = append(values, fmt.Sprintf("'%s'", v)) } return fmt.Sprintf("%s:%s(%s)", p.field, p.operator, strings.Join(values, ", ")) } func Eq(field, value string) *Predicate { return &Predicate{field: field, operator: "eq", values: []string{value}} } func Contains(field, value string) *Predicate { return &Predicate{field: field, operator: "contains", values: []string{value}} } func StartsWith(field, value string) *Predicate { return &Predicate{field: field, operator: "startsWith", values: []string{value}} } func EndsWith(field, value string) *Predicate { return &Predicate{field: field, operator: "endsWith", values: []string{value}} } func In(field string, values ...string) *Predicate { return &Predicate{field: field, operator: "in", values: values} } type Combined struct { predicates []*Predicate operator string } func (o *Combined) String() string { var parts []string for _, predicate := range o.predicates { parts = append(parts, predicate.String()) } return strings.Join(parts, " "+o.operator+" ") } func And(predicates ...*Predicate) *Combined { return &Combined{predicates: predicates, operator: "and"} } func Or(predicates ...*Predicate) *Combined { return &Combined{predicates: predicates, operator: "or"} } ================================================ FILE: providers/dns/bluecatv2/internal/predicates_test.go ================================================ package internal import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func TestPredicate(t *testing.T) { testCases := []struct { desc string predicate fmt.Stringer expected string }{ { desc: "Equals", predicate: Eq("foo", "bar"), expected: "foo:eq('bar')", }, { desc: "Contains", predicate: Contains("foo", "bar"), expected: "foo:contains('bar')", }, { desc: "Starts with", predicate: StartsWith("foo", "bar"), expected: "foo:startsWith('bar')", }, { desc: "Ends with", predicate: EndsWith("foo", "bar"), expected: "foo:endsWith('bar')", }, { desc: "Match a list of values", predicate: In("foo", "bar", "bir"), expected: "foo:in('bar', 'bir')", }, { desc: "Combined: and", predicate: And(Eq("foo", "bar"), Eq("fii", "bir")), expected: "foo:eq('bar') and fii:eq('bir')", }, { desc: "Combined: multiple and", predicate: And( Eq("foo", "bar"), Eq("fii", "bir"), Eq("fuu", "bur"), ), expected: "foo:eq('bar') and fii:eq('bir') and fuu:eq('bur')", }, { desc: "Combined: or", predicate: Or(Eq("foo", "bar"), Eq("foo", "bir")), expected: "foo:eq('bar') or foo:eq('bir')", }, { desc: "Combined: multiple or", predicate: Or( Eq("foo", "bar"), Eq("foo", "bir"), Eq("foo", "bur"), ), expected: "foo:eq('bar') or foo:eq('bir') or foo:eq('bur')", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() assert.Equal(t, test.expected, test.predicate.String()) }) } } ================================================ FILE: providers/dns/bluecatv2/internal/types.go ================================================ package internal import ( "fmt" "time" ) // Quick deployment states. // //nolint:misspell // US vs UK const ( QDStatePending = "PENDING" QDStateQueued = "QUEUED" QDStateRunning = "RUNNING" QDStateCancelled = "CANCELLED" QDStateCancelling = "CANCELLING" QDStateCompleted = "COMPLETED" QDStateCompletedWithErrors = "COMPLETED_WITH_ERRORS" QDStateCompletedWithWarnings = "COMPLETED_WITH_WARNINGS" QDStateFailed = "FAILED" QDStateUnknown = "UNKNOWN" ) // APIError represents an error. // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Errors/9.6.0 type APIError struct { Status int `json:"status"` Reason string `json:"reason"` Code string `json:"code"` Message string `json:"message"` } func (a *APIError) Error() string { return fmt.Sprintf("%d: %s: %s: %s", a.Status, a.Reason, a.Code, a.Message) } // CommonResource represents the common resource fields. // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Resources/9.6.0 type CommonResource struct { ID int64 `json:"id,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` } // Collection represents a collection of resources. // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Collections/9.6.0 type Collection[T any] struct { Count int64 `json:"count"` TotalCount int64 `json:"totalCount"` Data []T `json:"data"` } type CollectionOptions struct { // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Fields/9.6.0 Fields string `url:"fields,omitempty"` // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Pagination/9.6.0 Limit int `url:"limit,omitempty"` Offset int `url:"offset,omitempty"` // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Filter/9.6.0 Filter string `url:"filter,omitempty"` // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Ordering/9.6.0 OrderBy string `url:"orderBy,omitempty"` // Should return or not the total number of resources matching the query. Total bool `url:"total,omitempty"` } type RecordTXT struct { CommonResource TTL int `json:"ttl,omitempty"` AbsoluteName string `json:"absoluteName,omitempty"` Comment string `json:"comment,omitempty"` Dynamic bool `json:"dynamic,omitempty"` RecordType string `json:"recordType,omitempty"` Text string `json:"text,omitempty"` } type ZoneResource struct { CommonResource AbsoluteName string `json:"absoluteName,omitempty"` } type QuickDeployment struct { CommonResource State string `json:"state,omitempty"` Status string `json:"status,omitempty"` Message string `json:"message,omitempty"` PercentComplete int `json:"percentComplete,omitempty"` CreationDateTime time.Time `json:"creationDateTime,omitzero"` StartDateTime time.Time `json:"startDateTime,omitzero"` CompletionDateTime time.Time `json:"completionDateTime,omitzero"` Method string `json:"method,omitempty"` } // LoginInfo represents the login information. // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0 type LoginInfo struct { Username string `json:"username"` Password string `json:"password"` } // Session represents the session. // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0 type Session struct { ID int `json:"id"` Type string `json:"type"` APIToken string `json:"apiToken"` APITokenExpirationDateTime time.Time `json:"apiTokenExpirationDateTime"` BasicAuthenticationCredentials string `json:"basicAuthenticationCredentials"` RemoteAddress string `json:"remoteAddress"` ReadOnly bool `json:"readOnly"` LoginDateTime time.Time `json:"loginDateTime"` LogoutDateTime time.Time `json:"logoutDateTime"` State string `json:"state"` Response string `json:"response"` } ================================================ FILE: providers/dns/bookmyname/bookmyname.go ================================================ // Package bookmyname implements a DNS provider for solving the DNS-01 challenge using BookMyName. package bookmyname import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/bookmyname/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "BOOKMYNAME_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for BookMyName. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("bookmyname: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for BookMyName. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("bookmyname: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.Username, config.Password) if err != nil { return nil, fmt.Errorf("bookmyname: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) record := internal.Record{ Hostname: dns01.UnFqdn(info.EffectiveFQDN), Type: "txt", TTL: d.config.TTL, Value: info.Value, } err := d.client.AddRecord(context.Background(), record) if err != nil { return fmt.Errorf("bookmyname: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) record := internal.Record{ Hostname: dns01.UnFqdn(info.EffectiveFQDN), Type: "txt", TTL: d.config.TTL, Value: info.Value, } err := d.client.RemoveRecord(context.Background(), record) if err != nil { return fmt.Errorf("bookmyname: add record: %w", err) } return 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 } ================================================ FILE: providers/dns/bookmyname/bookmyname.toml ================================================ Name = "BookMyName" Description = '''''' URL = "https://www.bookmyname.com/" Code = "bookmyname" Since = "v4.23.0" Example = ''' BOOKMYNAME_USERNAME="xxx" \ BOOKMYNAME_PASSWORD="yyy" \ lego --dns bookmyname -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BOOKMYNAME_USERNAME = "Username" BOOKMYNAME_PASSWORD = "Password" [Configuration.Additional] BOOKMYNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" BOOKMYNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" BOOKMYNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" BOOKMYNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://fr.faqs.bookmyname.com/frfaqs/dyndns" ================================================ FILE: providers/dns/bookmyname/bookmyname_test.go ================================================ package bookmyname import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", }, }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "secret", }, expected: "bookmyname: some credentials information are missing: BOOKMYNAME_USERNAME", }, { desc: "missing paswword", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "", }, expected: "bookmyname: some credentials information are missing: BOOKMYNAME_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "bookmyname: some credentials information are missing: BOOKMYNAME_USERNAME,BOOKMYNAME_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "user", password: "secret", }, { desc: "missing username", password: "secret", expected: "bookmyname: credentials missing", }, { desc: "missing password", username: "user", expected: "bookmyname: credentials missing", }, { desc: "missing credentials", expected: "bookmyname: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func 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) } ================================================ FILE: providers/dns/bookmyname/internal/client.go ================================================ package internal import ( "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://www.bookmyname.com/dyndns/" // Client the BookMyName API client. type Client struct { username string password string baseURL string HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(username, password string) (*Client, error) { if username == "" || password == "" { return nil, errors.New("credentials missing") } return &Client{ username: username, password: password, baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddRecord(ctx context.Context, record Record) error { endpoint, err := c.createEndpoint(record, "add") if err != nil { return err } err = c.do(ctx, endpoint) if err != nil { return err } return nil } func (c *Client) RemoveRecord(ctx context.Context, record Record) error { endpoint, err := c.createEndpoint(record, "remove") if err != nil { return err } err = c.do(ctx, endpoint) if err != nil { return err } return nil } func (c *Client) createEndpoint(record Record, action string) (*url.URL, error) { endpoint, err := url.Parse(c.baseURL) if err != nil { return nil, fmt.Errorf("parse URL: %w", err) } values, err := querystring.Values(record) if err != nil { return nil, fmt.Errorf("query parameters: %w", err) } values.Set("do", action) endpoint.RawQuery = values.Encode() return endpoint, nil } func (c *Client) do(ctx context.Context, endpoint *url.URL) error { endpoint.User = url.UserPassword(c.username, c.password) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if !strings.HasPrefix(string(raw), "good: update done") && !strings.HasPrefix(string(raw), "good: remove done") { return fmt.Errorf("unexpected response: %s", string(bytes.TrimSpace(raw))) } return nil } ================================================ FILE: providers/dns/bookmyname/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL = server.URL return client, nil }, servermock.CheckHeader(). WithBasicAuth("user", "secret")) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("add_success.txt"), servermock.CheckQueryParameter().Strict(). With("do", "add"). With("hostname", "_acme-challenge.sub.example.com."). With("type", "txt"). With("value", "test"). With("ttl", "300"), ). Build(t) record := Record{ Hostname: "_acme-challenge.sub.example.com.", Type: "txt", TTL: 300, Value: "test", } err := client.AddRecord(t.Context(), record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("error.txt"), servermock.CheckQueryParameter(). With("do", "add")). Build(t) record := Record{ Hostname: "_acme-challenge.sub.example.com.", Type: "txt", TTL: 300, Value: "test", } err := client.AddRecord(t.Context(), record) require.Error(t, err) require.EqualError(t, err, "unexpected response: notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn") } func TestClient_RemoveRecord(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("remove_success.txt"), servermock.CheckQueryParameter().Strict(). With("do", "remove"). With("hostname", "_acme-challenge.sub.example.com."). With("type", "txt"). With("value", "test"). With("ttl", "300"), ). Build(t) record := Record{ Hostname: "_acme-challenge.sub.example.com.", Type: "txt", TTL: 300, Value: "test", } err := client.RemoveRecord(t.Context(), record) require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("error.txt"), servermock.CheckQueryParameter(). With("do", "remove")). Build(t) record := Record{ Hostname: "_acme-challenge.sub.example.com.", Type: "txt", TTL: 300, Value: "test", } err := client.RemoveRecord(t.Context(), record) require.Error(t, err) require.EqualError(t, err, "unexpected response: notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn") } ================================================ FILE: providers/dns/bookmyname/internal/fixtures/add_success.txt ================================================ good: update done, cid 123, domain id 456, type txt, ip xxx ================================================ FILE: providers/dns/bookmyname/internal/fixtures/error.txt ================================================ notfqdn: Host _acme-challenge.sub.example.com. malformed / vhn ================================================ FILE: providers/dns/bookmyname/internal/fixtures/remove_success.txt ================================================ good: remove done 1, cid 123, domain id 456, ttl 300, type txt, ip xxx ================================================ FILE: providers/dns/bookmyname/internal/types.go ================================================ package internal type Record struct { Hostname string `url:"hostname"` Type string `url:"type"` TTL int `url:"ttl"` Value string `url:"value"` } ================================================ FILE: providers/dns/brandit/brandit.go ================================================ package brandit import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/brandit/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "BRANDIT_" EnvAPIKey = envNamespace + "API_KEY" EnvAPIUsername = envNamespace + "API_USERNAME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string APIUsername string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client records map[string]string recordsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for BrandIT. // Credentials must be passed in the environment variables: BRANDIT_API_KEY, BRANDIT_API_USERNAME. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvAPIUsername) if err != nil { return nil, fmt.Errorf("brandit: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.APIUsername = values[EnvAPIUsername] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for BrandIT. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("brandit: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIUsername, config.APIKey) if err != nil { return nil, fmt.Errorf("brandit: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, records: make(map[string]string), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("brandit: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("brandit: %w", err) } ctx := context.Background() record := internal.Record{ Type: "TXT", Name: subDomain, Content: info.Value, TTL: d.config.TTL, } // find the account associated with the domain account, err := d.client.StatusDomain(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("brandit: status domain: %w", err) } // Find the next record id recordID, err := d.client.ListRecords(ctx, account.Registrar[0], dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("brandit: list records: %w", err) } result, err := d.client.AddRecord(ctx, dns01.UnFqdn(authZone), account.Registrar[0], strconv.Itoa(recordID.Total[0]), record) if err != nil { return fmt.Errorf("brandit: add record: %w", err) } d.recordsMu.Lock() d.records[token] = result.Record d.recordsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("brandit: could not find zone for domain %q: %w", domain, err) } // gets the record's unique ID d.recordsMu.Lock() dnsRecord, ok := d.records[token] d.recordsMu.Unlock() if !ok { return fmt.Errorf("brandit: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } ctx := context.Background() // find the account associated with the domain account, err := d.client.StatusDomain(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("brandit: status domain: %w", err) } records, err := d.client.ListRecords(ctx, account.Registrar[0], dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("brandit: list records: %w", err) } var recordID int for i, r := range records.RR { if r == dnsRecord { recordID = i } } err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), account.Registrar[0], dnsRecord, strconv.Itoa(recordID)) if err != nil { return fmt.Errorf("brandit: delete record: %w", err) } // deletes record ID from map d.recordsMu.Lock() delete(d.records, token) d.recordsMu.Unlock() return nil } ================================================ FILE: providers/dns/brandit/brandit.toml ================================================ Name = "Brandit (deprecated)" Description = ''' Brandit has been acquired by Abion. Abion has a different API. If you are a Brandit/Albion user, you can try the PR https://github.com/go-acme/lego/pull/2112. ''' URL = "https://www.brandit.com/" Code = "brandit" Since = "v4.11.0" Example = ''' BRANDIT_API_KEY=xxxxxxxxxxxxxxxxxxxxx \ BRANDIT_API_USERNAME=yyyyyyyyyyyyyyyyyyyy \ lego --dns brandit -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BRANDIT_API_KEY = "The API key" BRANDIT_API_USERNAME = "The API username" [Configuration.Additional] BRANDIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" BRANDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" BRANDIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" BRANDIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://portal.brandit.com/apidocv3" ================================================ FILE: providers/dns/brandit/brandit_test.go ================================================ package brandit import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvAPIUsername).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", EnvAPIUsername: "test_user", }, }, { desc: "missing API key", envVars: map[string]string{ EnvAPIUsername: "test_user", }, expected: "brandit: some credentials information are missing: BRANDIT_API_KEY", }, { desc: "missing secret", envVars: map[string]string{ EnvAPIKey: "key", }, expected: "brandit: some credentials information are missing: BRANDIT_API_USERNAME", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "brandit: some credentials information are missing: BRANDIT_API_KEY,BRANDIT_API_USERNAME", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string user string expected string }{ { desc: "success", apiKey: "key", user: "test_user", }, { desc: "missing API key", user: "test_user", expected: "brandit: credentials missing", }, { desc: "missing secret", apiKey: "key", expected: "brandit: credentials missing", }, { desc: "missing credentials", expected: "brandit: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.APIUsername = test.user p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/brandit/internal/client.go ================================================ package internal import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://portal.brandit.com/api/v3/" // Client a BrandIT DNS API client. type Client struct { apiUsername string apiKey string baseURL string HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiUsername, apiKey string) (*Client, error) { if apiKey == "" || apiUsername == "" { return nil, errors.New("credentials missing") } return &Client{ apiUsername: apiUsername, apiKey: apiKey, baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // ListRecords lists all records. // https://portal.brandit.com/apidocv3#listDNSRR func (c *Client) ListRecords(ctx context.Context, account, dnsZone string) (*ListRecordsResponse, error) { query := url.Values{} query.Add("command", "listDNSRR") query.Add("account", account) query.Add("dnszone", dnsZone) result := &Response[*ListRecordsResponse]{} err := c.do(ctx, query, result) if err != nil { return nil, err } for len(result.Response.RR) < result.Response.Total[0] { query.Add("first", strconv.Itoa(result.Response.Last[0]+1)) tmp := &Response[*ListRecordsResponse]{} err := c.do(ctx, query, tmp) if err != nil { return nil, err } result.Response.RR = append(result.Response.RR, tmp.Response.RR...) result.Response.Last = tmp.Response.Last } return result.Response, nil } // AddRecord adds a DNS record. // https://portal.brandit.com/apidocv3#addDNSRR func (c *Client) AddRecord(ctx context.Context, domainName, account, newRecordID string, record Record) (*AddRecord, error) { value := strings.Join([]string{record.Name, strconv.Itoa(record.TTL), "IN", record.Type, record.Content}, " ") query := url.Values{} query.Add("command", "addDNSRR") query.Add("account", account) query.Add("dnszone", domainName) query.Add("rrdata", value) query.Add("key", newRecordID) result := &AddRecord{} err := c.do(ctx, query, result) if err != nil { return nil, err } result.Record = value return result, nil } // DeleteRecord deletes a DNS record. // https://portal.brandit.com/apidocv3#deleteDNSRR func (c *Client) DeleteRecord(ctx context.Context, domainName, account, dnsRecord, recordID string) error { query := url.Values{} query.Add("command", "deleteDNSRR") query.Add("account", account) query.Add("dnszone", domainName) query.Add("rrdata", dnsRecord) query.Add("key", recordID) return c.do(ctx, query, nil) } // StatusDomain returns the status of a domain and account associated with it. // https://portal.brandit.com/apidocv3#statusDomain func (c *Client) StatusDomain(ctx context.Context, domain string) (*StatusResponse, error) { query := url.Values{} query.Add("command", "statusDomain") query.Add("domain", domain) result := &Response[*StatusResponse]{} err := c.do(ctx, query, result) if err != nil { return nil, err } return result.Response, nil } func (c *Client) do(ctx context.Context, query url.Values, result any) error { values, err := sign(c.apiUsername, c.apiKey, query) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(values.Encode())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } // Unmarshal the error response, because the API returns a 200 OK even if there is an error. var apiError APIError err = json.Unmarshal(raw, &apiError) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if apiError.Code > 299 || apiError.Status != "success" { return apiError } if result == nil { return nil } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func sign(apiUsername, apiKey string, query url.Values) (url.Values, error) { timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z") canonicalRequest := fmt.Sprintf("%s%s%s", apiUsername, timestamp, defaultBaseURL) mac := hmac.New(sha256.New, []byte(apiKey)) _, err := mac.Write([]byte(canonicalRequest)) if err != nil { return nil, err } hashed := mac.Sum(nil) signature := hex.EncodeToString(hashed) query.Add("user", apiUsername) query.Add("timestamp", timestamp) query.Add("signature", signature) return query, nil } ================================================ FILE: providers/dns/brandit/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL = server.URL return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func TestClient_StatusDomain(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("status-domain.json"), servermock.CheckForm().Strict(). WithRegexp("signature", "[a-z0-9]+"). WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). With("command", "statusDomain"). With("user", "user"). With("domain", "example.com"), ). Build(t) domain, err := client.StatusDomain(t.Context(), "example.com") require.NoError(t, err) expected := &StatusResponse{ RenewalMode: []string{"DEFAULT"}, Status: []string{"clientTransferProhibited"}, TransferLock: []int{1}, Registrar: []string{"brandit"}, PaidUntilDate: []string{"2021-12-15 05:00:00.0"}, Nameserver: []string{"NS1.RRPPROXY.NET", "NS2.RRPPROXY.NET"}, RegistrationExpirationDate: []string{"2021-12-15 05:00:00.0"}, Domain: []string{"example.com"}, RenewalDate: []string{"2024-01-19 05:00:00.0"}, UpdatedDate: []string{"2022-12-16 08:01:27.0"}, BillingContact: []string{"example"}, XDomainRoID: []string{"example"}, AdminContact: []string{"example"}, TechContact: []string{"example"}, DomainIDN: []string{"example.com"}, CreatedDate: []string{"2016-12-16 05:00:00.0"}, RegistrarTransferDate: []string{"2021-12-09 05:17:42.0"}, Zone: []string{"com"}, Auth: []string{"example"}, UpdatedBy: []string{"example"}, RoID: []string{"example"}, OwnerContact: []string{"example"}, CreatedBy: []string{"example"}, TransferMode: []string{"auto"}, } assert.Equal(t, expected, domain) } func TestClient_StatusDomain_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.StatusDomain(t.Context(), "example.com") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } func TestClient_ListRecords(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("list-records.json"), servermock.CheckForm().Strict(). WithRegexp("signature", "[a-z0-9]+"). WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). With("account", "example"). With("command", "listDNSRR"). With("user", "user"). With("dnszone", "example.com"), ). Build(t) resp, err := client.ListRecords(t.Context(), "example", "example.com") require.NoError(t, err) expected := &ListRecordsResponse{ Limit: []int{100}, Column: []string{"rr"}, Count: []int{1}, First: []int{0}, Total: []int{1}, RR: []string{"example.com. 600 IN TXT txttxttxt"}, Last: []int{0}, } assert.Equal(t, expected, resp) } func TestClient_ListRecords_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.ListRecords(t.Context(), "example", "example.com") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("add-record.json"), servermock.CheckForm().Strict(). WithRegexp("signature", "[a-z0-9]+"). WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). With("account", "test"). With("command", "addDNSRR"). With("key", "2565"). With("user", "user"). With("rrdata", "example.com 600 IN TXT txttxttxt"). With("dnszone", "example.com"), ). Build(t) testRecord := Record{ ID: 2565, Type: "TXT", Name: "example.com", Content: "txttxttxt", TTL: 600, } resp, err := client.AddRecord(t.Context(), "example.com", "test", "2565", testRecord) require.NoError(t, err) expected := &AddRecord{ Response: AddRecordResponse{ ZoneType: []string{"com"}, Signed: []int{1}, }, Record: "example.com 600 IN TXT txttxttxt", Code: 200, Status: "success", Error: "", } assert.Equal(t, expected, resp) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json")). Build(t) testRecord := Record{ ID: 2565, Type: "TXT", Name: "example.com", Content: "txttxttxt", TTL: 600, } _, err := client.AddRecord(t.Context(), "example.com", "test", "2565", testRecord) require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("delete-record.json"), servermock.CheckForm().Strict(). WithRegexp("signature", "[a-z0-9]+"). WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). With("account", "test"). With("command", "deleteDNSRR"). With("key", "2374"). With("user", "user"). With("rrdata", "example.com 600 IN TXT txttxttxt"). With("dnszone", "example.com"), ). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json")). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } ================================================ FILE: providers/dns/brandit/internal/fixtures/add-record.json ================================================ { "response": { "zonetype": [ "com" ], "signed": [ 1 ] }, "record": "example.com. 600 IN TXT txttxttxt", "code": 200, "status": "success", "error": "" } ================================================ FILE: providers/dns/brandit/internal/fixtures/delete-record.json ================================================ { "code": 200, "status": "success", "error": "" } ================================================ FILE: providers/dns/brandit/internal/fixtures/error.json ================================================ { "code": 402, "status": "error", "error": "Invalid user." } ================================================ FILE: providers/dns/brandit/internal/fixtures/list-records.json ================================================ { "response": { "limit": [ 100 ], "column": [ "rr" ], "count": [ 1 ], "first": [ 0 ], "total": [ 1 ], "rr": [ "example.com. 600 IN TXT txttxttxt" ], "last": [ 0 ] }, "code": 200, "status": "success", "error": "" } ================================================ FILE: providers/dns/brandit/internal/fixtures/status-domain.json ================================================ { "response": { "renewalmode": [ "DEFAULT" ], "status": [ "clientTransferProhibited" ], "transferlock": [ 1 ], "registrar": [ "brandit" ], "paiduntildate": [ "2021-12-15 05:00:00.0" ], "nameserver": [ "NS1.RRPPROXY.NET", "NS2.RRPPROXY.NET" ], "registrationexpirationdate": [ "2021-12-15 05:00:00.0" ], "domain": [ "example.com" ], "renewaldate": [ "2024-01-19 05:00:00.0" ], "updateddate": [ "2022-12-16 08:01:27.0" ], "billingcontact": [ "example" ], "x-domain-roid": [ "example" ], "admincontact": [ "example" ], "techcontact": [ "example" ], "domainidn": [ "example.com" ], "createddate": [ "2016-12-16 05:00:00.0" ], "registrartransferdate": [ "2021-12-09 05:17:42.0" ], "zone": [ "com" ], "auth": [ "example" ], "updatedby": [ "example" ], "roid": [ "example" ], "ownercontact": [ "example" ], "createdby": [ "example" ], "transfermode": [ "auto" ] }, "code": 200, "status": "success", "error": "" } ================================================ FILE: providers/dns/brandit/internal/types.go ================================================ package internal import "fmt" type Response[T any] struct { Response T `json:"response,omitempty"` Code int `json:"code"` Status string `json:"status"` Error string `json:"error"` } type StatusResponse struct { RenewalMode []string `json:"renewalmode"` Status []string `json:"status"` TransferLock []int `json:"transferlock"` Registrar []string `json:"registrar"` PaidUntilDate []string `json:"paiduntildate"` Nameserver []string `json:"nameserver"` RegistrationExpirationDate []string `json:"registrationexpirationdate"` Domain []string `json:"domain"` RenewalDate []string `json:"renewaldate"` UpdatedDate []string `json:"updateddate"` BillingContact []string `json:"billingcontact"` XDomainRoID []string `json:"x-domain-roid"` AdminContact []string `json:"admincontact"` TechContact []string `json:"techcontact"` DomainIDN []string `json:"domainidn"` CreatedDate []string `json:"createddate"` RegistrarTransferDate []string `json:"registrartransferdate"` Zone []string `json:"zone"` Auth []string `json:"auth"` UpdatedBy []string `json:"updatedby"` RoID []string `json:"roid"` OwnerContact []string `json:"ownercontact"` CreatedBy []string `json:"createdby"` TransferMode []string `json:"transfermode"` } type ListRecordsResponse struct { Limit []int `json:"limit,omitempty"` Column []string `json:"column,omitempty"` Count []int `json:"count,omitempty"` First []int `json:"first,omitempty"` Total []int `json:"total,omitempty"` RR []string `json:"rr,omitempty"` Last []int `json:"last,omitempty"` } type APIError struct { Code int `json:"code"` Status string `json:"status"` Message string `json:"error"` } func (a APIError) Error() string { return fmt.Sprintf("code: %d, status: %s, message: %s", a.Code, a.Status, a.Message) } type AddRecord struct { Response AddRecordResponse `json:"response"` Record string `json:"record"` Code int `json:"code"` Status string `json:"status"` Error string `json:"error"` } type AddRecordResponse struct { ZoneType []string `json:"zonetype"` Signed []int `json:"signed"` } type Record struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` // subdomain name or @ if you don't want subdomain Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` // default 600 } ================================================ FILE: providers/dns/bunny/bunny.go ================================================ // Package bunny implements a DNS provider for solving the DNS-01 challenge using Bunny DNS. package bunny import ( "context" "errors" "fmt" "net/http" "slices" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/nrdcg/bunny-go" "golang.org/x/net/publicsuffix" ) // Environment variables names. const ( envNamespace = "BUNNY_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 60 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *bunny.Client } // NewDNSProvider returns a DNSProvider instance configured for bunny. // Credentials must be passed in the environment variable: BUNNY_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("bunny: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for bunny. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("bunny: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("bunny: credentials missing") } if config.TTL < minTTL { return nil, fmt.Errorf("bunny: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } if config.HTTPClient == nil { config.HTTPClient = &http.Client{Timeout: 30 * time.Second} } config.HTTPClient = clientdebug.Wrap(config.HTTPClient) return &DNSProvider{ config: config, client: bunny.NewClient(config.APIKey, bunny.WithUserAgent(useragent.Get()), bunny.WithHTTPClient(config.HTTPClient), ), }, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("bunny: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.Domain)) if err != nil { return fmt.Errorf("bunny: %w", err) } record := &bunny.AddOrUpdateDNSRecordOptions{ Type: ptr.Pointer(bunny.DNSRecordTypeTXT), Name: ptr.Pointer(subDomain), Value: ptr.Pointer(info.Value), TTL: ptr.Pointer(int32(d.config.TTL)), } if _, err := d.client.DNSZone.AddDNSRecord(ctx, ptr.Deref(zone.ID), record); err != nil { return fmt.Errorf("bunny: failed to add TXT record: fqdn=%s, zoneID=%d: %w", info.EffectiveFQDN, ptr.Deref(zone.ID), err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("bunny: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.Domain)) if err != nil { return fmt.Errorf("bunny: %w", err) } var record *bunny.DNSRecord for _, r := range zone.Records { if ptr.Deref(r.Name) == subDomain && ptr.Deref(r.Type) == bunny.DNSRecordTypeTXT { r := r record = &r break } } if record == nil { return fmt.Errorf("bunny: could not find TXT record zone=%d, subdomain=%s", ptr.Deref(zone.ID), subDomain) } if err := d.client.DNSZone.DeleteDNSRecord(ctx, ptr.Deref(zone.ID), ptr.Deref(record.ID)); err != nil { return fmt.Errorf("bunny: failed to delete TXT record: id=%d, name=%s: %w", ptr.Deref(record.ID), ptr.Deref(record.Name), err) } return nil } func (d *DNSProvider) findZone(ctx context.Context, authZone string) (*bunny.DNSZone, error) { zones, err := d.client.DNSZone.List(ctx, nil) if err != nil { return nil, err } zone := findZone(zones, authZone) if zone == nil { return nil, fmt.Errorf("could not find DNSZone domain=%s", authZone) } return zone, nil } func findZone(zones *bunny.DNSZones, domain string) *bunny.DNSZone { domains := possibleDomains(domain) var domainLength int var zone *bunny.DNSZone for _, item := range zones.Items { if item == nil { continue } curr := ptr.Deref(item.Domain) if slices.Contains(domains, curr) && domainLength < len(curr) { domainLength = len(curr) zone = item } } return zone } func possibleDomains(domain string) []string { var domains []string tld, _ := publicsuffix.PublicSuffix(domain) for d := range dns01.DomainsSeq(domain) { if tld == d { // skip the TLD break } domains = append(domains, dns01.UnFqdn(d)) } return domains } ================================================ FILE: providers/dns/bunny/bunny.toml ================================================ Name = "Bunny" Description = '''''' URL = "https://bunny.net" Code = "bunny" Since = "v4.11.0" Example = ''' BUNNY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ lego --dns bunny -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] BUNNY_API_KEY = "API key" [Configuration.Additional] BUNNY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" BUNNY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" BUNNY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" BUNNY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.bunny.net/reference/dnszonepublic_index" bunny-go = "https://github.com/nrdcg/bunny-go" ================================================ FILE: providers/dns/bunny/bunny_test.go ================================================ package bunny import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/nrdcg/bunny-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", }, expected: "bunny: some credentials information are missing: BUNNY_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string ttl int expected string }{ { desc: "success", ttl: minTTL, apiKey: "123", }, { desc: "missing credentials", ttl: minTTL, expected: "bunny: credentials missing", }, { desc: "invalid TTL", apiKey: "123", ttl: 10, expected: "bunny: invalid TTL, TTL (10) must be greater than 60", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.TTL = test.ttl p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func Test_findZone(t *testing.T) { testCases := []struct { desc string domain string items []*bunny.DNSZone expected *bunny.DNSZone }{ { desc: "found subdomain", domain: "_acme-challenge.foo.bar.example.com", items: []*bunny.DNSZone{ {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")}, {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")}, {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")}, {ID: ptr.Pointer[int64](5), Domain: ptr.Pointer("bar.example.com")}, {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")}, }, expected: &bunny.DNSZone{ ID: ptr.Pointer[int64](5), Domain: ptr.Pointer("bar.example.com"), }, }, { desc: "found the longest subdomain", domain: "_acme-challenge.foo.bar.example.com", items: []*bunny.DNSZone{ {ID: ptr.Pointer[int64](7), Domain: ptr.Pointer("foo.bar.example.com")}, {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")}, {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")}, {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")}, {ID: ptr.Pointer[int64](5), Domain: ptr.Pointer("bar.example.com")}, {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")}, }, expected: &bunny.DNSZone{ ID: ptr.Pointer[int64](7), Domain: ptr.Pointer("foo.bar.example.com"), }, }, { desc: "found apex", domain: "_acme-challenge.foo.bar.example.com", items: []*bunny.DNSZone{ {ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com")}, {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")}, {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")}, {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")}, }, expected: &bunny.DNSZone{ ID: ptr.Pointer[int64](1), Domain: ptr.Pointer("example.com"), }, }, { desc: "not found", domain: "_acme-challenge.foo.bar.example.com", items: []*bunny.DNSZone{ {ID: ptr.Pointer[int64](2), Domain: ptr.Pointer("example.org")}, {ID: ptr.Pointer[int64](4), Domain: ptr.Pointer("bar.example.org")}, {ID: ptr.Pointer[int64](6), Domain: ptr.Pointer("foo.example.com")}, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() zones := &bunny.DNSZones{Items: test.items} zone := findZone(zones, test.domain) assert.Equal(t, test.expected, zone) }) } } func Test_possibleDomains(t *testing.T) { testCases := []struct { desc string domain string expected []string }{ { desc: "apex", domain: "example.com", expected: []string{"example.com"}, }, { desc: "CCTLD", domain: "example.co.uk", expected: []string{"example.co.uk"}, }, { desc: "long domain", domain: "_acme-challenge.foo.bar.example.com", expected: []string{"_acme-challenge.foo.bar.example.com", "foo.bar.example.com", "bar.example.com", "example.com"}, }, { desc: "empty", domain: "", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() domains := possibleDomains(test.domain) assert.Equal(t, test.expected, domains) }) } } ================================================ FILE: providers/dns/checkdomain/checkdomain.go ================================================ // Package checkdomain implements a DNS provider for solving the DNS-01 challenge using CheckDomain DNS. package checkdomain import ( "context" "errors" "fmt" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/checkdomain/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CHECKDOMAIN_" EnvEndpoint = envNamespace + "ENDPOINT" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL Token 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, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 7*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for CheckDomain. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("checkdomain: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] endpoint, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, internal.DefaultEndpoint)) if err != nil { return nil, fmt.Errorf("checkdomain: invalid %s: %w", EnvEndpoint, err) } config.Endpoint = endpoint return NewDNSProviderConfig(config) } func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.Endpoint == nil { return nil, errors.New("checkdomain: invalid endpoint") } if config.Token == "" { return nil, errors.New("checkdomain: missing token") } client := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.Token), ), ) if config.Endpoint != nil { client.BaseURL = config.Endpoint } return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() // TODO(ldez) replace domain by FQDN to follow CNAME. domainID, err := d.client.GetDomainIDByName(ctx, domain) if err != nil { return fmt.Errorf("checkdomain: %w", err) } err = d.client.CheckNameservers(ctx, domainID) if err != nil { return fmt.Errorf("checkdomain: %w", err) } info := dns01.GetChallengeInfo(domain, keyAuth) err = d.client.CreateRecord(ctx, domainID, &internal.Record{ Name: info.EffectiveFQDN, TTL: d.config.TTL, Type: "TXT", Value: info.Value, }) if err != nil { return fmt.Errorf("checkdomain: %w", err) } return nil } // CleanUp removes the TXT record previously created. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() // TODO(ldez) replace domain by FQDN to follow CNAME. domainID, err := d.client.GetDomainIDByName(ctx, domain) if err != nil { return fmt.Errorf("checkdomain: %w", err) } err = d.client.CheckNameservers(ctx, domainID) if err != nil { return fmt.Errorf("checkdomain: %w", err) } info := dns01.GetChallengeInfo(domain, keyAuth) defer d.client.CleanCache(info.EffectiveFQDN) err = d.client.DeleteTXTRecord(ctx, domainID, info.EffectiveFQDN, info.Value) if err != nil { return fmt.Errorf("checkdomain: %w", err) } return 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 } ================================================ FILE: providers/dns/checkdomain/checkdomain.toml ================================================ Name = "Checkdomain" Description = '''''' URL = "https://checkdomain.de/" Code = "checkdomain" Since = "v3.3.0" Example = ''' CHECKDOMAIN_TOKEN=yoursecrettoken \ lego --dns checkdomain -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CHECKDOMAIN_TOKEN = "API token" [Configuration.Additional] CHECKDOMAIN_ENDPOINT = "API endpoint URL, defaults to https://api.checkdomain.de" CHECKDOMAIN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" CHECKDOMAIN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 300)" CHECKDOMAIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 7)" CHECKDOMAIN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.checkdomain.de/reference/" Guide = "https://developer.checkdomain.de/guide/" Settings = "https://www.checkdomain.net/en/login/data/api/" ================================================ FILE: providers/dns/checkdomain/checkdomain_test.go ================================================ package checkdomain import ( "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/checkdomain/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvEndpoint, EnvToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "dummy", }, }, { desc: "no token", envVars: map[string]string{}, expected: "checkdomain: some credentials information are missing: CHECKDOMAIN_TOKEN", }, { desc: "invalid endpoint", envVars: map[string]string{ EnvToken: "dummy", EnvEndpoint: ":", }, expected: `checkdomain: invalid CHECKDOMAIN_ENDPOINT: parse ":": missing protocol scheme`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "dummy", }, { desc: "missing token", token: "", expected: "checkdomain: missing token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Endpoint, _ = url.Parse(internal.DefaultEndpoint) if test.token != "" { config.Token = test.token } p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/checkdomain/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) const ( ns1 = "ns.checkdomain.de" ns2 = "ns2.checkdomain.de" ) // DefaultEndpoint the default API endpoint. const DefaultEndpoint = "https://api.checkdomain.de" const domainNotFound = -1 // max page limit that the checkdomain api allows. const maxLimit = 100 // max integer value. const maxInt = int((^uint(0)) >> 1) // Client the Autodns API client. type Client struct { BaseURL *url.URL httpClient *http.Client domainIDMapping map[string]int domainIDMu sync.Mutex } // NewClient creates a new Client. func NewClient(hc *http.Client) *Client { baseURL, _ := url.Parse(DefaultEndpoint) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } return &Client{ BaseURL: baseURL, httpClient: hc, domainIDMapping: make(map[string]int), } } func (c *Client) GetDomainIDByName(ctx context.Context, name string) (int, error) { // Load from cache if exists c.domainIDMu.Lock() id, ok := c.domainIDMapping[name] c.domainIDMu.Unlock() if ok { return id, nil } // Find out by querying API domains, err := c.listDomains(ctx) if err != nil { return domainNotFound, err } // Linear search over all registered domains for _, domain := range domains { if domain.Name == name || strings.HasSuffix(name, "."+domain.Name) { c.domainIDMu.Lock() c.domainIDMapping[name] = domain.ID c.domainIDMu.Unlock() return domain.ID, nil } } return domainNotFound, errors.New("domain not found") } func (c *Client) listDomains(ctx context.Context) ([]*Domain, error) { endpoint := c.BaseURL.JoinPath("v1", "domains") // Checkdomain also provides a query param 'query' which allows filtering domains for a string. // But that functionality is kinda broken, // so we scan through the whole list of registered domains to later find the one that is of interest to us. q := endpoint.Query() q.Set("limit", strconv.Itoa(maxLimit)) currentPage := 1 totalPages := maxInt var domainList []*Domain for currentPage <= totalPages { q.Set("page", strconv.Itoa(currentPage)) endpoint.RawQuery = q.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } var res DomainListingResponse if err := c.do(req, &res); err != nil { return nil, fmt.Errorf("failed to send domain listing request: %w", err) } // This is the first response, // so we update totalPages and allocate the slice memory. if totalPages == maxInt { totalPages = res.Pages domainList = make([]*Domain, 0, res.Total) } domainList = append(domainList, res.Embedded.Domains...) currentPage++ } return domainList, nil } func (c *Client) getNameserverInfo(ctx context.Context, domainID int) (*NameserverResponse, error) { endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } res := &NameserverResponse{} if err := c.do(req, res); err != nil { return nil, err } return res, nil } func (c *Client) CheckNameservers(ctx context.Context, domainID int) error { info, err := c.getNameserverInfo(ctx, domainID) if err != nil { return err } var found1, found2 bool for _, item := range info.Nameservers { switch item.Name { case ns1: found1 = true case ns2: found2 = true } } if !found1 || !found2 { return errors.New("not using checkdomain nameservers, can not update records") } return nil } func (c *Client) CreateRecord(ctx context.Context, domainID int, record *Record) error { endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } // DeleteTXTRecord Checkdomain doesn't seem provide a way to delete records but one can replace all records at once. // The current solution is to fetch all records and then use that list minus the record deleted as the new record list. // TODO: Simplify this function once Checkdomain do provide the functionality. func (c *Client) DeleteTXTRecord(ctx context.Context, domainID int, recordName, recordValue string) error { domainInfo, err := c.getDomainInfo(ctx, domainID) if err != nil { return err } nsInfo, err := c.getNameserverInfo(ctx, domainID) if err != nil { return err } allRecords, err := c.listRecords(ctx, domainID, "") if err != nil { return err } recordName = strings.TrimSuffix(recordName, "."+domainInfo.Name+".") var recordsToKeep []*Record // Find and delete matching records for _, record := range allRecords { if skipRecord(recordName, recordValue, record, nsInfo) { continue } // Checkdomain API can return records without any TTL set (indicated by the value of 0). // The API Call to replace the records would fail if we wouldn't specify a value. // Thus, we use the default TTL queried beforehand if record.TTL == 0 { record.TTL = nsInfo.SOA.TTL } recordsToKeep = append(recordsToKeep, record) } return c.replaceRecords(ctx, domainID, recordsToKeep) } func (c *Client) getDomainInfo(ctx context.Context, domainID int) (*DomainResponse, error) { endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var res DomainResponse err = c.do(req, &res) if err != nil { return nil, err } return &res, nil } func (c *Client) listRecords(ctx context.Context, domainID int, recordType string) ([]*Record, error) { endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records") q := endpoint.Query() q.Set("limit", strconv.Itoa(maxLimit)) if recordType != "" { q.Set("type", recordType) } currentPage := 1 totalPages := maxInt var recordList []*Record for currentPage <= totalPages { q.Set("page", strconv.Itoa(currentPage)) endpoint.RawQuery = q.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } var res RecordListingResponse if err := c.do(req, &res); err != nil { return nil, fmt.Errorf("failed to send record listing request: %w", err) } // This is the first response, so we update totalPages and allocate the slice memory. if totalPages == maxInt { totalPages = res.Pages recordList = make([]*Record, 0, res.Total) } recordList = append(recordList, res.Embedded.Records...) currentPage++ } return recordList, nil } func (c *Client) replaceRecords(ctx context.Context, domainID int, records []*Record) error { endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, records) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) CleanCache(fqdn string) { c.domainIDMu.Lock() delete(c.domainIDMapping, fqdn) c.domainIDMu.Unlock() } func skipRecord(recordName, recordValue string, record *Record, nsInfo *NameserverResponse) bool { // Skip empty records if record.Value == "" { return true } // Skip some special records, otherwise we would get a "Nameserver update failed" if record.Type == "SOA" || record.Type == "NS" || record.Name == "@" || (nsInfo.General.IncludeWWW && record.Name == "www") { return true } nameMatch := recordName == "" || record.Name == recordName valueMatch := recordValue == "" || record.Value == recordValue // Skip our matching record if record.Type == "TXT" && nameMatch && valueMatch { return true } return false } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/checkdomain/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer secret")) } func TestClient_GetDomainIDByName(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains", servermock.JSONEncode(DomainListingResponse{ Embedded: EmbeddedDomainList{Domains: []*Domain{ {ID: 1, Name: "test.com"}, {ID: 2, Name: "test.org"}, }}, })). Build(t) id, err := client.GetDomainIDByName(t.Context(), "test.com") require.NoError(t, err) assert.Equal(t, 1, id) } func TestClient_CheckNameservers(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/1/nameservers", servermock.JSONEncode(NameserverResponse{ Nameservers: []*Nameserver{ {Name: ns1}, {Name: ns2}, // {Name: "ns.fake.de"}, }, })). Build(t) err := client.CheckNameservers(t.Context(), 1) require.NoError(t, err) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /v1/domains/1/nameservers/records", nil, servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := &Record{ Name: "test.com", TTL: 300, Type: "TXT", Value: "value", } err := client.CreateRecord(t.Context(), 1, record) require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { domainName := "lego.test" recordValue := "test" client := mockBuilder(). Route("GET /v1/domains/", servermock.JSONEncode(DomainResponse{ ID: 1, Name: domainName, })). Route("GET /v1/domains/1/nameservers", servermock.JSONEncode(NameserverResponse{ Nameservers: []*Nameserver{{Name: ns1}, {Name: ns2}}, })). Route("GET /v1/domains/1/nameservers/records", servermock.JSONEncode(RecordListingResponse{ Embedded: EmbeddedRecordList{ Records: []*Record{ { Name: "_acme-challenge", Value: recordValue, Type: "TXT", }, { Name: "_acme-challenge", Value: recordValue, Type: "A", }, { Name: "foobar", Value: recordValue, Type: "TXT", }, }, }, })). Route("PUT /v1/domains/1/nameservers/records", nil, servermock.CheckRequestJSONBodyFromFixture("delete_txt_record-request.json")). Build(t) info := dns01.GetChallengeInfo(domainName, "abc") err := client.DeleteTXTRecord(t.Context(), 1, info.EffectiveFQDN, recordValue) require.NoError(t, err) } ================================================ FILE: providers/dns/checkdomain/internal/fixtures/create_record-request.json ================================================ { "name": "test.com", "value": "value", "ttl": 300, "priority": 0, "type": "TXT" } ================================================ FILE: providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json ================================================ [ { "name": "_acme-challenge", "value": "test", "ttl": 0, "priority": 0, "type": "A" }, { "name": "foobar", "value": "test", "ttl": 0, "priority": 0, "type": "TXT" } ] ================================================ FILE: providers/dns/checkdomain/internal/types.go ================================================ package internal // Some fields have been omitted from the structs // because they are not required for this application. type DomainListingResponse struct { Page int `json:"page"` Limit int `json:"limit"` Pages int `json:"pages"` Total int `json:"total"` Embedded EmbeddedDomainList `json:"_embedded"` } type EmbeddedDomainList struct { Domains []*Domain `json:"domains"` } type Domain struct { ID int `json:"id"` Name string `json:"name"` } type DomainResponse struct { ID int `json:"id"` Name string `json:"name"` Created string `json:"created"` PaidUp string `json:"payed_up"` Active bool `json:"active"` } type NameserverResponse struct { General NameserverGeneral `json:"general"` Nameservers []*Nameserver `json:"nameservers"` SOA NameserverSOA `json:"soa"` } type NameserverGeneral struct { IPv4 string `json:"ip_v4"` IPv6 string `json:"ip_v6"` IncludeWWW bool `json:"include_www"` } type NameserverSOA struct { Mail string `json:"mail"` Refresh int `json:"refresh"` Retry int `json:"retry"` Expiry int `json:"expiry"` TTL int `json:"ttl"` } type Nameserver struct { Name string `json:"name"` } type RecordListingResponse struct { Page int `json:"page"` Limit int `json:"limit"` Pages int `json:"pages"` Total int `json:"total"` Embedded EmbeddedRecordList `json:"_embedded"` } type EmbeddedRecordList struct { Records []*Record `json:"records"` } type Record struct { Name string `json:"name"` Value string `json:"value"` TTL int `json:"ttl"` Priority int `json:"priority"` Type string `json:"type"` } ================================================ FILE: providers/dns/civo/civo.go ================================================ // Package civo implements a DNS provider for solving the DNS-01 challenge using CIVO. package civo import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/civo/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CIVO_" EnvAPIToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const ( minTTL = 600 defaultPollingInterval = 30 * time.Second defaultPropagationTimeout = 300 * time.Second ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for CIVO. // Credentials must be passed in the environment variables: API_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("civo: %w", err) } config := NewDefaultConfig() config.Token = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for CIVO. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("civo: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("civo: credentials missing") } if config.TTL < minTTL { config.TTL = minTTL } // Create a Civo client - DNS is region independent, we can use any region client, err := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.Token), ), "LON1") if err != nil { return nil, fmt.Errorf("civo: %w", err) } return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("civo: could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) domainID, err := d.getDomainIDByName(ctx, zone) if err != nil { return fmt.Errorf("civo: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("civo: %w", err) } _, err = d.client.CreateDNSRecord(ctx, domainID, internal.Record{ Name: subDomain, Value: info.Value, Type: "TXT", TTL: d.config.TTL, }) if err != nil { return fmt.Errorf("civo: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("civo: could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) domainID, err := d.getDomainIDByName(ctx, zone) if err != nil { return fmt.Errorf("civo: %w", err) } dnsRecords, err := d.client.ListDNSRecords(ctx, domainID) if err != nil { return fmt.Errorf("civo: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("civo: %w", err) } var dnsRecord internal.Record for _, entry := range dnsRecords { if entry.Name == subDomain && entry.Value == info.Value { dnsRecord = entry break } } err = d.client.DeleteDNSRecord(ctx, dnsRecord) if err != nil { return fmt.Errorf("civo: %w", err) } return 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 } func (d *DNSProvider) getDomainIDByName(ctx context.Context, domain string) (string, error) { domains, err := d.client.ListDomains(ctx) if err != nil { return "", fmt.Errorf("list domains: %w", err) } for _, d := range domains { if d.Name == domain { return d.ID, nil } } return "", fmt.Errorf("domain %q not found", domain) } ================================================ FILE: providers/dns/civo/civo.toml ================================================ Name = "Civo" Description = '''''' URL = "https://civo.com" Code = "civo" Since = "v4.9.0" Example = ''' CIVO_TOKEN=xxxxxx \ lego --dns civo -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CIVO_TOKEN = "Authentication token" [Configuration.Additional] CIVO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" CIVO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" CIVO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" [Links] API = "https://www.civo.com/api/dns" ================================================ FILE: providers/dns/civo/civo_test.go ================================================ package civo import ( "fmt" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "00000000000000000000000000000000000000000000000000", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIToken: "", }, expected: fmt.Sprintf("civo: some credentials information are missing: %s", EnvAPIToken), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string ttl int expected string }{ { desc: "success", token: "00000000000000000000000000000000000000000000000000", ttl: minTTL, }, { desc: "missing api key", token: "", ttl: minTTL, expected: "civo: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.TTL = test.ttl config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.Token = "secret" p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("Authorization", "Bearer secret"). WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). // https://www.civo.com/api/dns#list-domain-names Route("GET /dns", servermock.ResponseFromInternal("list_domain_names.json"), servermock.CheckQueryParameter().Strict(). With("region", "LON1")). // https://www.civo.com/api/dns#create-a-new-dns-record Route("POST /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", servermock.ResponseFromInternal("create_dns_record.json"), servermock.CheckRequestJSONBodyFromInternal("create_dns_record-request.json")). Build(t) err := provider.Present("example.com", "abd", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). // https://www.civo.com/api/dns#list-domain-names Route("GET /dns", servermock.ResponseFromInternal("list_domain_names.json"), servermock.CheckQueryParameter(). With("region", "LON1")). // https://www.civo.com/api/dns#list-dns-records Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", servermock.ResponseFromInternal("list_dns_records.json"), servermock.CheckQueryParameter().Strict(). With("region", "LON1")). // https://www.civo.com/api/dns#deleting-a-dns-record Route("DELETE /dns/edc5dacf-a2ad-4757-41ee-c12f06259c70/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3", servermock.ResponseFromInternal("delete_dns_record.json"), servermock.CheckQueryParameter().Strict(). With("region", "LON1")). Build(t) err := provider.CleanUp("example.com", "abd", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/civo/internal/client.go ================================================ /* Package internal Civo API client. Because the dependencies on k8s, the official client cannot be used. - https://github.com/civo/civogo/blob/v0.2.99/go.mod -> k8s.io/client-go - https://github.com/civo/civogo/blob/v0.3.34/go.mod -> k8s.io/api - https://github.com/civo/civogo/blob/v0.3.38/go.mod -> k8s.io/api + k8s.io/apimachinery - Current version -> https://github.com/civo/civogo/blob/v0.6.1/go.mod */ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "golang.org/x/oauth2" ) const defaultBaseURL = "https://api.civo.com/v2" // Client the Civo API client. type Client struct { region string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(hc *http.Client, region string) (*Client, error) { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } return &Client{ region: region, BaseURL: baseURL, HTTPClient: hc, }, nil } // ListDomains a list of all domain names within the account. // https://www.civo.com/api/dns#list-domain-names func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { endpoint := c.BaseURL.JoinPath("dns") req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []Domain err = c.do(req, &result) if err != nil { return nil, err } return result, nil } // ListDNSRecords a list of all DNS records in the specified domain. // https://www.civo.com/api/dns#list-dns-records func (c *Client) ListDNSRecords(ctx context.Context, domainID string) ([]Record, error) { endpoint := c.BaseURL.JoinPath("dns", domainID, "records") req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []Record err = c.do(req, &result) if err != nil { return nil, err } return result, nil } // CreateDNSRecord creates DNS records for a specific domain. // https://www.civo.com/api/dns#create-a-new-dns-record func (c *Client) CreateDNSRecord(ctx context.Context, domainID string, record Record) (*Record, error) { endpoint := c.BaseURL.JoinPath("dns", domainID, "records") req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } var result Record err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } // DeleteDNSRecord remove a DNS record from a domain. // https://www.civo.com/api/dns#deleting-a-dns-record func (c *Client) DeleteDNSRecord(ctx context.Context, record Record) error { endpoint := c.BaseURL.JoinPath("dns", record.DomainID, "records", record.ID) req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } if method == http.MethodGet || method == http.MethodDelete { query := endpoint.Query() query.Set("region", c.region) endpoint.RawQuery = query.Encode() } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } useragent.SetHeader(req.Header) return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } // OAuthStaticAccessToken Authorization header. // https://www.civo.com/api#authentication func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/civo/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "LON1") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("Authorization", "Bearer secret"). WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`), ) } func TestClient_ListDomains(t *testing.T) { client := mockBuilder(). Route("GET /dns", servermock.ResponseFromFixture("list_domain_names.json"), servermock.CheckQueryParameter().Strict(). With("region", "LON1")). Build(t) domains, err := client.ListDomains(t.Context()) require.NoError(t, err) expected := []Domain{{ ID: "7088fcea-7658-43e6-97fa-273f901978fd", AccountID: "e7e8386e-434e-482f-95e0-c406e5d564c2", Name: "example.com", }} assert.Equal(t, expected, domains) } func TestClient_ListDNSRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", servermock.ResponseFromFixture("list_dns_records.json"), servermock.CheckQueryParameter().Strict(). With("region", "LON1")). Build(t) records, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd") require.NoError(t, err) expected := []Record{ { ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70", Name: "_acme-challenge", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", Type: "txt", TTL: 600, }, } assert.Equal(t, expected, records) } func TestClient_ListDNSRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd") require.EqualError(t, err, "database_account_not_found: Failed to find the account within the internal database") } func TestClient_ListDNSRecords_error_raw(t *testing.T) { // the API says: // > 4xx/5xx status may not be JSON, unless it's obvious that the response should be parsed for a specific reason. // > So, for example, 404 Not Found pages are a standard page of text // > but 403 Unauthorized requests may have a reason attribute available in the JSON object. // https://www.civo.com/api#parameters-and-responses client := mockBuilder(). Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", servermock.RawStringResponse(http.StatusText(http.StatusNotFound)). WithStatusCode(http.StatusNotFound)). Build(t) _, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd") require.EqualError(t, err, "unexpected status code: [status code: 404] body: Not Found") } func TestClient_CreateDNSRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/7088fcea-7658-43e6-97fa-273f901978fd/records", servermock.ResponseFromFixture("create_dns_record.json"), servermock.CheckRequestJSONBodyFromFixture("create_dns_record-request.json")). Build(t) record := Record{ Name: "_acme-challenge", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", Type: "TXT", TTL: 600, } newRecord, err := client.CreateDNSRecord(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd", record) require.NoError(t, err) expected := &Record{ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70", Name: "_acme-challenge", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", Type: "txt", TTL: 600, } assert.Equal(t, expected, newRecord) } func TestClient_DeleteDNSRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/edc5dacf-a2ad-4757-41ee-c12f06259c70/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3", servermock.ResponseFromFixture("delete_dns_record.json"), servermock.CheckQueryParameter().Strict(). With("region", "LON1")). Build(t) record := Record{ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70", Name: "_acme-challenge", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", Type: "TXT", TTL: 600, } err := client.DeleteDNSRecord(t.Context(), record) require.NoError(t, err) } ================================================ FILE: providers/dns/civo/internal/fixtures/create_dns_record-request.json ================================================ { "type": "TXT", "name": "_acme-challenge", "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 600 } ================================================ FILE: providers/dns/civo/internal/fixtures/create_dns_record.json ================================================ { "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", "created_at": "2019-04-11T12:47:56.000+01:00", "updated_at": "2019-04-11T12:47:56.000+01:00", "account_id": null, "domain_id": "edc5dacf-a2ad-4757-41ee-c12f06259c70", "name": "_acme-challenge", "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "type": "txt", "ttl": 600 } ================================================ FILE: providers/dns/civo/internal/fixtures/delete_dns_record.json ================================================ { "result": "success" } ================================================ FILE: providers/dns/civo/internal/fixtures/error.json ================================================ { "code": "database_account_not_found", "reason": "Failed to find the account within the internal database" } ================================================ FILE: providers/dns/civo/internal/fixtures/list_dns_records.json ================================================ [ { "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", "created_at": "2019-04-11T12:47:56.000+01:00", "updated_at": "2019-04-11T12:47:56.000+01:00", "account_id": null, "domain_id": "edc5dacf-a2ad-4757-41ee-c12f06259c70", "name": "_acme-challenge", "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "type": "txt", "ttl": 600 } ] ================================================ FILE: providers/dns/civo/internal/fixtures/list_domain_names.json ================================================ [ { "id": "7088fcea-7658-43e6-97fa-273f901978fd", "account_id": "e7e8386e-434e-482f-95e0-c406e5d564c2", "name": "example.com" } ] ================================================ FILE: providers/dns/civo/internal/types.go ================================================ package internal import "fmt" type APIError struct { Code string `json:"code"` Reason string `json:"reason"` } func (a *APIError) Error() string { return fmt.Sprintf("%s: %s", a.Code, a.Reason) } type Record struct { ID string `json:"id,omitempty"` AccountID string `json:"account_id,omitempty"` DomainID string `json:"domain_id,omitempty"` Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` Type string `json:"type,omitempty"` TTL int `json:"ttl,omitempty"` } type Domain struct { ID string `json:"id,omitempty"` AccountID string `json:"account_id,omitempty"` Name string `json:"name,omitempty"` } ================================================ FILE: providers/dns/clouddns/clouddns.go ================================================ // Package clouddns implements a DNS provider for solving the DNS-01 challenge using CloudDNS API. package clouddns import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/clouddns/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CLOUDDNS_" EnvClientID = envNamespace + "CLIENT_ID" EnvEmail = envNamespace + "EMAIL" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the DNSProvider. type Config struct { ClientID string Email string Password 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, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), 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 CloudDNS. // Credentials must be passed in the environment variables: // CLOUDDNS_CLIENT_ID, CLOUDDNS_EMAIL, CLOUDDNS_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvClientID, EnvEmail, EnvPassword) if err != nil { return nil, fmt.Errorf("clouddns: %w", err) } config := NewDefaultConfig() config.ClientID = values[EnvClientID] config.Email = values[EnvEmail] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for CloudDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("clouddns: the configuration of the DNS provider is nil") } if config.ClientID == "" || config.Email == "" || config.Password == "" { return nil, errors.New("clouddns: credentials missing") } client := internal.NewClient(config.ClientID, config.Email, config.Password, config.TTL) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.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 using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("clouddns: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return err } err = d.client.AddRecord(ctx, authZone, info.EffectiveFQDN, info.Value) if err != nil { return fmt.Errorf("clouddns: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("clouddns: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return err } err = d.client.DeleteRecord(ctx, authZone, info.EffectiveFQDN) if err != nil { return fmt.Errorf("clouddns: delete record: %w", err) } return nil } ================================================ FILE: providers/dns/clouddns/clouddns.toml ================================================ Name = "CloudDNS" Description = '''''' URL = "https://vshosting.eu/" Code = "clouddns" Since = "v3.6.0" Example = ''' CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \ CLOUDDNS_EMAIL=you@example.com \ CLOUDDNS_PASSWORD=b9841238feb177a84330f \ lego --dns clouddns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CLOUDDNS_CLIENT_ID = "Client ID" CLOUDDNS_EMAIL = "Account email" CLOUDDNS_PASSWORD = "Account password" [Configuration.Additional] CLOUDDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" CLOUDDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" CLOUDDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" CLOUDDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://admin.vshosting.cloud/clouddns/swagger/" APIAdmin = "https://admin.vshosting.cloud/api/public/swagger/" Documentation = "https://github.com/vshosting/clouddns" ================================================ FILE: providers/dns/clouddns/clouddns_test.go ================================================ package clouddns import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvClientID, EnvEmail, EnvPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvClientID: "client123", EnvEmail: "test@example.com", EnvPassword: "password123", }, }, { desc: "missing clientId", envVars: map[string]string{ EnvClientID: "", EnvEmail: "test@example.com", EnvPassword: "password123", }, expected: "clouddns: some credentials information are missing: CLOUDDNS_CLIENT_ID", }, { desc: "missing email", envVars: map[string]string{ EnvClientID: "client123", EnvEmail: "", EnvPassword: "password123", }, expected: "clouddns: some credentials information are missing: CLOUDDNS_EMAIL", }, { desc: "missing password", envVars: map[string]string{ EnvClientID: "client123", EnvEmail: "test@example.com", EnvPassword: "", }, expected: "clouddns: some credentials information are missing: CLOUDDNS_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string clientID string email string password string expected string }{ { desc: "success", clientID: "ID", email: "test@example.com", password: "secret", }, { desc: "missing credentials", expected: "clouddns: credentials missing", }, { desc: "missing client ID", clientID: "", email: "test@example.com", password: "secret", expected: "clouddns: credentials missing", }, { desc: "missing email", clientID: "ID", email: "", password: "secret", expected: "clouddns: credentials missing", }, { desc: "missing password", clientID: "ID", email: "test@example.com", password: "", expected: "clouddns: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.ClientID = test.clientID config.Email = test.email config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/clouddns/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const apiBaseURL = "https://admin.vshosting.cloud/clouddns" const authorizationHeader = "Authorization" // Client handles all communication with CloudDNS API. type Client struct { clientID string email string password string ttl int apiBaseURL *url.URL loginURL *url.URL HTTPClient *http.Client } // NewClient returns a Client instance configured to handle CloudDNS API communication. func NewClient(clientID, email, password string, ttl int) *Client { baseURL, _ := url.Parse(apiBaseURL) loginBaseURL, _ := url.Parse(loginURL) return &Client{ clientID: clientID, email: email, password: password, ttl: ttl, apiBaseURL: baseURL, loginURL: loginBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // AddRecord is a high level method to add a new record into CloudDNS zone. func (c *Client) AddRecord(ctx context.Context, zone, recordName, recordValue string) error { domain, err := c.getDomain(ctx, zone) if err != nil { return err } record := Record{DomainID: domain.ID, Name: recordName, Value: recordValue, Type: "TXT"} err = c.addTxtRecord(ctx, record) if err != nil { return err } return c.publishRecords(ctx, domain.ID) } // DeleteRecord is a high level method to remove a record from zone. func (c *Client) DeleteRecord(ctx context.Context, zone, recordName string) error { domain, err := c.getDomain(ctx, zone) if err != nil { return err } record, err := c.getRecord(ctx, domain.ID, recordName) if err != nil { return err } err = c.deleteRecord(ctx, record) if err != nil { return err } return c.publishRecords(ctx, domain.ID) } func (c *Client) addTxtRecord(ctx context.Context, record Record) error { endpoint := c.apiBaseURL.JoinPath("record-txt") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } func (c *Client) deleteRecord(ctx context.Context, record Record) error { endpoint := c.apiBaseURL.JoinPath("record", record.ID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) getDomain(ctx context.Context, zone string) (Domain, error) { searchQuery := SearchQuery{ Search: []Search{ {Name: "clientId", Operator: "eq", Value: c.clientID}, {Name: "domainName", Operator: "eq", Value: zone}, }, } endpoint := c.apiBaseURL.JoinPath("domain", "search") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, searchQuery) if err != nil { return Domain{}, err } var result SearchResponse err = c.do(req, &result) if err != nil { return Domain{}, err } if len(result.Items) == 0 { return Domain{}, fmt.Errorf("domain not found: %s", zone) } return result.Items[0], nil } func (c *Client) getRecord(ctx context.Context, domainID, recordName string) (Record, error) { endpoint := c.apiBaseURL.JoinPath("domain", domainID) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return Record{}, err } var result DomainInfo err = c.do(req, &result) if err != nil { return Record{}, err } for _, record := range result.LastDomainRecordList { if record.Name == recordName && record.Type == "TXT" { return record, nil } } return Record{}, fmt.Errorf("record not found: domainID %s, name %s", domainID, recordName) } func (c *Client) publishRecords(ctx context.Context, domainID string) error { endpoint := c.apiBaseURL.JoinPath("domain", domainID, "publish") payload := DomainInfo{SoaTTL: c.ttl} req, err := newJSONRequest(ctx, http.MethodPut, endpoint, payload) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { at := getAccessToken(req.Context()) if at != "" { req.Header.Set(authorizationHeader, "Bearer "+at) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIError err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Error) } ================================================ FILE: providers/dns/clouddns/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("clientID", "email@example.com", "secret", 300) client.HTTPClient = server.Client() client.apiBaseURL, _ = url.Parse(server.URL + "/api") client.loginURL, _ = url.Parse(server.URL + "/login") return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /api/domain/search", servermock.ResponseFromFixture("domain_search.json"), servermock.CheckRequestJSONBodyFromFixture("domain_search-request.json")). Route("POST /api/record-txt", nil, servermock.CheckRequestJSONBodyFromFixture("record_txt-request.json")). Route("PUT /api/domain/A/publish", nil, servermock.CheckRequestJSONBodyFromFixture("publish-request.json")). Route("POST /login", servermock.ResponseFromFixture("login.json"), servermock.CheckRequestJSONBodyFromFixture("login-request.json")). Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) err = client.AddRecord(ctx, "example.com", "_acme-challenge.example.com", "txt") require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /api/domain/search", servermock.ResponseFromFixture("domain_search.json"), servermock.CheckRequestJSONBodyFromFixture("domain_search-request.json")). Route("GET /api/domain/A", servermock.ResponseFromFixture("domain-request.json")). Route("DELETE /api/record/R01", nil). Route("PUT /api/domain/A/publish", nil, servermock.CheckRequestJSONBodyFromFixture("publish-request.json")). Route("POST /login", servermock.ResponseFromFixture("login.json"), servermock.CheckRequestJSONBodyFromFixture("login-request.json")). Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) err = client.DeleteRecord(ctx, "example.com", "_acme-challenge.example.com") require.NoError(t, err) } ================================================ FILE: providers/dns/clouddns/internal/fixtures/domain-request.json ================================================ { "id": "Z", "domainName": "example.com", "lastDomainRecordList": [ { "id": "R01", "domainId": "A", "name": "_acme-challenge.example.com", "value": "txt", "type": "TXT" } ], "soaTtl": 300 } ================================================ FILE: providers/dns/clouddns/internal/fixtures/domain_search-request.json ================================================ { "search": [ { "name": "clientId", "operator": "eq", "value": "clientID" }, { "name": "domainName", "operator": "eq", "value": "example.com" } ] } ================================================ FILE: providers/dns/clouddns/internal/fixtures/domain_search.json ================================================ { "items": [ { "id": "A", "domainName": "example.com" } ] } ================================================ FILE: providers/dns/clouddns/internal/fixtures/login-request.json ================================================ { "email": "email@example.com", "password": "secret" } ================================================ FILE: providers/dns/clouddns/internal/fixtures/login.json ================================================ { "auth": { "accessToken": "at" } } ================================================ FILE: providers/dns/clouddns/internal/fixtures/publish-request.json ================================================ { "soaTtl": 300 } ================================================ FILE: providers/dns/clouddns/internal/fixtures/record_txt-request.json ================================================ { "domainId": "A", "name": "_acme-challenge.example.com", "value": "txt", "type": "TXT" } ================================================ FILE: providers/dns/clouddns/internal/identity.go ================================================ package internal import ( "context" "net/http" ) const loginURL = "https://admin.vshosting.cloud/api/public/auth/login" type token string const accessTokenKey token = "accessToken" func (c *Client) login(ctx context.Context) (*AuthResponse, error) { authorization := Authorization{Email: c.email, Password: c.password} req, err := newJSONRequest(ctx, http.MethodPost, c.loginURL, authorization) if err != nil { return nil, err } var result AuthResponse err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { tok, err := c.login(ctx) if err != nil { return nil, err } return context.WithValue(ctx, accessTokenKey, tok.Auth.AccessToken), nil } func getAccessToken(ctx context.Context) string { tok, ok := ctx.Value(accessTokenKey).(string) if !ok { return "" } return tok } ================================================ FILE: providers/dns/clouddns/internal/identity_test.go ================================================ package internal import ( "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClient_CreateAuthenticatedContext(t *testing.T) { client := mockBuilder(). Route("POST /login", servermock.ResponseFromFixture("login.json"), servermock.CheckRequestJSONBodyFromFixture("login-request.json")). Route("DELETE /api/record/xxx", nil). Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) at := getAccessToken(ctx) assert.Equal(t, "at", at) err = client.deleteRecord(ctx, Record{ID: "xxx"}) require.NoError(t, err) } ================================================ FILE: providers/dns/clouddns/internal/types.go ================================================ package internal import "fmt" type APIError struct { Error ErrorContent `json:"error"` } type ErrorContent struct { Code int `json:"code,omitempty"` Message string `json:"message,omitempty"` } func (e ErrorContent) Error() string { return fmt.Sprintf("%d: %s", e.Code, e.Message) } type Authorization struct { Email string `json:"email,omitempty"` Password string `json:"password,omitempty"` } type AuthResponse struct { Auth Auth `json:"auth"` } type Auth struct { AccessToken string `json:"accessToken,omitempty"` RefreshToken string `json:"refreshToken,omitempty"` } type SearchQuery struct { Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"` Search []Search `json:"search,omitempty"` Sort []Sort `json:"sort,omitempty"` } // Search used for searches in the CloudDNS API. type Search struct { Name string `json:"name,omitempty"` Operator string `json:"operator,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value,omitempty"` } type Sort struct { Ascending bool `json:"ascending,omitempty"` Name string `json:"name,omitempty"` } type SearchResponse struct { Items []Domain `json:"items,omitempty"` Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"` TotalHits int `json:"totalHits,omitempty"` } type Domain struct { ID string `json:"id,omitempty"` DomainName string `json:"domainName,omitempty"` Status string `json:"status,omitempty"` } // Record represents a DNS record. type Record struct { ID string `json:"id,omitempty"` DomainID string `json:"domainId,omitempty"` Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` Type string `json:"type,omitempty"` } type DomainInfo struct { ID string `json:"id,omitempty"` DomainName string `json:"domainName,omitempty"` LastDomainRecordList []Record `json:"lastDomainRecordList,omitempty"` SoaTTL int `json:"soaTtl,omitempty"` Status string `json:"status,omitempty"` } ================================================ FILE: providers/dns/cloudflare/cloudflare.go ================================================ // Package cloudflare implements a DNS provider for solving the DNS-01 challenge using cloudflare DNS. package cloudflare import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal" ) // Environment variables names. const ( envNamespace = "CLOUDFLARE_" EnvEmail = envNamespace + "EMAIL" EnvAPIKey = envNamespace + "API_KEY" EnvDNSAPIToken = envNamespace + "DNS_API_TOKEN" EnvZoneAPIToken = envNamespace + "ZONE_API_TOKEN" EnvBaseURL = envNamespace + "BASE_URL" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const ( altEnvNamespace = "CF_" altEnvEmail = altEnvNamespace + "API_EMAIL" ) const ( minTTL = 120 ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AuthEmail string AuthKey string AuthToken string ZoneToken string BaseURL 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.GetOneWithFallback(EnvTTL, minTTL, strconv.Atoi, altEnvName(EnvTTL)), PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, 2*time.Minute, env.ParseSecond, altEnvName(EnvPropagationTimeout)), PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)), HTTPClient: &http.Client{ Timeout: env.GetOneWithFallback(EnvHTTPTimeout, 30*time.Second, env.ParseSecond, altEnvName(EnvHTTPTimeout)), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *metaClient config *Config recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Cloudflare. // Credentials must be passed in as environment variables: // // Either provide CLOUDFLARE_EMAIL and CLOUDFLARE_API_KEY, // or a CLOUDFLARE_DNS_API_TOKEN. // // For a more paranoid setup, provide CLOUDFLARE_DNS_API_TOKEN and CLOUDFLARE_ZONE_API_TOKEN. // // The email and API key should be avoided, if possible. // Instead, set up an API token with both Zone:Read and DNS:Edit permission, and pass the CLOUDFLARE_DNS_API_TOKEN environment variable. // You can split the Zone:Read and DNS:Edit permissions across multiple API tokens: // in this case pass both CLOUDFLARE_ZONE_API_TOKEN and CLOUDFLARE_DNS_API_TOKEN accordingly. func NewDNSProvider() (*DNSProvider, error) { values, err := env.GetWithFallback( []string{EnvEmail, altEnvEmail}, []string{EnvAPIKey, altEnvName(EnvAPIKey)}, ) if err != nil { var errT error values, errT = env.GetWithFallback( []string{EnvDNSAPIToken, altEnvName(EnvDNSAPIToken)}, []string{EnvZoneAPIToken, altEnvName(EnvZoneAPIToken), EnvDNSAPIToken, altEnvName(EnvDNSAPIToken)}, ) if errT != nil { //nolint:errorlint return nil, fmt.Errorf("cloudflare: %v or %v", err, errT) } } config := NewDefaultConfig() config.AuthEmail = values[EnvEmail] config.AuthKey = values[EnvAPIKey] config.AuthToken = values[EnvDNSAPIToken] config.ZoneToken = values[EnvZoneAPIToken] config.BaseURL = env.GetOrFile(EnvBaseURL) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Cloudflare. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("cloudflare: the configuration of the DNS provider is nil") } if config.TTL < minTTL { return nil, fmt.Errorf("cloudflare: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client, err := newClient(config) if err != nil { return nil, fmt.Errorf("cloudflare: %w", err) } return &DNSProvider{ client: client, config: config, recordIDs: make(map[string]string), }, 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 { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.client.ZoneIDByName(ctx, authZone) if err != nil { return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err) } dnsRecord := internal.Record{ Type: "TXT", Name: dns01.UnFqdn(info.EffectiveFQDN), Content: `"` + info.Value + `"`, TTL: d.config.TTL, } response, err := d.client.CreateDNSRecord(ctx, zoneID, dnsRecord) if err != nil { return fmt.Errorf("cloudflare: failed to create TXT record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = response.ID d.recordIDsMu.Unlock() log.Infof("cloudflare: new record for %s, ID %s", domain, response.ID) return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.client.ZoneIDByName(ctx, authZone) if err != nil { return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err) } // get the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("cloudflare: unknown record ID for '%s'", info.EffectiveFQDN) } err = d.client.DeleteDNSRecord(ctx, zoneID, recordID) if err != nil { log.Printf("cloudflare: failed to delete TXT record: %v", err) } // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func altEnvName(v string) string { return strings.ReplaceAll(v, envNamespace, altEnvNamespace) } ================================================ FILE: providers/dns/cloudflare/cloudflare.toml ================================================ Name = "Cloudflare" Description = '''''' URL = "https://www.cloudflare.com/dns/" Code = "cloudflare" Since = "v0.3.0" Example = ''' CLOUDFLARE_EMAIL=you@example.com \ CLOUDFLARE_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ lego --dns cloudflare -d '*.example.com' -d example.com run # or CLOUDFLARE_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ lego --dns cloudflare -d '*.example.com' -d example.com run ''' Additional = ''' ## Description You may use `CF_API_EMAIL` and `CF_API_KEY` to authenticate, or `CF_DNS_API_TOKEN`, or `CF_DNS_API_TOKEN` and `CF_ZONE_API_TOKEN`. ### API keys If using API keys (`CF_API_EMAIL` and `CF_API_KEY`), the Global API Key needs to be used, not the Origin CA Key. Please be aware, that this in principle allows Lego to read and change *everything* related to this account. ### API tokens With API tokens (`CF_DNS_API_TOKEN`, and optionally `CF_ZONE_API_TOKEN`), very specific access can be granted to your resources at Cloudflare. See this [Cloudflare announcement](https://blog.cloudflare.com/api-tokens-general-availability/) for details. The main resources Lego cares for are the DNS entries for your Zones. It also needs to resolve a domain name to an internal Zone ID in order to manipulate DNS entries. Hence, you should create an API token with the following permissions: * Zone / Zone / Read * Zone / DNS / Edit You also need to scope the access to all your domains for this to work. Then pass the API token as `CF_DNS_API_TOKEN` to Lego. **Alternatively,** if you prefer a more strict set of privileges, you can split the access tokens: * Create one with *Zone / Zone / Read* permissions and scope it to all your zones or just the individual zone you need to edit. This is needed to resolve domain names to Zone IDs and can be shared among multiple Lego installations. Pass this API token as `CF_ZONE_API_TOKEN` to Lego. * Create another API token with *Zone / DNS / Edit* permissions and set the scope to the domains you want to manage with a single Lego installation. Pass this token as `CF_DNS_API_TOKEN` to Lego. * Repeat the previous step for each host you want to run Lego on. * It is possible to use the same api token for both variables if it is given `Zone:Read` and `DNS:Edit` permission for the zone. This "paranoid" setup is mainly interesting for users who manage many zones/domains with a single Cloudflare account. It follows the principle of least privilege and limits the possible damage, should one of the hosts become compromised. ''' [Configuration] [Configuration.Credentials] CF_API_EMAIL = "Account email" CF_API_KEY = "API key" CF_DNS_API_TOKEN = "API token with DNS:Edit permission (since v3.1.0)" CF_ZONE_API_TOKEN = "API token with Zone:Read permission (since v3.1.0)" CLOUDFLARE_EMAIL = "Alias to CF_API_EMAIL" CLOUDFLARE_API_KEY = "Alias to CF_API_KEY" CLOUDFLARE_DNS_API_TOKEN = "Alias to CF_DNS_API_TOKEN" CLOUDFLARE_ZONE_API_TOKEN = "Alias to CF_ZONE_API_TOKEN" [Configuration.Additional] CLOUDFLARE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" CLOUDFLARE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" CLOUDFLARE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" CLOUDFLARE_HTTP_TIMEOUT = "API request timeout in seconds (Default: )" CLOUDFLARE_BASE_URL = "API base URL (Default: https://api.cloudflare.com/client/v4)" [Links] API = "https://api.cloudflare.com/" GoClient = "https://github.com/cloudflare/cloudflare-go" ================================================ FILE: providers/dns/cloudflare/cloudflare_test.go ================================================ package cloudflare import ( "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvEmail, EnvAPIKey, EnvDNSAPIToken, EnvZoneAPIToken, EnvBaseURL, altEnvEmail, altEnvName(EnvAPIKey), altEnvName(EnvDNSAPIToken), altEnvName(EnvZoneAPIToken)). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success email, API key", envVars: map[string]string{ EnvEmail: "test@example.com", EnvAPIKey: "123", }, }, { desc: "success API token", envVars: map[string]string{ EnvDNSAPIToken: "012345abcdef", }, }, { desc: "success separate API tokens", envVars: map[string]string{ EnvDNSAPIToken: "012345abcdef", EnvZoneAPIToken: "abcdef012345", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvEmail: "", EnvAPIKey: "", EnvDNSAPIToken: "", }, expected: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, { desc: "missing email", envVars: map[string]string{ EnvEmail: "", EnvAPIKey: "key", }, expected: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, { desc: "missing api key", envVars: map[string]string{ EnvEmail: "awesome@possum.com", EnvAPIKey: "", }, expected: "cloudflare: some credentials information are missing: CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN,CLOUDFLARE_ZONE_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderWithToken(t *testing.T) { type expected struct { dnsToken string zoneToken string sameClient bool error string } testCases := []struct { desc string // test input envVars map[string]string // expectations expected expected }{ { desc: "same client when zone token is missing", envVars: map[string]string{ EnvDNSAPIToken: "123", }, expected: expected{ dnsToken: "123", zoneToken: "123", sameClient: true, }, }, { desc: "same client when zone token equals dns token", envVars: map[string]string{ EnvDNSAPIToken: "123", EnvZoneAPIToken: "123", }, expected: expected{ dnsToken: "123", zoneToken: "123", sameClient: true, }, }, { desc: "failure when only zone api given", envVars: map[string]string{ EnvZoneAPIToken: "123", }, expected: expected{ error: "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY or some credentials information are missing: CLOUDFLARE_DNS_API_TOKEN", }, }, { desc: "different clients when zone and dns token differ", envVars: map[string]string{ EnvDNSAPIToken: "123", EnvZoneAPIToken: "abc", }, expected: expected{ dnsToken: "123", zoneToken: "abc", sameClient: false, }, }, { desc: "aliases work as expected", // CLOUDFLARE_* takes precedence over CF_* envVars: map[string]string{ EnvDNSAPIToken: "123", altEnvName(EnvDNSAPIToken): "456", EnvZoneAPIToken: "abc", altEnvName(EnvZoneAPIToken): "def", }, expected: expected{ dnsToken: "123", zoneToken: "abc", sameClient: false, }, }, } defer envTest.RestoreEnv() localEnvTest := tester.NewEnvTest( EnvDNSAPIToken, altEnvName(EnvDNSAPIToken), EnvZoneAPIToken, altEnvName(EnvZoneAPIToken), ).WithDomain(envDomain) envTest.ClearEnv() for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer localEnvTest.RestoreEnv() localEnvTest.ClearEnv() localEnvTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected.error != "" { require.EqualError(t, err, test.expected.error) return } require.NoError(t, err) require.NotNil(t, p) assert.Equal(t, test.expected.dnsToken, p.config.AuthToken) assert.Equal(t, test.expected.zoneToken, p.config.ZoneToken) if test.expected.sameClient { assert.Equal(t, p.client.clientRead, p.client.clientEdit) } else { assert.NotEqual(t, p.client.clientRead, p.client.clientEdit) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string authEmail string authKey string authToken string expected string }{ { desc: "success with email and api key", authEmail: "test@example.com", authKey: "123", }, { desc: "success with api token", authToken: "012345abcdef", }, { desc: "prefer api token", authToken: "012345abcdef", authEmail: "test@example.com", authKey: "123", }, { desc: "missing credentials", expected: "cloudflare: invalid credentials: authEmail, authKey or authToken must be set", }, { desc: "missing email", authKey: "123", expected: "cloudflare: invalid credentials: authEmail and authKey must be set together", }, { desc: "missing api key", authEmail: "test@example.com", expected: "cloudflare: invalid credentials: authEmail and authKey must be set together", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AuthEmail = test.authEmail config.AuthKey = test.authKey config.AuthToken = test.authToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.AuthEmail = "foo@example.com" config.AuthKey = "secret" config.BaseURL = server.URL config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`). With("X-Auth-Email", "foo@example.com"). With("X-Auth-Key", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). // https://developers.cloudflare.com/api/resources/zones/methods/list/ Route("GET /zones", servermock.ResponseFromInternal("zones.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com"). With("per_page", "50")). // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/ Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", servermock.ResponseFromInternal("create_record.json"), servermock.CheckHeader(). WithContentType("application/json"), servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). // https://developers.cloudflare.com/api/resources/zones/methods/list/ Route("GET /zones", servermock.ResponseFromInternal("zones.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com"). With("per_page", "50")). // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/ Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", servermock.ResponseFromInternal("delete_record.json")). Build(t) token := "abc" provider.recordIDsMu.Lock() provider.recordIDs["abc"] = "xxx" provider.recordIDsMu.Unlock() err := provider.CleanUp("example.com", token, "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/cloudflare/internal/client.go ================================================ /* Package internal Cloudflare API client. The official client is huge and still growing. - https://github.com/cloudflare/cloudflare-go/issues/4171 */ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://api.cloudflare.com/client/v4" // Client the Cloudflare API client. type Client struct { authEmail string authKey string authToken string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(opts ...Option) (*Client, error) { baseURL, _ := url.Parse(defaultBaseURL) client := &Client{ baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } for _, opt := range opts { err := opt(client) if err != nil { return nil, err } } if client.authToken != "" { return client, nil } if client.authEmail == "" && client.authKey == "" { return nil, errors.New("invalid credentials: authEmail, authKey or authToken must be set") } if client.authEmail == "" || client.authKey == "" { return nil, errors.New("invalid credentials: authEmail and authKey must be set together") } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return client, nil } // CreateDNSRecord creates a new DNS record for a zone. // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/ func (c *Client) CreateDNSRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } var result APIResponse[Record] err = c.do(req, &result) if err != nil { return nil, err } return &result.Result, nil } // DeleteDNSRecord deletes DNS record. // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/ func (c *Client) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } // ZonesByName returns a list of zones matching the given name. // https://developers.cloudflare.com/api/resources/zones/methods/list/ func (c *Client) ZonesByName(ctx context.Context, name string) ([]Zone, error) { endpoint := c.baseURL.JoinPath("zones") query := endpoint.Query() query.Set("name", name) query.Set("per_page", "50") endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result APIResponse[[]Zone] err = c.do(req, &result) if err != nil { return nil, err } return result.Result, nil } func (c *Client) do(req *http.Request, result any) error { // https://developers.cloudflare.com/fundamentals/api/how-to/make-api-calls/ if c.authToken != "" { req.Header.Set("Authorization", "Bearer "+c.authToken) } else { req.Header.Set("X-Auth-Email", c.authEmail) req.Header.Set("X-Auth-Key", c.authKey) } useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIResponse[any] err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Errors) } ================================================ FILE: providers/dns/cloudflare/internal/client_test.go ================================================ package internal import ( "context" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder( func(server *httptest.Server) (*Client, error) { client, err := NewClient( WithAuthKey("foo@example.com", "secret"), WithHTTPClient(server.Client()), WithBaseURL(server.URL), ) if err != nil { return nil, err } return client, nil }, servermock.CheckHeader(). WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`). WithAccept("application/json"). With("X-Auth-Email", "foo@example.com"). With("X-Auth-Key", "secret"), ) } func TestClient_CreateDNSRecord(t *testing.T) { client := mockBuilder(). Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", servermock.ResponseFromFixture("create_record.json"), servermock.CheckHeader(). WithContentType("application/json"), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := Record{ Name: "_acme-challenge.example.com", TTL: 120, Type: "TXT", Content: `"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"`, } newRecord, err := client.CreateDNSRecord(t.Context(), "023e105f4ecef8ad9ca31a8372d0c353", record) require.NoError(t, err) expected := &Record{ ID: "023e105f4ecef8ad9ca31a8372d0c353", Name: "example.com", TTL: 3600, Type: "A", Comment: "Domain verification record", Content: "198.51.100.4", } assert.Equal(t, expected, newRecord) } func TestClient_CreateDNSRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) record := Record{ Name: "_acme-challenge.example.com", TTL: 120, Type: "TXT", Content: `"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"`, } _, err := client.CreateDNSRecord(t.Context(), "023e105f4ecef8ad9ca31a8372d0c353", record) require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") } func TestClient_DeleteDNSRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", servermock.ResponseFromFixture("delete_record.json")). Build(t) err := client.DeleteDNSRecord(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "xxx") require.NoError(t, err) } func TestClient_DeleteDNSRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) err := client.DeleteDNSRecord(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "xxx") require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") } func TestClient_ZonesByName(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("zones.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com"). With("per_page", "50")). Build(t) zones, err := client.ZonesByName(context.Background(), "example.com") require.NoError(t, err) expected := []Zone{ { ID: "023e105f4ecef8ad9ca31a8372d0c353", Account: Account{ID: "023e105f4ecef8ad9ca31a8372d0c353", Name: "Example Account Name"}, Meta: Meta{ CdnOnly: true, CustomCertificateQuota: 1, DNSOnly: true, FoundationDNS: true, PageRuleQuota: 100, PhishingDetected: false, Step: 2, }, Name: "example.com", Owner: Owner{ ID: "023e105f4ecef8ad9ca31a8372d0c353", Name: "Example Org", Type: "organization", }, Plan: Plan{ ID: "023e105f4ecef8ad9ca31a8372d0c353", CanSubscribe: false, Currency: "USD", ExternallyManaged: false, Frequency: "monthly", IsSubscribed: false, LegacyDiscount: false, LegacyID: "free", Price: 10, Name: "Example Org", }, CnameSuffix: "cdn.cloudflare.com", Paused: true, Permissions: []string{"#worker:read"}, Tenant: Tenant{ ID: "023e105f4ecef8ad9ca31a8372d0c353", Name: "Example Account Name", }, TenantUnit: TenantUnit{ ID: "023e105f4ecef8ad9ca31a8372d0c353", }, Type: "full", VanityNameServers: []string{"ns1.example.com", "ns2.example.com"}, }, } assert.Equal(t, expected, zones) } func TestClient_ZonesByName_error(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.ZonesByName(context.Background(), "example.com") require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") } ================================================ FILE: providers/dns/cloudflare/internal/fixtures/create_record-request.json ================================================ { "type": "TXT", "name": "_acme-challenge.example.com", "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"", "ttl": 120 } ================================================ FILE: providers/dns/cloudflare/internal/fixtures/create_record.json ================================================ { "errors": [ { "code": 1000, "message": "message", "documentation_url": "documentation_url", "source": { "pointer": "pointer" } } ], "messages": [ { "code": 1000, "message": "message", "documentation_url": "documentation_url", "source": { "pointer": "pointer" } } ], "success": true, "result": { "name": "example.com", "ttl": 3600, "type": "A", "comment": "Domain verification record", "content": "198.51.100.4", "proxied": true, "settings": { "ipv4_only": true, "ipv6_only": true }, "tags": [ "owner:dns-team" ], "id": "023e105f4ecef8ad9ca31a8372d0c353", "proxiable": true } } ================================================ FILE: providers/dns/cloudflare/internal/fixtures/delete_record.json ================================================ { "result": { "id": "023e105f4ecef8ad9ca31a8372d0c353" } } ================================================ FILE: providers/dns/cloudflare/internal/fixtures/error.json ================================================ { "success": false, "errors": [ { "code": 6003, "message": "Invalid request headers", "error_chain": [ { "code": 6103, "message": "Invalid format for X-Auth-Key header" } ] } ], "messages": [], "result": null } ================================================ FILE: providers/dns/cloudflare/internal/fixtures/zones.json ================================================ { "errors": [ { "code": 1000, "message": "message", "documentation_url": "documentation_url", "source": { "pointer": "pointer" } } ], "messages": [ { "code": 1000, "message": "message", "documentation_url": "documentation_url", "source": { "pointer": "pointer" } } ], "success": true, "result": [ { "id": "023e105f4ecef8ad9ca31a8372d0c353", "account": { "id": "023e105f4ecef8ad9ca31a8372d0c353", "name": "Example Account Name" }, "meta": { "cdn_only": true, "custom_certificate_quota": 1, "dns_only": true, "foundation_dns": true, "page_rule_quota": 100, "phishing_detected": false, "step": 2 }, "name": "example.com", "owner": { "id": "023e105f4ecef8ad9ca31a8372d0c353", "name": "Example Org", "type": "organization" }, "plan": { "id": "023e105f4ecef8ad9ca31a8372d0c353", "can_subscribe": false, "currency": "USD", "externally_managed": false, "frequency": "monthly", "is_subscribed": false, "legacy_discount": false, "legacy_id": "free", "price": 10, "name": "Example Org" }, "cname_suffix": "cdn.cloudflare.com", "paused": true, "permissions": [ "#worker:read" ], "tenant": { "id": "023e105f4ecef8ad9ca31a8372d0c353", "name": "Example Account Name" }, "tenant_unit": { "id": "023e105f4ecef8ad9ca31a8372d0c353" }, "type": "full", "vanity_name_servers": [ "ns1.example.com", "ns2.example.com" ] } ], "result_info": { "count": 1, "page": 1, "per_page": 20, "total_count": 1, "total_pages": 1 } } ================================================ FILE: providers/dns/cloudflare/internal/options.go ================================================ package internal import ( "net/http" "net/url" ) type Option func(c *Client) error func WithAuthKey(authEmail, authKey string) Option { return func(c *Client) error { c.authEmail = authEmail c.authKey = authKey return nil } } func WithAuthToken(authToken string) Option { return func(c *Client) error { c.authToken = authToken return nil } } func WithBaseURL(baseURL string) Option { return func(c *Client) error { if baseURL == "" { return nil } bu, err := url.Parse(baseURL) if err != nil { return err } c.baseURL = bu return nil } } func WithHTTPClient(client *http.Client) Option { return func(c *Client) error { if client != nil { c.HTTPClient = client } return nil } } ================================================ FILE: providers/dns/cloudflare/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type Record struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` Comment string `json:"comment,omitempty"` Content string `json:"content,omitempty"` } type APIResponse[T any] struct { Errors Errors `json:"errors,omitempty"` Messages []Message `json:"messages,omitempty"` Success bool `json:"success,omitempty"` Result T `json:"result,omitempty"` ResultInfo *ResultInfo `json:"result_info,omitempty"` } type Message struct { Code int `json:"code"` Message string `json:"message"` DocumentationURL string `json:"documentation_url"` Source *Source `json:"source"` ErrorChain []ErrorChain `json:"error_chain"` } type Source struct { Pointer string `json:"pointer"` } type ErrorChain struct { Code int `json:"code"` Message string `json:"message"` } type Errors []Message func (e Errors) Error() string { msg := new(strings.Builder) for _, item := range e { _, _ = fmt.Fprintf(msg, "%d: %s", item.Code, item.Message) for _, link := range item.ErrorChain { _, _ = fmt.Fprintf(msg, "; %d: %s", link.Code, link.Message) } } return msg.String() } type ResultInfo struct { Count int `json:"count"` Page int `json:"page"` PerPage int `json:"per_page"` TotalCount int `json:"total_count"` TotalPages int `json:"total_pages"` } type Zone struct { ID string `json:"id"` Account Account `json:"account"` Meta Meta `json:"meta"` Name string `json:"name"` Owner Owner `json:"owner"` Plan Plan `json:"plan"` CnameSuffix string `json:"cname_suffix"` Paused bool `json:"paused"` Permissions []string `json:"permissions"` Tenant Tenant `json:"tenant"` TenantUnit TenantUnit `json:"tenant_unit"` Type string `json:"type"` VanityNameServers []string `json:"vanity_name_servers"` } type Account struct { ID string `json:"id"` Name string `json:"name"` } type Meta struct { CdnOnly bool `json:"cdn_only"` CustomCertificateQuota int `json:"custom_certificate_quota"` DNSOnly bool `json:"dns_only"` FoundationDNS bool `json:"foundation_dns"` PageRuleQuota int `json:"page_rule_quota"` PhishingDetected bool `json:"phishing_detected"` Step int `json:"step"` } type Owner struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` } type Plan struct { ID string `json:"id"` CanSubscribe bool `json:"can_subscribe"` Currency string `json:"currency"` ExternallyManaged bool `json:"externally_managed"` Frequency string `json:"frequency"` IsSubscribed bool `json:"is_subscribed"` LegacyDiscount bool `json:"legacy_discount"` LegacyID string `json:"legacy_id"` Price int `json:"price"` Name string `json:"name"` } type Tenant struct { ID string `json:"id"` Name string `json:"name"` } type TenantUnit struct { ID string `json:"id"` } ================================================ FILE: providers/dns/cloudflare/wrapper.go ================================================ package cloudflare import ( "context" "errors" "sync" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal" ) type metaClient struct { clientEdit *internal.Client // needs Zone/DNS/Edit permissions clientRead *internal.Client // needs Zone/Zone/Read permissions zones map[string]string // caches calls to ZoneIDByName, see lookupZoneID() zonesMu *sync.RWMutex } func newClient(config *Config) (*metaClient, error) { // with AuthKey/AuthEmail we can access all available APIs if config.AuthToken == "" { client, err := internal.NewClient( internal.WithBaseURL(config.BaseURL), internal.WithHTTPClient(config.HTTPClient), internal.WithAuthKey(config.AuthEmail, config.AuthKey)) if err != nil { return nil, err } return &metaClient{ clientEdit: client, clientRead: client, zones: make(map[string]string), zonesMu: &sync.RWMutex{}, }, nil } dns, err := internal.NewClient( internal.WithBaseURL(config.BaseURL), internal.WithHTTPClient(config.HTTPClient), internal.WithAuthToken(config.AuthToken)) if err != nil { return nil, err } if config.ZoneToken == "" || config.ZoneToken == config.AuthToken { return &metaClient{ clientEdit: dns, clientRead: dns, zones: make(map[string]string), zonesMu: &sync.RWMutex{}, }, nil } zone, err := internal.NewClient( internal.WithBaseURL(config.BaseURL), internal.WithHTTPClient(config.HTTPClient), internal.WithAuthToken(config.ZoneToken)) if err != nil { return nil, err } return &metaClient{ clientEdit: dns, clientRead: zone, zones: make(map[string]string), zonesMu: &sync.RWMutex{}, }, nil } func (m *metaClient) CreateDNSRecord(ctx context.Context, zoneID string, rr internal.Record) (*internal.Record, error) { return m.clientEdit.CreateDNSRecord(ctx, zoneID, rr) } func (m *metaClient) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error { return m.clientEdit.DeleteDNSRecord(ctx, zoneID, recordID) } func (m *metaClient) ZoneIDByName(ctx context.Context, fdqn string) (string, error) { m.zonesMu.RLock() id := m.zones[fdqn] m.zonesMu.RUnlock() if id != "" { return id, nil } zones, err := m.clientRead.ZonesByName(ctx, dns01.UnFqdn(fdqn)) if err != nil { return "", err } id, err = extractZoneID(zones) if err != nil { return "", err } m.zonesMu.Lock() m.zones[fdqn] = id m.zonesMu.Unlock() return id, nil } func extractZoneID(res []internal.Zone) (string, error) { switch len(res) { case 0: return "", errors.New("zone could not be found") case 1: return res[0].ID, nil default: return "", errors.New("ambiguous zone name; an account ID might help") } } ================================================ FILE: providers/dns/cloudns/cloudns.go ================================================ // Package cloudns implements a DNS provider for solving the DNS-01 challenge using ClouDNS DNS. package cloudns import ( "context" "errors" "fmt" "net/http" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/cloudns/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CLOUDNS_" EnvAuthID = envNamespace + "AUTH_ID" EnvSubAuthID = envNamespace + "SUB_AUTH_ID" EnvAuthPassword = envNamespace + "AUTH_PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AuthID string SubAuthID string AuthPassword string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for ClouDNS. // Credentials must be passed in the environment variables: // CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { var subAuthID string authID := env.GetOrFile(EnvAuthID) if authID == "" { subAuthID = env.GetOrFile(EnvSubAuthID) } if authID == "" && subAuthID == "" { return nil, fmt.Errorf("ClouDNS: some credentials information are missing: %s or %s", EnvAuthID, EnvSubAuthID) } values, err := env.Get(EnvAuthPassword) if err != nil { return nil, fmt.Errorf("ClouDNS: %w", err) } config := NewDefaultConfig() config.AuthID = authID config.SubAuthID = subAuthID config.AuthPassword = values[EnvAuthPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ClouDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ClouDNS: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.AuthID, config.SubAuthID, config.AuthPassword) if err != nil { return nil, fmt.Errorf("ClouDNS: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.client.GetZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("ClouDNS: %w", err) } err = d.client.AddTxtRecord(ctx, zone.Name, info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("ClouDNS: %w", err) } return d.waitNameservers(ctx, domain, zone) } // CleanUp removes the TXT records matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.client.GetZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("ClouDNS: %w", err) } records, err := d.client.ListTxtRecords(ctx, zone.Name, info.EffectiveFQDN) if err != nil { return fmt.Errorf("ClouDNS: %w", err) } if len(records) == 0 { return nil } for _, record := range records { err = d.client.RemoveTxtRecord(ctx, record.ID, zone.Name) if err != nil { return fmt.Errorf("ClouDNS: %w", err) } } return 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 } // waitNameservers At the time of writing 4 servers are found as authoritative, but 8 are reported during the sync. // If this is not done, the secondary verification done by Let's Encrypt server will fail quire a bit. func (d *DNSProvider) waitNameservers(ctx context.Context, domain string, zone *internal.Zone) error { return wait.Retry(ctx, func() error { syncProgress, err := d.client.GetUpdateStatus(ctx, zone.Name) if err != nil { return fmt.Errorf("nameserver sync on %s: %w", domain, err) } log.Infof("[%s] Sync %d/%d complete", domain, syncProgress.Updated, syncProgress.Total) if !syncProgress.Complete { return fmt.Errorf("nameserver sync on %s not complete", domain) } return nil }, backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), backoff.WithMaxElapsedTime(d.config.PropagationTimeout), ) } ================================================ FILE: providers/dns/cloudns/cloudns.toml ================================================ Name = "ClouDNS" Description = '''''' URL = "https://www.cloudns.net" Code = "cloudns" Since = "v2.3.0" Example = ''' CLOUDNS_AUTH_ID=xxxx \ CLOUDNS_AUTH_PASSWORD=yyyy \ lego --dns cloudns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CLOUDNS_AUTH_ID = "The API user ID" CLOUDNS_AUTH_PASSWORD = "The password for API user ID" [Configuration.Additional] CLOUDNS_SUB_AUTH_ID = "The API sub user ID" CLOUDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" CLOUDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)" CLOUDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" CLOUDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.cloudns.net/wiki/article/42/" ================================================ FILE: providers/dns/cloudns/cloudns_test.go ================================================ package cloudns import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAuthID, EnvSubAuthID, EnvAuthPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success auth-id", envVars: map[string]string{ EnvAuthID: "123", EnvSubAuthID: "", EnvAuthPassword: "456", }, }, { desc: "success sub-auth-id", envVars: map[string]string{ EnvAuthID: "", EnvSubAuthID: "123", EnvAuthPassword: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAuthID: "", EnvSubAuthID: "", EnvAuthPassword: "", }, expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_ID or CLOUDNS_SUB_AUTH_ID", }, { desc: "missing auth-id", envVars: map[string]string{ EnvAuthID: "", EnvSubAuthID: "", EnvAuthPassword: "456", }, expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_ID or CLOUDNS_SUB_AUTH_ID", }, { desc: "missing sub-auth-id", envVars: map[string]string{ EnvAuthID: "", EnvSubAuthID: "", EnvAuthPassword: "456", }, expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_ID or CLOUDNS_SUB_AUTH_ID", }, { desc: "missing auth-password", envVars: map[string]string{ EnvAuthID: "123", EnvSubAuthID: "", EnvAuthPassword: "", }, expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string authID string subAuthID string authPassword string expected string }{ { desc: "success auth-id", authID: "123", subAuthID: "", authPassword: "456", }, { desc: "success sub-auth-id", authID: "", subAuthID: "123", authPassword: "456", }, { desc: "missing credentials", expected: "ClouDNS: credentials missing: authID or subAuthID", }, { desc: "missing auth-id", authID: "", subAuthID: "", authPassword: "456", expected: "ClouDNS: credentials missing: authID or subAuthID", }, { desc: "missing sub-auth-id", authID: "", subAuthID: "", authPassword: "456", expected: "ClouDNS: credentials missing: authID or subAuthID", }, { desc: "missing auth-password", authID: "123", expected: "ClouDNS: credentials missing: authPassword", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AuthID = test.authID config.SubAuthID = test.subAuthID config.AuthPassword = test.authPassword p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/cloudns/internal/client.go ================================================ package internal import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.cloudns.net/dns/" // Client the ClouDNS client. type Client struct { authID string subAuthID string authPassword string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a ClouDNS client. func NewClient(authID, subAuthID, authPassword string) (*Client, error) { if authID == "" && subAuthID == "" { return nil, errors.New("credentials missing: authID or subAuthID") } if authPassword == "" { return nil, errors.New("credentials missing: authPassword") } baseURL, err := url.Parse(defaultBaseURL) if err != nil { return nil, err } return &Client{ authID: authID, subAuthID: subAuthID, authPassword: authPassword, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // GetZone Get domain name information for a FQDN. func (c *Client) GetZone(ctx context.Context, authFQDN string) (*Zone, error) { authZone, err := dns01.FindZoneByFqdn(authFQDN) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) } authZoneName := dns01.UnFqdn(authZone) endpoint := c.BaseURL.JoinPath("get-zone-info.json") q := endpoint.Query() q.Set("domain-name", authZoneName) endpoint.RawQuery = q.Encode() req, err := c.newRequest(ctx, http.MethodGet, endpoint) if err != nil { return nil, err } rawMessage, err := c.do(req) if err != nil { return nil, err } var zone Zone if len(rawMessage) > 0 { if err = json.Unmarshal(rawMessage, &zone); err != nil { return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) } } if zone.Name == authZoneName { return &zone, nil } return nil, fmt.Errorf("zone %s not found for authFQDN %s", authZoneName, authFQDN) } // FindTxtRecord returns the TXT record a zone ID and a FQDN. func (c *Client) FindTxtRecord(ctx context.Context, zoneName, fqdn string) (*TXTRecord, error) { subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) if err != nil { return nil, err } endpoint := c.BaseURL.JoinPath("records.json") q := endpoint.Query() q.Set("domain-name", zoneName) q.Set("host", subDomain) q.Set("type", "TXT") endpoint.RawQuery = q.Encode() req, err := c.newRequest(ctx, http.MethodGet, endpoint) if err != nil { return nil, err } rawMessage, err := c.do(req) if err != nil { return nil, err } // the API returns [] when there is no records. if string(rawMessage) == "[]" { return nil, nil } var records map[string]TXTRecord if err = json.Unmarshal(rawMessage, &records); err != nil { return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) } for _, record := range records { if record.Host == subDomain && record.Type == "TXT" { return &record, nil } } return nil, nil } // ListTxtRecords returns the TXT records a zone ID and a FQDN. func (c *Client) ListTxtRecords(ctx context.Context, zoneName, fqdn string) ([]TXTRecord, error) { subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) if err != nil { return nil, err } endpoint := c.BaseURL.JoinPath("records.json") q := endpoint.Query() q.Set("domain-name", zoneName) q.Set("host", subDomain) q.Set("type", "TXT") endpoint.RawQuery = q.Encode() req, err := c.newRequest(ctx, http.MethodGet, endpoint) if err != nil { return nil, err } rawMessage, err := c.do(req) if err != nil { return nil, err } // the API returns [] when there is no records. if string(rawMessage) == "[]" { return nil, nil } var raw map[string]TXTRecord if err = json.Unmarshal(rawMessage, &raw); err != nil { return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) } var records []TXTRecord for _, record := range raw { if record.Host == subDomain && record.Type == "TXT" { records = append(records, record) } } return records, nil } // AddTxtRecord adds a TXT record. func (c *Client) AddTxtRecord(ctx context.Context, zoneName, fqdn, value string, ttl int) error { subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) if err != nil { return err } endpoint := c.BaseURL.JoinPath("add-record.json") q := endpoint.Query() q.Set("domain-name", zoneName) q.Set("host", subDomain) q.Set("record", value) q.Set("ttl", strconv.Itoa(ttlRounder(ttl))) q.Set("record-type", "TXT") endpoint.RawQuery = q.Encode() req, err := c.newRequest(ctx, http.MethodPost, endpoint) if err != nil { return err } rawMessage, err := c.do(req) if err != nil { return err } resp := apiResponse{} if err = json.Unmarshal(rawMessage, &resp); err != nil { return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) } if resp.Status != "Success" { return fmt.Errorf("failed to add TXT record: %s %s", resp.Status, resp.StatusDescription) } return nil } // RemoveTxtRecord removes a TXT record. func (c *Client) RemoveTxtRecord(ctx context.Context, recordID int, zoneName string) error { endpoint := c.BaseURL.JoinPath("delete-record.json") q := endpoint.Query() q.Set("domain-name", zoneName) q.Set("record-id", strconv.Itoa(recordID)) endpoint.RawQuery = q.Encode() req, err := c.newRequest(ctx, http.MethodPost, endpoint) if err != nil { return err } rawMessage, err := c.do(req) if err != nil { return err } resp := apiResponse{} if err = json.Unmarshal(rawMessage, &resp); err != nil { return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) } if resp.Status != "Success" { return fmt.Errorf("failed to remove TXT record: %s %s", resp.Status, resp.StatusDescription) } return nil } // GetUpdateStatus gets sync progress of all CloudDNS NS servers. func (c *Client) GetUpdateStatus(ctx context.Context, zoneName string) (*SyncProgress, error) { endpoint := c.BaseURL.JoinPath("update-status.json") q := endpoint.Query() q.Set("domain-name", zoneName) endpoint.RawQuery = q.Encode() req, err := c.newRequest(ctx, http.MethodGet, endpoint) if err != nil { return nil, err } rawMessage, err := c.do(req) if err != nil { return nil, err } // the API returns [] when there is no records. if string(rawMessage) == "[]" { return nil, errors.New("no nameservers records returned") } var records []UpdateRecord if err = json.Unmarshal(rawMessage, &records); err != nil { return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err) } updatedCount := 0 for _, record := range records { if record.Updated { updatedCount++ } } return &SyncProgress{Complete: updatedCount == len(records), Updated: updatedCount, Total: len(records)}, nil } func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL) (*http.Request, error) { q := endpoint.Query() if c.subAuthID != "" { q.Set("sub-auth-id", c.subAuthID) } else { q.Set("auth-id", c.authID) } q.Set("auth-password", c.authPassword) endpoint.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } return req, nil } func (c *Client) do(req *http.Request) (json.RawMessage, error) { resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } return raw, nil } // Rounds the given TTL in seconds to the next accepted value. // Accepted TTL values are: // - 60 = 1 minute // - 300 = 5 minutes // - 900 = 15 minutes // - 1800 = 30 minutes // - 3600 = 1 hour // - 21600 = 6 hours // - 43200 = 12 hours // - 86400 = 1 day // - 172800 = 2 days // - 259200 = 3 days // - 604800 = 1 week // - 1209600 = 2 weeks // - 2592000 = 1 month // // See https://www.cloudns.net/wiki/article/58/ for details. func ttlRounder(ttl int) int { for _, validTTL := range []int{60, 300, 900, 1800, 3600, 21600, 43200, 86400, 172800, 259200, 604800, 1209600} { if ttl <= validTTL { return validTTL } } return 2592000 } ================================================ FILE: providers/dns/cloudns/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(subAuthID string) func(server *httptest.Server) (*Client, error) { return func(server *httptest.Server) (*Client, error) { client, err := NewClient("myAuthID", subAuthID, "myAuthPassword") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil } } func TestNewClient(t *testing.T) { testCases := []struct { desc string authID string subAuthID string authPassword string expected string }{ { desc: "all provided", authID: "1000", subAuthID: "1111", authPassword: "no-secret", }, { desc: "missing authID & subAuthID", authID: "", subAuthID: "", authPassword: "no-secret", expected: "credentials missing: authID or subAuthID", }, { desc: "missing authID & subAuthID", authID: "", subAuthID: "present", authPassword: "", expected: "credentials missing: authPassword", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client, err := NewClient(test.authID, test.subAuthID, test.authPassword) if test.expected != "" { assert.Nil(t, client) require.EqualError(t, err, test.expected) } else { assert.NotNil(t, client) require.NoError(t, err) } }) } } func TestClient_GetZone(t *testing.T) { type expected struct { zone *Zone errorMsg string } testCases := []struct { desc string authFQDN string apiResponse string expected expected }{ { desc: "zone found", authFQDN: "_acme-challenge.foo.com.", apiResponse: `{"name": "foo.com", "type": "master", "zone": "zone", "status": "1"}`, expected: expected{ zone: &Zone{ Name: "foo.com", Type: "master", Zone: "zone", Status: "1", }, }, }, { desc: "zone not found", authFQDN: "_acme-challenge.foo.com.", apiResponse: ``, expected: expected{ errorMsg: "zone foo.com not found for authFQDN _acme-challenge.foo.com.", }, }, { desc: "invalid json response", authFQDN: "_acme-challenge.foo.com.", apiResponse: `[{}]`, expected: expected{ errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.Zone", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient("")). Route("GET /get-zone-info.json", servermock.RawStringResponse(test.apiResponse), servermock.CheckQueryParameter().Strict(). With("auth-id", "myAuthID"). With("auth-password", "myAuthPassword"). With("domain-name", "foo.com"), ). Build(t) zone, err := client.GetZone(t.Context(), test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) assert.Equal(t, test.expected.zone, zone) } }) } } func TestClient_FindTxtRecord(t *testing.T) { type expected struct { txtRecord *TXTRecord errorMsg string } testCases := []struct { desc string authFQDN string zoneName string apiResponse string expected expected }{ { desc: "record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", "host": "_acme-challenge", "record": "txtTXTtxtTXTtxtTXTtxtTXT", "failover": "0", "ttl": "3600", "status": 1 }, "181805209": { "id": "181805209", "type": "TXT", "host": "_github-challenge", "record": "b66b8324b5", "failover": "0", "ttl": "300", "status": 1 } }`, expected: expected{ txtRecord: &TXTRecord{ ID: 5769228, Type: "TXT", Host: "_acme-challenge", Record: "txtTXTtxtTXTtxtTXTtxtTXT", Failover: 0, TTL: 3600, Status: 1, }, }, }, { desc: "no record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", "host": "_other-challenge", "record": "txtTXTtxtTXTtxtTXTtxtTXT", "failover": "0", "ttl": "3600", "status": 1 }, "181805209": { "id": "181805209", "type": "TXT", "host": "_github-challenge", "record": "b66b8324b5", "failover": "0", "ttl": "300", "status": 1 } }`, }, { desc: "zero records", authFQDN: "_acme-challenge.example.com.", zoneName: "example.com", apiResponse: `[]`, }, { desc: "invalid json response", authFQDN: "_acme-challenge.example.com.", zoneName: "example.com", apiResponse: `[{}]`, expected: expected{ errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient("")). Route("GET /records.json", servermock.RawStringResponse(test.apiResponse), servermock.CheckQueryParameter().Strict(). With("auth-id", "myAuthID"). With("auth-password", "myAuthPassword"). With("type", "TXT"). With("host", "_acme-challenge"). With("domain-name", test.zoneName), ). Build(t) txtRecord, err := client.FindTxtRecord(t.Context(), test.zoneName, test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) assert.Equal(t, test.expected.txtRecord, txtRecord) } }) } } func TestClient_ListTxtRecord(t *testing.T) { type expected struct { txtRecords []TXTRecord errorMsg string } testCases := []struct { desc string authFQDN string zoneName string apiResponse string expected expected }{ { desc: "record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", "host": "_acme-challenge", "record": "txtTXTtxtTXTtxtTXTtxtTXT", "failover": "0", "ttl": "3600", "status": 1 }, "181805209": { "id": "181805209", "type": "TXT", "host": "_github-challenge", "record": "b66b8324b5", "failover": "0", "ttl": "300", "status": 1 } }`, expected: expected{ txtRecords: []TXTRecord{ { ID: 5769228, Type: "TXT", Host: "_acme-challenge", Record: "txtTXTtxtTXTtxtTXTtxtTXT", Failover: 0, TTL: 3600, Status: 1, }, }, }, }, { desc: "no record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", "host": "_other-challenge", "record": "txtTXTtxtTXTtxtTXTtxtTXT", "failover": "0", "ttl": "3600", "status": 1 }, "181805209": { "id": "181805209", "type": "TXT", "host": "_github-challenge", "record": "b66b8324b5", "failover": "0", "ttl": "300", "status": 1 } }`, }, { desc: "zero records", authFQDN: "_acme-challenge.example.com.", zoneName: "example.com", apiResponse: `[]`, }, { desc: "invalid json response", authFQDN: "_acme-challenge.example.com.", zoneName: "example.com", apiResponse: `[{}]`, expected: expected{ errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient("")). Route("GET /records.json", servermock.RawStringResponse(test.apiResponse), servermock.CheckQueryParameter().Strict(). With("auth-id", "myAuthID"). With("auth-password", "myAuthPassword"). With("type", "TXT"). With("host", "_acme-challenge"). With("domain-name", test.zoneName), ). Build(t) txtRecords, err := client.ListTxtRecords(t.Context(), test.zoneName, test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) assert.Equal(t, test.expected.txtRecords, txtRecords) } }) } } func TestClient_AddTxtRecord(t *testing.T) { type expected struct { query url.Values errorMsg string } testCases := []struct { desc string authID string subAuthID string zoneName string authFQDN string value string ttl int apiResponse string expected expected }{ { desc: "sub-zone", authID: "myAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.foo.example.com.", value: "txtTXTtxtTXTtxtTXTtxtTXT", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ query: url.Values{ "auth-id": {"myAuthID"}, "auth-password": {"myAuthPassword"}, "domain-name": {"example.com"}, "host": {"_acme-challenge.foo"}, "record": {"txtTXTtxtTXTtxtTXTtxtTXT"}, "record-type": {"TXT"}, "ttl": {"60"}, }, }, }, { desc: "main zone (authID)", authID: "myAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ query: url.Values{ "auth-id": {"myAuthID"}, "auth-password": {"myAuthPassword"}, "domain-name": {"example.com"}, "host": {"_acme-challenge"}, "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, "record-type": {"TXT"}, "ttl": {"60"}, }, }, }, { desc: "main zone (subAuthID)", subAuthID: "mySubAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ query: url.Values{ "auth-password": {"myAuthPassword"}, "domain-name": {"example.com"}, "host": {"_acme-challenge"}, "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, "record-type": {"TXT"}, "sub-auth-id": {"mySubAuthID"}, "ttl": {"60"}, }, }, }, { desc: "invalid status", authID: "myAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 120, apiResponse: `{"status":"Failed","statusDescription":"Invalid TTL. Choose from the list of the values we support."}`, expected: expected{ query: url.Values{ "auth-id": {"myAuthID"}, "auth-password": {"myAuthPassword"}, "domain-name": {"example.com"}, "host": {"_acme-challenge"}, "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, "record-type": {"TXT"}, "ttl": {"300"}, }, errorMsg: "failed to add TXT record: Failed Invalid TTL. Choose from the list of the values we support.", }, }, { desc: "invalid json response", authID: "myAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 120, apiResponse: `[{}]`, expected: expected{ query: url.Values{ "auth-id": {"myAuthID"}, "auth-password": {"myAuthPassword"}, "domain-name": {"example.com"}, "host": {"_acme-challenge"}, "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, "record-type": {"TXT"}, "ttl": {"300"}, }, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient(test.subAuthID)). Route("POST /add-record.json", servermock.RawStringResponse(test.apiResponse), servermock.CheckQueryParameter().Strict(). WithValues(test.expected.query), ). Build(t) err := client.AddTxtRecord(t.Context(), test.zoneName, test.authFQDN, test.value, test.ttl) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) } }) } } func TestClient_RemoveTxtRecord(t *testing.T) { type expected struct { query url.Values errorMsg string } testCases := []struct { desc string id int zoneName string apiResponse string expected expected }{ { desc: "record found", id: 5769228, zoneName: "foo.com", apiResponse: `{ "status": "Success", "statusDescription": "The record was deleted successfully." }`, expected: expected{ query: url.Values{ "auth-id": {"myAuthID"}, "auth-password": {"myAuthPassword"}, "domain-name": {"foo.com"}, "record-id": {"5769228"}, }, }, }, { desc: "record not found", id: 5769000, zoneName: "foo.com", apiResponse: `{ "status": "Failed", "statusDescription": "Invalid record-id param." }`, expected: expected{ query: url.Values{ "auth-id": {"myAuthID"}, "auth-password": {"myAuthPassword"}, "domain-name": {"foo.com"}, "record-id": {"5769000"}, }, errorMsg: "failed to remove TXT record: Failed Invalid record-id param.", }, }, { desc: "invalid json response", id: 44, zoneName: "foo-plus.com", apiResponse: `[{}]`, expected: expected{ query: url.Values{ "auth-id": {"myAuthID"}, "auth-password": {"myAuthPassword"}, "domain-name": {"foo-plus.com"}, "record-id": {"44"}, }, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient("")). Route("POST /delete-record.json", servermock.RawStringResponse(test.apiResponse), servermock.CheckQueryParameter().Strict(). WithValues(test.expected.query), ). Build(t) err := client.RemoveTxtRecord(t.Context(), test.id, test.zoneName) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) } }) } } func TestClient_GetUpdateStatus(t *testing.T) { type expected struct { progress *SyncProgress errorMsg string } testCases := []struct { desc string authFQDN string zoneName string apiResponse string expected expected }{ { desc: "50% sync", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `[ {"server": "ns101.foo.com.", "ip4": "10.11.12.13", "ip6": "2a00:2a00:2a00:9::5", "updated": true }, {"server": "ns102.foo.com.", "ip4": "10.14.16.17", "ip6": "2100:2100:2100:3::1", "updated": false } ]`, expected: expected{progress: &SyncProgress{Updated: 1, Total: 2}}, }, { desc: "100% sync", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `[ {"server": "ns101.foo.com.", "ip4": "10.11.12.13", "ip6": "2a00:2a00:2a00:9::5", "updated": true }, {"server": "ns102.foo.com.", "ip4": "10.14.16.17", "ip6": "2100:2100:2100:3::1", "updated": true } ]`, expected: expected{progress: &SyncProgress{Complete: true, Updated: 2, Total: 2}}, }, { desc: "record not found", authFQDN: "_acme-challenge.foo.com.", zoneName: "test-zone", apiResponse: `[]`, expected: expected{errorMsg: "no nameservers records returned"}, }, { desc: "invalid json response", authFQDN: "_acme-challenge.foo.com.", zoneName: "test-zone", apiResponse: `[x]`, expected: expected{errorMsg: "unable to unmarshal response: [status code: 200] body: [x] error: invalid character 'x' looking for beginning of value"}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient("")). Route("GET /update-status.json", servermock.RawStringResponse(test.apiResponse), servermock.CheckQueryParameter().Strict(). With("auth-id", "myAuthID"). With("auth-password", "myAuthPassword"). With("domain-name", test.zoneName), ). Build(t) syncProgress, err := client.GetUpdateStatus(t.Context(), test.zoneName) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) } assert.Equal(t, test.expected.progress, syncProgress) }) } } ================================================ FILE: providers/dns/cloudns/internal/types.go ================================================ package internal type apiResponse struct { Status string `json:"status"` StatusDescription string `json:"statusDescription"` } // Zone is a zone. type Zone struct { Name string Type string Zone string Status string // is an integer, but cast as string } // TXTRecord is a TXT record. type TXTRecord struct { ID int `json:"id,string"` Type string `json:"type"` Host string `json:"host"` Record string `json:"record"` Failover int `json:"failover,string"` TTL int `json:"ttl,string"` Status int `json:"status"` } // UpdateRecord is a Server Sync Record. type UpdateRecord struct { Server string `json:"server"` IP4 string `json:"ip4"` IP6 string `json:"ip6"` Updated bool `json:"updated"` } type SyncProgress struct { Complete bool Updated int Total int } ================================================ FILE: providers/dns/cloudru/cloudru.go ================================================ // Package cloudru implements a DNS provider for solving the DNS-01 challenge using cloud.ru DNS. package cloudru import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/cloudru/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CLOUDRU_" EnvServiceInstanceID = envNamespace + "SERVICE_INSTANCE_ID" EnvKeyID = envNamespace + "KEY_ID" EnvSecret = envNamespace + "SECRET" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ServiceInstanceID string KeyID string Secret string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } type DNSProvider struct { config *Config client *internal.Client records map[string]*internal.Record recordsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for cloud.ru. // Credentials must be passed in the environment variables: // CLOUDRU_SERVICE_INSTANCE_ID, CLOUDRU_KEY_ID, and CLOUDRU_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvServiceInstanceID, EnvKeyID, EnvSecret) if err != nil { return nil, fmt.Errorf("cloudru: %w", err) } config := NewDefaultConfig() config.ServiceInstanceID = values[EnvServiceInstanceID] config.KeyID = values[EnvKeyID] config.Secret = values[EnvSecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for cloud.ru. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("cloudru: the configuration of the DNS provider is nil") } if config.ServiceInstanceID == "" || config.KeyID == "" || config.Secret == "" { return nil, errors.New("cloudru: some credentials information are missing") } client := internal.NewClient(config.KeyID, config.Secret) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, records: make(map[string]*internal.Record), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("cloudru: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("cloudru: %w", err) } zone, err := d.getZoneInformationByName(ctx, d.config.ServiceInstanceID, authZone) if err != nil { return fmt.Errorf("cloudru: could not find zone information (ServiceInstanceID: %s, zone: %s): %w", d.config.ServiceInstanceID, authZone, err) } record := internal.Record{ Name: info.EffectiveFQDN, Type: "TXT", Values: []string{info.Value}, TTL: strconv.Itoa(d.config.TTL), } newRecord, err := d.client.CreateRecord(ctx, zone.ID, record) if err != nil { return fmt.Errorf("cloudru: could not create record: %w", err) } d.recordsMu.Lock() d.records[token] = newRecord d.recordsMu.Unlock() return nil } // CleanUp removes a given record that was generated by Present. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordsMu.Lock() record, ok := d.records[token] d.recordsMu.Unlock() if !ok { return fmt.Errorf("cloudru: unknown recordID for %q", info.EffectiveFQDN) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("cloudru: %w", err) } err = d.client.DeleteRecord(ctx, record.ZoneID, record.Name, "TXT") if err != nil { return fmt.Errorf("cloudru: %w", err) } d.recordsMu.Lock() delete(d.records, token) d.recordsMu.Unlock() return nil } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // 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 } func (d *DNSProvider) getZoneInformationByName(ctx context.Context, parentID, name string) (internal.Zone, error) { zs, err := d.client.GetZones(ctx, parentID) if err != nil { return internal.Zone{}, err } for _, element := range zs { if element.Name == name { return element, nil } } return internal.Zone{}, errors.New("could not find Zone record") } ================================================ FILE: providers/dns/cloudru/cloudru.toml ================================================ Name = "Cloud.ru" Description = '''''' URL = "https://cloud.ru" Code = "cloudru" Since = "v4.14.0" Example = ''' CLOUDRU_SERVICE_INSTANCE_ID=ppp \ CLOUDRU_KEY_ID=xxx \ CLOUDRU_SECRET=yyy \ lego --dns cloudru -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CLOUDRU_SERVICE_INSTANCE_ID = "Service Instance ID (parentId)" CLOUDRU_KEY_ID = "Key ID (login)" CLOUDRU_SECRET = "Key Secret" [Configuration.Additional] CLOUDRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" CLOUDRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" CLOUDRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" CLOUDRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" CLOUDRU_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)" [Links] API = "https://cloud.ru/ru/docs/clouddns/ug/topics/api-ref.html" ================================================ FILE: providers/dns/cloudru/cloudru_test.go ================================================ package cloudru import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvServiceInstanceID, EnvKeyID, EnvSecret). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvServiceInstanceID: "123", EnvKeyID: "user", EnvSecret: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "cloudru: some credentials information are missing: CLOUDRU_SERVICE_INSTANCE_ID,CLOUDRU_KEY_ID,CLOUDRU_SECRET", }, { desc: "missing service instance ID", envVars: map[string]string{ EnvServiceInstanceID: "", EnvKeyID: "user", EnvSecret: "secret", }, expected: "cloudru: some credentials information are missing: CLOUDRU_SERVICE_INSTANCE_ID", }, { desc: "missing key ID", envVars: map[string]string{ EnvServiceInstanceID: "123", EnvKeyID: "", EnvSecret: "secret", }, expected: "cloudru: some credentials information are missing: CLOUDRU_KEY_ID", }, { desc: "missing secret", envVars: map[string]string{ EnvServiceInstanceID: "123", EnvKeyID: "user", EnvSecret: "", }, expected: "cloudru: some credentials information are missing: CLOUDRU_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string serviceInstanceID string keyID string secret string expected string }{ { desc: "success", serviceInstanceID: "123", keyID: "user", secret: "secret", }, { desc: "missing credentials", expected: "cloudru: some credentials information are missing", }, { desc: "missing service instance ID", serviceInstanceID: "", keyID: "user", secret: "secret", expected: "cloudru: some credentials information are missing", }, { desc: "missing key ID", serviceInstanceID: "123", keyID: "", secret: "secret", expected: "cloudru: some credentials information are missing", }, { desc: "missing secret", serviceInstanceID: "123", keyID: "user", secret: "", expected: "cloudru: some credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.ServiceInstanceID = test.serviceInstanceID config.KeyID = test.keyID config.Secret = test.secret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/cloudru/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // Default API endpoints. const ( APIBaseURL = "https://console.cloud.ru/api/clouddns/v1" AuthBaseURL = "https://auth.iam.cloud.ru/auth/system/openid/token" ) // Client the Cloud.ru API client. type Client struct { keyID string secret string APIEndpoint *url.URL AuthEndpoint *url.URL HTTPClient *http.Client token *Token muToken sync.Mutex } // NewClient Creates a new Client. func NewClient(login, secret string) *Client { apiEndpoint, _ := url.Parse(APIBaseURL) authEndpoint, _ := url.Parse(AuthBaseURL) return &Client{ keyID: login, secret: secret, APIEndpoint: apiEndpoint, AuthEndpoint: authEndpoint, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) GetZones(ctx context.Context, parentID string) ([]Zone, error) { endpoint := c.APIEndpoint.JoinPath("zones") query := endpoint.Query() query.Set("parentId", parentID) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var zones APIResponse[Zone] err = c.do(req, &zones) if err != nil { return nil, err } return zones.Items, nil } func (c *Client) GetRecords(ctx context.Context, zoneID string) ([]Record, error) { endpoint := c.APIEndpoint.JoinPath("zones", zoneID, "records") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var records APIResponse[Record] err = c.do(req, &records) if err != nil { return nil, err } return records.Items, nil } func (c *Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { endpoint := c.APIEndpoint.JoinPath("zones", zoneID, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } var result Record err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } func (c *Client) DeleteRecord(ctx context.Context, zoneID, name, recordType string) error { endpoint := c.APIEndpoint.JoinPath("zones", zoneID, "records", name, recordType) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { tok := getToken(req.Context()) if tok != nil { req.Header.Set("Authorization", "Bearer "+tok.AccessToken) } else { return errors.New("not logged in") } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if result == nil { return nil } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/cloudru/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.APIEndpoint, _ = url.Parse(server.URL) client.token = &Token{ AccessToken: "secret", ExpiresIn: 60, TokenType: "Bearer", Deadline: time.Now().Add(1 * time.Minute), } return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer xxx")) } func TestClient_GetZones(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("zones.json")). Build(t) ctx := mockContext(t) zones, err := client.GetZones(ctx, "xxx") require.NoError(t, err) expected := []Zone{ { ID: "59556fcd-95ff-451f-b49b-9732f21f944a", ParentID: "2d7b6194-2b83-4f71-86fd-a1e727e347b2", Name: "example.com", Valid: true, Delegated: true, CreatedAt: time.Date(2023, 7, 23, 8, 12, 41, 0, time.UTC), UpdatedAt: time.Date(2023, 7, 24, 5, 50, 28, 0, time.UTC), }, } assert.Equal(t, expected, zones) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /zones/zzz/records", servermock.ResponseFromFixture("records.json")). Build(t) ctx := mockContext(t) records, err := client.GetRecords(ctx, "zzz") require.NoError(t, err) expected := []Record{ { ZoneID: "59556fcd-95ff-451f-b49b-9732f21f944a", Name: "example.com.", Type: "SOA", Values: []string{ "cdns-ns01.sbercloud.ru. mail.sbercloud.ru 1 120 3600 604800 3600", }, TTL: "3600", Enables: true, }, { ZoneID: "59556fcd-95ff-451f-b49b-9732f21f944a", Name: "example.com.", Type: "NS", Values: []string{ "cdns-ns01.sbercloud.ru.", "cdns-ns02.sbercloud.ru.", }, TTL: "3600", Enables: true, }, { ZoneID: "59556fcd-95ff-451f-b49b-9732f21f944a", Name: "www.example.com.", Type: "A", Values: []string{ "8.8.8.8", }, TTL: "3600", Enables: true, }, } assert.Equal(t, expected, records) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /zones/zzz/records", servermock.ResponseFromFixture("record.json"), servermock.CheckRequestJSONBody(`{"name":"www.example.com.","type":"TXT","values":["text"],"ttl":"3600"}`)). Build(t) ctx := mockContext(t) recordReq := Record{ Name: "www.example.com.", Type: "TXT", Values: []string{"text"}, TTL: "3600", } record, err := client.CreateRecord(ctx, "zzz", recordReq) require.NoError(t, err) expected := &Record{ ZoneID: "59556fcd-95ff-451f-b49b-9732f21f944a", Name: "www.example.com.", Type: "TXT", Values: []string{ "txt", }, TTL: "3600", Enables: true, } assert.Equal(t, expected, record) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/zzz/records/example.com/TXT", servermock.ResponseFromFixture("record.json")). Build(t) ctx := mockContext(t) err := client.DeleteRecord(ctx, "zzz", "example.com", "TXT") require.NoError(t, err) } ================================================ FILE: providers/dns/cloudru/internal/fixtures/auth-error.json ================================================ { "error": "invalid_client", "error_description": "client not found" } ================================================ FILE: providers/dns/cloudru/internal/fixtures/auth.json ================================================ { "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImEyMjM3ZDhhLWQ0ZDQtNDA5Yi04ZTMxLWM3NGJhYTZhM2NjYiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaWFtIl0sImF1dGhfdGltZSI6MTY5MDA1Mzg1MiwiYXpwIjoiOWE0M2I0OTM1ZDRhMDc5NmRkYjE0Mjk0NjUxYjk2NzciLCJlbWFpbCI6ImxlZ29AOTlmN2I5NzItZmZlYS00OTkyLTgyN2EtY2M4MDYzOTg1MmNhLmlhbS5zYmVyY2xvdWQucnUiLCJleHAiOjE2OTAwNTc0NTIsImlhdCI6MTY5MDA1Mzg1MiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmlhbS5zYmVyY2xvdWQucnUvYXV0aC9zeXN0ZW0iLCJqdGkiOiJlYzk0ZWJhNC03NzU2LTRjNjQtYmNmMC0zMzYxODIwNWM5ODkiLCJuYmYiOjE2OTAwNTM4NTIsIm5vbmNlIjoiIiwicmVhbG1fYWNjZXNzIjpudWxsLCJyZXNvdXJjZV9hY2Nlc3MiOnt9LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJvbGVzIiwic3ViIjoiZmVhMzRmYTUtZmE5ZS00OTdkLTk5MDAtNjdhYWJjOWNkZWJjIiwic3ViX2lkIjoiZmVhMzRmYTUtZmE5ZS00OTdkLTk5MDAtNjdhYWJjOWNkZWJjIiwic3ViX3R5cGUiOiJzZXJ2aWNlX2FjY291bnQiLCJ0eXAiOiJCZWFyZXIifQ.hhPr-Xr_NbyRwrqGoqeepthWfpfmD47RjzHUwo2lVPkeMiL8AMWzDPRxs-8gns9eTSHZCoAH0RjyrBnTaOrztInM72h8_rIIFr0MMPIIqrUkp2id_alya9eoiSWg_69PzNZ2CKWJDylL8o4Vi9_cSBYp-6H1xNcOAvO4a9xkNCoGGiogjHWNFq64qnS_P6fYY-pl9leuprCeq1GAKPODevHwzmc4gkEZIj_15SUh_ofJRJICgyLmkELQ8a0wDGYmZcdNKiGQDpd7rHaGrOvO1k8IJHfgs5aCMyuHXybTg6AMlodpYs8MBdk6K_VFY-cxSRB8ocq_Q7Hgt9qaRADg2Q", "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImEyMjM3ZDhhLWQ0ZDQtNDA5Yi04ZTMxLWM3NGJhYTZhM2NjYiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaWFtIl0sImF1dGhfdGltZSI6MTY5MDA1Mzg1MiwiYXpwIjoiOWE0M2I0OTM1ZDRhMDc5NmRkYjE0Mjk0NjUxYjk2NzciLCJlbWFpbCI6ImxlZ29AOTlmN2I5NzItZmZlYS00OTkyLTgyN2EtY2M4MDYzOTg1MmNhLmlhbS5zYmVyY2xvdWQucnUiLCJleHAiOjE2OTAwNTc0NTIsImlhdCI6MTY5MDA1Mzg1MiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmlhbS5zYmVyY2xvdWQucnUvYXV0aC9zeXN0ZW0iLCJqdGkiOiIxNDRmMDRlNS1jYjZkLTQ2NTktODJhMi0yMmE5MDQwNGZlZjAiLCJuYmYiOjE2OTAwNTM4NTIsIm5vbmNlIjoiIiwicmVhbG1fYWNjZXNzIjpudWxsLCJyZXNvdXJjZV9hY2Nlc3MiOnt9LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJvbGVzIiwic3ViIjoiZmVhMzRmYTUtZmE5ZS00OTdkLTk5MDAtNjdhYWJjOWNkZWJjIiwic3ViX2lkIjoiZmVhMzRmYTUtZmE5ZS00OTdkLTk5MDAtNjdhYWJjOWNkZWJjIiwic3ViX3R5cGUiOiJzZXJ2aWNlX2FjY291bnQiLCJ0eXAiOiJJRCJ9.oW9w9X2EBozdY7JTnL6WBPE114BM52ZOaWLkXamJvUOks_F4fRxw5lJIN-LkTwMZ9jE3PsBV2_SueCL5Ry2ISiEXaZeoQ_FPnSkz-CMFDP6Ph2erOvEWQInTIPA6h-ToIhYMZR8lc_kPOmar2mTT8b043FZ6zFDf28PJCCo8snCgA_tIO7R0fNJYT7Hr-UR7LSrE-Sjz7lsgttyDEPH1P4yPm4ZzRLYLcR240p1iGKG9yxtl8IL6uxseS4pUddimaH6jFPhMFLH44PV4O_-uYs74erjoPiroCHiaWQIdDR5GZDoPCbYXQa0knh9hnK1pX6fO-krHeT3RtfuFf5609A", "expires_in": 3600, "not-before-policy": 0, "scope": "openid profile email roles", "token_type": "Bearer" } ================================================ FILE: providers/dns/cloudru/internal/fixtures/record.json ================================================ { "zone_id": "59556fcd-95ff-451f-b49b-9732f21f944a", "name": "www.example.com.", "type": "TXT", "values": [ "txt" ], "ttl": "3600", "enables": true, "readonly": false } ================================================ FILE: providers/dns/cloudru/internal/fixtures/records.json ================================================ { "items": [ { "zone_id": "59556fcd-95ff-451f-b49b-9732f21f944a", "name": "example.com.", "type": "SOA", "values": [ "cdns-ns01.sbercloud.ru. mail.sbercloud.ru 1 120 3600 604800 3600" ], "ttl": "3600", "enables": true, "readonly": true }, { "zone_id": "59556fcd-95ff-451f-b49b-9732f21f944a", "name": "example.com.", "type": "NS", "values": [ "cdns-ns01.sbercloud.ru.", "cdns-ns02.sbercloud.ru." ], "ttl": "3600", "enables": true, "readonly": true }, { "zone_id": "59556fcd-95ff-451f-b49b-9732f21f944a", "name": "www.example.com.", "type": "A", "values": [ "8.8.8.8" ], "ttl": "3600", "enables": true, "readonly": false } ] } ================================================ FILE: providers/dns/cloudru/internal/fixtures/zones.json ================================================ { "items": [ { "id": "59556fcd-95ff-451f-b49b-9732f21f944a", "parent_id": "2d7b6194-2b83-4f71-86fd-a1e727e347b2", "name": "example.com", "valid": true, "validation_text": "sbc-verification: 5c86c962-7ee2-4983-b39b-1d9461959d8b", "delegated": true, "created_at": "2023-07-23T08:12:41.000000Z", "updated_at": "2023-07-24T05:50:28.000000Z" } ] } ================================================ FILE: providers/dns/cloudru/internal/identity.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) type token string const tokenKey token = "token" // obtainToken Logs into cloud.ru and acquires a bearer token for use in future API calls. // https://cloud.ru/ru/docs/clouddns/ug/topics/api-ref_authentication.html func (c *Client) obtainToken(ctx context.Context) (*Token, error) { data := make(url.Values) data.Set("grant_type", "access_key") data.Set("client_id", c.keyID) data.Set("client_secret", c.secret) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.AuthEndpoint.String(), strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } tok := Token{} err = json.Unmarshal(raw, &tok) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if !strings.EqualFold(tok.TokenType, "Bearer") { return nil, fmt.Errorf("received unexpected token type: %s", tok.TokenType) } tok.Deadline = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second) return &tok, nil } func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { c.muToken.Lock() defer c.muToken.Unlock() if c.token != nil && time.Now().Before(c.token.Deadline) { // Already authenticated, stop now return context.WithValue(ctx, tokenKey, c.token), nil } tok, err := c.obtainToken(ctx) if err != nil { return nil, err } return context.WithValue(ctx, tokenKey, tok), nil } func parseError(req *http.Request, resp *http.Response) error { if resp.StatusCode < 400 || resp.StatusCode > 499 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, _ := io.ReadAll(resp.Body) errResp := &authResponseError{} err := json.Unmarshal(raw, errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("%d: %w", resp.StatusCode, errResp) } func getToken(ctx context.Context) *Token { tok, ok := ctx.Value(tokenKey).(*Token) if !ok { return nil } return tok } ================================================ FILE: providers/dns/cloudru/internal/identity_test.go ================================================ package internal import ( "context" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockContext(t *testing.T) context.Context { t.Helper() return context.WithValue(t.Context(), tokenKey, &Token{AccessToken: "xxx"}) } func setupIdentityClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.AuthEndpoint, _ = url.Parse(server.URL) return client, nil } func TestClient_obtainToken(t *testing.T) { client := servermock.NewBuilder[*Client](setupIdentityClient, servermock.CheckHeader(). WithContentTypeFromURLEncoded(), ). Route("POST /", servermock.JSONEncode(Token{ AccessToken: "xxx", TokenID: "yyy", ExpiresIn: 666, TokenType: "Bearer", Scope: "openid profile email roles", }), servermock.CheckForm().Strict(). With("client_id", "user"). With("client_secret", "secret"). With("grant_type", "access_key"), ). Build(t) assert.Nil(t, client.token) tok, err := client.obtainToken(t.Context()) require.NoError(t, err) assert.NotNil(t, tok) assert.NotZero(t, tok.Deadline) assert.Equal(t, "xxx", tok.AccessToken) } func TestClient_CreateAuthenticatedContext(t *testing.T) { client := servermock.NewBuilder[*Client](setupIdentityClient, servermock.CheckHeader(). WithContentTypeFromURLEncoded(), ). Route("POST /", servermock.JSONEncode(Token{ AccessToken: "xxx", TokenID: "yyy", ExpiresIn: 666, TokenType: "Bearer", Scope: "openid profile email roles", }), servermock.CheckForm().Strict(). With("client_id", "user"). With("client_secret", "secret"). With("grant_type", "access_key"), ). Build(t) assert.Nil(t, client.token) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) tok := getToken(ctx) assert.NotNil(t, tok) assert.NotZero(t, tok.Deadline) assert.Equal(t, "xxx", tok.AccessToken) } ================================================ FILE: providers/dns/cloudru/internal/types.go ================================================ package internal import ( "fmt" "time" ) type Token struct { // The bearer token for use in API requests AccessToken string `json:"access_token"` TokenID string `json:"id_token"` TokenType string `json:"token_type"` // Number in seconds before the expiration ExpiresIn int `json:"expires_in"` NotBeforePolicy int `json:"not-before-policy"` Scope string `json:"scope"` Deadline time.Time `json:"-"` } type authResponseError struct { ErrorMsg string `json:"error"` ErrorDescription string `json:"error_description"` } func (a authResponseError) Error() string { return fmt.Sprintf("%s: %s", a.ErrorMsg, a.ErrorDescription) } type APIResponse[T any] struct { Items []T `json:"items"` } type Zone struct { ID string `json:"id,omitempty"` ParentID string `json:"parent_id,omitempty"` Name string `json:"name,omitempty"` Valid bool `json:"valid,omitempty"` ValidationText string `json:"validationText,omitempty"` Delegated bool `json:"delegated,omitempty"` LastCheck time.Time `json:"lastCheck,omitzero"` CreatedAt time.Time `json:"created_at,omitzero"` UpdatedAt time.Time `json:"updated_at,omitzero"` } type Record struct { ZoneID string `json:"zone_id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Values []string `json:"values,omitempty"` TTL string `json:"ttl,omitempty"` Enables bool `json:"enables,omitempty"` } ================================================ FILE: providers/dns/cloudxns/cloudxns.go ================================================ // Package cloudxns implements a DNS provider for solving the DNS-01 challenge using CloudXNS DNS. package cloudxns import ( "errors" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" ) // Environment variables names. const ( envNamespace = "CLOUDXNS_" EnvAPIKey = envNamespace + "API_KEY" EnvSecretKey = envNamespace + "SECRET_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string SecretKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{} } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct{} // NewDNSProvider returns a DNSProvider instance configured for CloudXNS. func NewDNSProvider() (*DNSProvider, error) { return NewDNSProviderConfig(&Config{}) } // NewDNSProviderConfig return a DNSProvider instance configured for CloudXNS. func NewDNSProviderConfig(_ *Config) (*DNSProvider, error) { return nil, errors.New("cloudxns: provider has shut down") } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(_, _, _ string) error { return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(_, _, _ string) error { return 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 dns01.DefaultPropagationTimeout, dns01.DefaultPollingInterval } ================================================ FILE: providers/dns/cloudxns/cloudxns.toml ================================================ Name = "CloudXNS (Deprecated)" Description = ''' The CloudXNS DNS provider has shut down. ''' URL = "https://github.com/go-acme/lego/issues/2323" Code = "cloudxns" Since = "v0.5.0" Example = ''' CLOUDXNS_API_KEY=xxxx \ CLOUDXNS_SECRET_KEY=yyyy \ lego --dns cloudxns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CLOUDXNS_API_KEY = "The API key" CLOUDXNS_SECRET_KEY = "The API secret key" [Configuration.Additional] CLOUDXNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: )" CLOUDXNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: )" CLOUDXNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: )" CLOUDXNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: )" ================================================ FILE: providers/dns/com35/com35.go ================================================ // Package com35 implements a DNS provider for solving the DNS-01 challenge using 35.com/三五互联. package com35 import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/westcn" ) // Environment variables names. const ( envNamespace = "COM35_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://api.35.cn/api/v2" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = westcn.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for 35.com/三五互联. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("35com: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for 35.com/三五互联. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("35com: the configuration of the DNS provider is nil") } provider, err := westcn.NewDNSProviderConfig(config, defaultBaseURL) if err != nil { return nil, fmt.Errorf("35com: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("35com: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("35com: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/com35/com35.toml ================================================ Name = "35.com/三五互联" Description = '''''' URL = "https://www.35.cn/" Code = "com35" Since = "v4.31.0" Example = ''' COM35_USERNAME="xxx" \ COM35_PASSWORD="yyy" \ lego --dns com35 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] COM35_USERNAME = "Username" COM35_PASSWORD = "API password" [Configuration.Additional] COM35_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" COM35_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" COM35_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" COM35_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.35.cn/CustomerCenter/doc/domain_v2.html" ================================================ FILE: providers/dns/com35/com35_test.go ================================================ package com35 import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", }, }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "secret", }, expected: "35com: some credentials information are missing: COM35_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "", }, expected: "35com: some credentials information are missing: COM35_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "35com: some credentials information are missing: COM35_USERNAME,COM35_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "user", password: "secret", }, { desc: "missing username", password: "secret", expected: "35com: credentials missing", }, { desc: "missing password", username: "user", expected: "35com: credentials missing", }, { desc: "missing credentials", expected: "35com: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/conoha/conoha.go ================================================ // Package conoha implements a DNS provider for solving the DNS-01 challenge using ConoHa DNS. package conoha import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/conoha/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CONOHA_" EnvRegion = envNamespace + "REGION" EnvTenantID = envNamespace + "TENANT_ID" EnvAPIUsername = envNamespace + "API_USERNAME" EnvAPIPassword = envNamespace + "API_PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Region string TenantID string Username string Password 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{ Region: env.GetOrDefaultString(EnvRegion, "tyo1"), TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for ConoHa DNS. // Credentials must be passed in the environment variables: // CONOHA_TENANT_ID, CONOHA_API_USERNAME, CONOHA_API_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvTenantID, EnvAPIUsername, EnvAPIPassword) if err != nil { return nil, fmt.Errorf("conoha: %w", err) } config := NewDefaultConfig() config.TenantID = values[EnvTenantID] config.Username = values[EnvAPIUsername] config.Password = values[EnvAPIPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ConoHa DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("conoha: the configuration of the DNS provider is nil") } if config.TenantID == "" || config.Username == "" || config.Password == "" { return nil, errors.New("conoha: some credentials information are missing") } identifier, err := internal.NewIdentifier(config.Region) if err != nil { return nil, fmt.Errorf("conoha: failed to create identity client: %w", err) } if config.HTTPClient != nil { identifier.HTTPClient = config.HTTPClient } identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient) auth := internal.Auth{ TenantID: config.TenantID, PasswordCredentials: internal.PasswordCredentials{ Username: config.Username, Password: config.Password, }, } tokens, err := identifier.GetToken(context.TODO(), auth) if err != nil { return nil, fmt.Errorf("conoha: failed to log in: %w", err) } client, err := internal.NewClient(config.Region, tokens.Access.Token.ID) if err != nil { return nil, fmt.Errorf("conoha: failed to create client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("conoha: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() id, err := d.client.GetDomainID(ctx, authZone) if err != nil { return fmt.Errorf("conoha: failed to get domain ID: %w", err) } record := internal.Record{ Name: info.EffectiveFQDN, Type: "TXT", Data: info.Value, TTL: d.config.TTL, } err = d.client.CreateRecord(ctx, id, record) if err != nil { return fmt.Errorf("conoha: failed to create record: %w", err) } return nil } // CleanUp clears ConoHa DNS TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("conoha: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() domID, err := d.client.GetDomainID(ctx, authZone) if err != nil { return fmt.Errorf("conoha: failed to get domain ID: %w", err) } recID, err := d.client.GetRecordID(ctx, domID, info.EffectiveFQDN, "TXT", info.Value) if err != nil { return fmt.Errorf("conoha: failed to get record ID: %w", err) } err = d.client.DeleteRecord(ctx, domID, recID) if err != nil { return fmt.Errorf("conoha: failed to delete record: %w", err) } return 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 } ================================================ FILE: providers/dns/conoha/conoha.toml ================================================ Name = "ConoHa v2" Description = '''''' URL = "https://www.conoha.jp/" Code = "conoha" Since = "v1.2.0" Example = ''' CONOHA_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \ CONOHA_API_USERNAME=xxxx \ CONOHA_API_PASSWORD=yyyy \ lego --dns conoha -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CONOHA_TENANT_ID = "Tenant ID" CONOHA_API_USERNAME = "The API username" CONOHA_API_PASSWORD = "The API password" [Configuration.Additional] CONOHA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" CONOHA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" CONOHA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" CONOHA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" CONOHA_REGION = "The region (Default: tyo1)" [Links] API = "https://doc.conoha.jp/reference/api-vps2/api-dns-vps2" ================================================ FILE: providers/dns/conoha/conoha_test.go ================================================ package conoha import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvTenantID, EnvAPIUsername, EnvAPIPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "complete credentials, but login failed", envVars: map[string]string{ EnvTenantID: "tenant_id", EnvAPIUsername: "api_username", EnvAPIPassword: "api_password", }, expected: `conoha: failed to log in: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, }, { desc: "missing credentials", envVars: map[string]string{ EnvTenantID: "", EnvAPIUsername: "", EnvAPIPassword: "", }, expected: "conoha: some credentials information are missing: CONOHA_TENANT_ID,CONOHA_API_USERNAME,CONOHA_API_PASSWORD", }, { desc: "missing tenant id", envVars: map[string]string{ EnvTenantID: "", EnvAPIUsername: "api_username", EnvAPIPassword: "api_password", }, expected: "conoha: some credentials information are missing: CONOHA_TENANT_ID", }, { desc: "missing api username", envVars: map[string]string{ EnvTenantID: "tenant_id", EnvAPIUsername: "", EnvAPIPassword: "api_password", }, expected: "conoha: some credentials information are missing: CONOHA_API_USERNAME", }, { desc: "missing api password", envVars: map[string]string{ EnvTenantID: "tenant_id", EnvAPIUsername: "api_username", EnvAPIPassword: "", }, expected: "conoha: some credentials information are missing: CONOHA_API_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string tenant string username string password string }{ { desc: "complete credentials, but login failed", expected: `conoha: failed to log in: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`, tenant: "tenant_id", username: "api_username", password: "api_password", }, { desc: "missing credentials", expected: "conoha: some credentials information are missing", }, { desc: "missing tenant id", expected: "conoha: some credentials information are missing", username: "api_username", password: "api_password", }, { desc: "missing api username", expected: "conoha: some credentials information are missing", tenant: "tenant_id", password: "api_password", }, { desc: "missing api password", expected: "conoha: some credentials information are missing", tenant: "tenant_id", username: "api_username", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.TenantID = test.tenant config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/conoha/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const dnsServiceBaseURL = "https://dns-service.%s.conoha.io" // Client is a ConoHa API client. type Client struct { token string baseURL *url.URL HTTPClient *http.Client } // NewClient returns a client instance logged into the ConoHa service. func NewClient(region, token string) (*Client, error) { baseURL, err := url.Parse(fmt.Sprintf(dnsServiceBaseURL, region)) if err != nil { return nil, err } return &Client{ token: token, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, }, nil } // GetDomainID returns an ID of specified domain. func (c *Client) GetDomainID(ctx context.Context, domainName string) (string, error) { domainList, err := c.getDomains(ctx) if err != nil { return "", err } for _, domain := range domainList.Domains { if domain.Name == domainName { return domain.ID, nil } } return "", fmt.Errorf("no such domain: %s", domainName) } // https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-list-domains-v2/?btn_id=reference-api-vps2--sidebar_reference-paas-dns-list-domains-v2 func (c *Client) getDomains(ctx context.Context) (*DomainListResponse, error) { endpoint := c.baseURL.JoinPath("v1", "domains") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } domainList := &DomainListResponse{} err = c.do(req, domainList) if err != nil { return nil, err } return domainList, nil } // GetRecordID returns an ID of specified record. func (c *Client) GetRecordID(ctx context.Context, domainID, recordName, recordType, data string) (string, error) { recordList, err := c.getRecords(ctx, domainID) if err != nil { return "", err } for _, record := range recordList.Records { if record.Name == recordName && record.Type == recordType && record.Data == data { return record.ID, nil } } return "", errors.New("no such record") } // https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-list-records-in-a-domain-v2/?btn_id=reference-paas-dns-list-domains-v2--sidebar_reference-paas-dns-list-records-in-a-domain-v2 func (c *Client) getRecords(ctx context.Context, domainID string) (*RecordListResponse, error) { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } recordList := &RecordListResponse{} err = c.do(req, recordList) if err != nil { return nil, err } return recordList, nil } // CreateRecord adds new record. func (c *Client) CreateRecord(ctx context.Context, domainID string, record Record) error { _, err := c.createRecord(ctx, domainID, record) return err } // https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-create-record-v2/?btn_id=reference-paas-dns-list-records-in-a-domain-v2--sidebar_reference-paas-dns-create-record-v2 func (c *Client) createRecord(ctx context.Context, domainID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } newRecord := &Record{} err = c.do(req, newRecord) if err != nil { return nil, err } return newRecord, nil } // DeleteRecord removes specified record. // https://doc.conoha.jp/reference/api-vps2/api-dns-vps2/paas-dns-delete-a-record-v2/?btn_id=reference-paas-dns-create-record-v2--sidebar_reference-paas-dns-delete-a-record-v2 func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID string) error { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { if c.token != "" { req.Header.Set("X-Auth-Token", c.token) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/conoha/internal/client_test.go ================================================ package internal import ( "bytes" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("tyo1", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With("X-Auth-Token", "secret")) } func TestClient_GetDomainID(t *testing.T) { type expected struct { domainID string error bool } testCases := []struct { desc string domainName string response string expected expected }{ { desc: "success", domainName: "domain1.com.", response: "domains_GET.json", expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"}, }, { desc: "non existing domain", domainName: "domain1.com.", response: "empty.json", expected: expected{error: true}, }, { desc: "marshaling error", domainName: "domain1.com.", response: "empty.json", expected: expected{error: true}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains", servermock.ResponseFromFixture(test.response)). Build(t) domainID, err := client.GetDomainID(t.Context(), test.domainName) if test.expected.error { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, test.expected.domainID, domainID) } }) } } func TestClient_CreateRecord(t *testing.T) { testCases := []struct { desc string handler http.HandlerFunc assert require.ErrorAssertionFunc }{ { desc: "success", handler: func(rw http.ResponseWriter, req *http.Request) { raw, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } defer func() { _ = req.Body.Close() }() if string(bytes.TrimSpace(raw)) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` { http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) return } file, err := os.Open(filepath.Join("fixtures", "domains-records_POST.json")) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } defer func() { _ = file.Close() }() _, _ = io.Copy(rw, file) }, assert: require.NoError, }, { desc: "bad request", handler: func(rw http.ResponseWriter, req *http.Request) { http.Error(rw, "OOPS", http.StatusBadRequest) }, assert: require.Error, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(). Route("POST /v1/domains/lego/records", test.handler). Build(t) domainID := "lego" record := Record{ Name: "lego.com.", Type: "TXT", Data: "txtTXTtxt", TTL: 300, } err := client.CreateRecord(t.Context(), domainID, record) test.assert(t, err) }) } } func TestClient_GetRecordID(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", servermock.ResponseFromFixture("domains-records_GET.json")). Build(t) recordID, err := client.GetRecordID(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153") require.NoError(t, err) assert.Equal(t, "2e32e609-3a4f-45ba-bdef-e50eacd345ad", recordID) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", servermock.ResponseFromFixture("domains-records_GET.json")). Build(t) err := client.DeleteRecord(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad") require.NoError(t, err) } ================================================ FILE: providers/dns/conoha/internal/fixtures/domains-records_GET.json ================================================ { "records": [ { "id": "2e32e609-3a4f-45ba-bdef-e50eacd345ad", "name": "www.example.com.", "type": "A", "ttl": 3600, "created_at": "2012-11-02T19:56:26.000000", "updated_at": "2012-11-04T13:22:36.000000", "data": "15.185.172.153", "domain_id": "89acac79-38e7-497d-807c-a011e1310438", "version": 1, "gslb_region": "JP", "gslb_weight": 250, "gslb_check": 12300 }, { "id": "8e9ecf3e-fb92-4a3a-a8ae-7596f167bea3", "name": "host1.example.com.", "type": "A", "ttl": 3600, "created_at": "2012-11-04T13:57:50.000000", "updated_at": null, "data": "15.185.172.154", "domain_id": "89acac79-38e7-497d-807c-a011e1310438", "version": 1, "gslb_region": "US", "gslb_weight": 220, "gslb_check": 12200 }, { "id": "4ad19089-3e62-40f8-9482-17cc8ccb92cb", "name": "web.example.com.", "type": "CNAME", "ttl": 3600, "created_at": "2012-11-04T13:58:16.393735", "updated_at": null, "data": "www.example.com.", "domain_id": "89acac79-38e7-497d-807c-a011e1310438", "version": 1 } ] } ================================================ FILE: providers/dns/conoha/internal/fixtures/domains-records_POST.json ================================================ { "id": "2e32e609-3a4f-45ba-bdef-e50eacd345ad", "name": "www.example.com.", "type": "A", "created_at": "2012-11-02T19:56:26.366792", "updated_at": null, "domain_id": "89acac79-38e7-497d-807c-a011e1310438", "ttl": null, "data": "192.0.2.3", "gslb_check": 1, "gslb_region": "JP", "gslb_weight": 250 } ================================================ FILE: providers/dns/conoha/internal/fixtures/domains_GET.json ================================================ { "domains":[ { "id": "09494b72-b65b-4297-9efb-187f65a0553e", "name": "domain1.com.", "ttl": 3600, "serial": 1351800668, "email": "nsadmin@example.org", "gslb": 0, "created_at": "2012-11-01T20:11:08.000000", "updated_at": null, "description": "memo" }, { "id": "cf661142-e577-40b5-b3eb-75795cdc0cd7", "name": "domain2.com.", "ttl": 7200, "serial": 1351800670, "email": "nsadmin2@example.org", "gslb": 1, "created_at": "2012-11-01T20:11:08.000000", "updated_at": "2012-12-01T20:11:08.000000", "description": "memomemo" } ] } ================================================ FILE: providers/dns/conoha/internal/fixtures/empty.json ================================================ {} ================================================ FILE: providers/dns/conoha/internal/fixtures/tokens_POST.json ================================================ { "access": { "token": { "issued_at": "2015-05-19T07:08:21.927295", "expires": "2015-05-20T07:08:21Z", "id": "sample00d88246078f2bexample788f7", "tenant": { "name": "example00000000", "enabled": true, "tyo1_image_size": "550GB" }, "endpoints_links": [], "type": "mailhosting", "name": "Mail Hosting Service" } } } ================================================ FILE: providers/dns/conoha/internal/identity.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const identityBaseURL = "https://identity.%s.conoha.io" type Identifier struct { baseURL *url.URL HTTPClient *http.Client } // NewIdentifier creates a new Identifier. func NewIdentifier(region string) (*Identifier, error) { baseURL, err := url.Parse(fmt.Sprintf(identityBaseURL, region)) if err != nil { return nil, err } return &Identifier{ baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, }, nil } // GetToken gets valid token information. // https://doc.conoha.jp/reference/api-vps2/api-identity-vps2/identity-post_tokens-v2/?btn_id=reference-paas-dns-delete-a-record-v2--sidebar_reference-identity-post_tokens-v2 func (c *Identifier) GetToken(ctx context.Context, auth Auth) (*IdentityResponse, error) { endpoint := c.baseURL.JoinPath("v2.0", "tokens") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, &IdentityRequest{Auth: auth}) if err != nil { return nil, err } identity := &IdentityResponse{} err = c.do(req, identity) if err != nil { return nil, err } return identity, nil } func (c *Identifier) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } ================================================ FILE: providers/dns/conoha/internal/identity_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupIdentifier(server *httptest.Server) (*Identifier, error) { identifier, err := NewIdentifier("tyo1") if err != nil { return nil, err } identifier.HTTPClient = server.Client() identifier.baseURL, _ = url.Parse(server.URL) return identifier, nil } func TestNewClient(t *testing.T) { identifier := servermock.NewBuilder[*Identifier](setupIdentifier, servermock.CheckHeader().WithJSONHeaders(), ). Route("POST /v2.0/tokens", servermock.ResponseFromFixture("tokens_POST.json")). Build(t) auth := Auth{ TenantID: "487727e3921d44e3bfe7ebb337bf085e", PasswordCredentials: PasswordCredentials{ Username: "ConoHa", Password: "paSSword123456#$%", }, } token, err := identifier.GetToken(t.Context(), auth) require.NoError(t, err) expected := &IdentityResponse{Access: Access{Token: Token{ID: "sample00d88246078f2bexample788f7"}}} assert.Equal(t, expected, token) } ================================================ FILE: providers/dns/conoha/internal/types.go ================================================ package internal // IdentityRequest is an authentication request body. type IdentityRequest struct { Auth Auth `json:"auth"` } // Auth is an authentication information. type Auth struct { TenantID string `json:"tenantId"` PasswordCredentials PasswordCredentials `json:"passwordCredentials"` } // PasswordCredentials is API-user's credentials. type PasswordCredentials struct { Username string `json:"username"` Password string `json:"password"` } // IdentityResponse is an authentication response body. type IdentityResponse struct { Access Access `json:"access"` } // Access is an identity information. type Access struct { Token Token `json:"token"` } // Token is an api access token. type Token struct { ID string `json:"id"` } // DomainListResponse is a response of a domain listing request. type DomainListResponse struct { Domains []Domain `json:"domains"` } // Domain is a hosted domain entry. type Domain struct { ID string `json:"id"` Name string `json:"name"` } // RecordListResponse is a response of record listing request. type RecordListResponse struct { Records []Record `json:"records"` } // Record is a record entry. type Record struct { ID string `json:"id,omitempty"` Name string `json:"name"` Type string `json:"type"` Data string `json:"data"` TTL int `json:"ttl"` } ================================================ FILE: providers/dns/conohav3/conohav3.go ================================================ // Package conohav3 implements a DNS provider for solving the DNS-01 challenge using ConoHa VPS Ver 3.0 DNS. package conohav3 import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/conohav3/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CONOHAV3_" EnvRegion = envNamespace + "REGION" EnvTenantID = envNamespace + "TENANT_ID" EnvAPIUserID = envNamespace + "API_USER_ID" EnvAPIPassword = envNamespace + "API_PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Region string TenantID string UserID string Password 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{ Region: env.GetOrDefaultString(EnvRegion, "c3j1"), TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for ConoHa DNS. // Credentials must be passed in the environment variables: // CONOHAV3_TENANT_ID, CONOHAV3_API_USER_ID, CONOHAV3_API_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvTenantID, EnvAPIUserID, EnvAPIPassword) if err != nil { return nil, fmt.Errorf("conohav3: %w", err) } config := NewDefaultConfig() config.TenantID = values[EnvTenantID] config.UserID = values[EnvAPIUserID] config.Password = values[EnvAPIPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ConoHa DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("conohav3: the configuration of the DNS provider is nil") } if config.TenantID == "" || config.UserID == "" || config.Password == "" { return nil, errors.New("conohav3: some credentials information are missing") } identifier, err := internal.NewIdentifier(config.Region) if err != nil { return nil, fmt.Errorf("conohav3: failed to create identity client: %w", err) } if config.HTTPClient != nil { identifier.HTTPClient = config.HTTPClient } identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient) auth := internal.Auth{ Identity: internal.Identity{ Methods: []string{"password"}, Password: internal.Password{ User: internal.User{ ID: config.UserID, Password: config.Password, }, }, }, Scope: internal.Scope{ Project: internal.Project{ ID: config.TenantID, }, }, } token, err := identifier.GetToken(context.Background(), auth) if err != nil { return nil, fmt.Errorf("conohav3: failed to log in: %w", err) } client, err := internal.NewClient(config.Region, token) if err != nil { return nil, fmt.Errorf("conohav3: failed to create client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("conohav3: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() id, err := d.client.GetDomainID(ctx, authZone) if err != nil { return fmt.Errorf("conohav3: failed to get domain ID: %w", err) } record := internal.Record{ Name: info.EffectiveFQDN, Type: "TXT", Data: info.Value, TTL: d.config.TTL, } err = d.client.CreateRecord(ctx, id, record) if err != nil { return fmt.Errorf("conohav3: failed to create record: %w", err) } return nil } // CleanUp clears ConoHa DNS TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("conohav3: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() domID, err := d.client.GetDomainID(ctx, authZone) if err != nil { return fmt.Errorf("conohav3: failed to get domain ID: %w", err) } recID, err := d.client.GetRecordID(ctx, domID, info.EffectiveFQDN, "TXT", info.Value) if err != nil { return fmt.Errorf("conohav3: failed to get record ID: %w", err) } err = d.client.DeleteRecord(ctx, domID, recID) if err != nil { return fmt.Errorf("conohav3: failed to delete record: %w", err) } return nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } ================================================ FILE: providers/dns/conohav3/conohav3.toml ================================================ Name = "ConoHa v3" Description = '''''' URL = "https://www.conoha.jp/" Code = "conohav3" Since = "v4.24.0" Example = ''' CONOHAV3_TENANT_ID=487727e3921d44e3bfe7ebb337bf085e \ CONOHAV3_API_USER_ID=xxxx \ CONOHAV3_API_PASSWORD=yyyy \ lego --dns conohav3 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CONOHAV3_TENANT_ID = "Tenant ID" CONOHAV3_API_USER_ID = "The API user ID" CONOHAV3_API_PASSWORD = "The API password" [Configuration.Additional] CONOHAV3_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" CONOHAV3_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" CONOHAV3_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" CONOHAV3_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" CONOHAV3_REGION = "The region (Default: c3j1)" [Links] API = "https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/" ================================================ FILE: providers/dns/conohav3/conohav3_test.go ================================================ package conohav3 import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvTenantID, EnvAPIUserID, EnvAPIPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "complete credentials, but login failed", envVars: map[string]string{ EnvTenantID: "tenant_id", EnvAPIUserID: "api_user_id", EnvAPIPassword: "api_password", }, expected: `conohav3: failed to log in: unexpected status code: [status code: 400] body: {"code": 400, "error": "user does not exist"}`, }, { desc: "missing credentials", envVars: map[string]string{ EnvTenantID: "", EnvAPIUserID: "", EnvAPIPassword: "", }, expected: "conohav3: some credentials information are missing: CONOHAV3_TENANT_ID,CONOHAV3_API_USER_ID,CONOHAV3_API_PASSWORD", }, { desc: "missing tenant id", envVars: map[string]string{ EnvTenantID: "", EnvAPIUserID: "api_user_id", EnvAPIPassword: "api_password", }, expected: "conohav3: some credentials information are missing: CONOHAV3_TENANT_ID", }, { desc: "missing api user id", envVars: map[string]string{ EnvTenantID: "tenant_id", EnvAPIUserID: "", EnvAPIPassword: "api_password", }, expected: "conohav3: some credentials information are missing: CONOHAV3_API_USER_ID", }, { desc: "missing api password", envVars: map[string]string{ EnvTenantID: "tenant_id", EnvAPIUserID: "api_user_id", EnvAPIPassword: "", }, expected: "conohav3: some credentials information are missing: CONOHAV3_API_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string tenant string userid string password string }{ { desc: "complete credentials, but login failed", expected: `conohav3: failed to log in: unexpected status code: [status code: 400] body: {"code": 400, "error": "user does not exist"}`, tenant: "tenant_id", userid: "api_user_id", password: "api_password", }, { desc: "missing credentials", expected: "conohav3: some credentials information are missing", }, { desc: "missing tenant id", expected: "conohav3: some credentials information are missing", userid: "api_user_id", password: "api_password", }, { desc: "missing api user id", expected: "conohav3: some credentials information are missing", tenant: "tenant_id", password: "api_password", }, { desc: "missing api password", expected: "conohav3: some credentials information are missing", tenant: "tenant_id", userid: "api_user_id", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.TenantID = test.tenant config.UserID = test.userid config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/conohav3/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const dnsServiceBaseURL = "https://dns-service.%s.conoha.io" // Client is a ConoHa API client. type Client struct { token string baseURL *url.URL HTTPClient *http.Client } // NewClient returns a client instance logged into the ConoHa service. func NewClient(region, token string) (*Client, error) { baseURL, err := url.Parse(fmt.Sprintf(dnsServiceBaseURL, region)) if err != nil { return nil, err } return &Client{ token: token, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, }, nil } // GetDomainID returns an ID of specified domain. func (c *Client) GetDomainID(ctx context.Context, domainName string) (string, error) { domainList, err := c.getDomains(ctx) if err != nil { return "", err } for _, domain := range domainList.Domains { if domain.Name == domainName { return domain.UUID, nil } } return "", fmt.Errorf("no such domain: %s", domainName) } // https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-get_domains_list-v3/?btn_id=reference-api-vps3--sidebar_reference-dnsaas-get_domains_list-v3 func (c *Client) getDomains(ctx context.Context) (*DomainListResponse, error) { endpoint := c.baseURL.JoinPath("v1", "domains") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } domainList := &DomainListResponse{} err = c.do(req, domainList) if err != nil { return nil, err } return domainList, nil } // GetRecordID returns an ID of specified record. func (c *Client) GetRecordID(ctx context.Context, domainID, recordName, recordType, data string) (string, error) { recordList, err := c.getRecords(ctx, domainID) if err != nil { return "", err } for _, record := range recordList.Records { if record.Name == recordName && record.Type == recordType && record.Data == data { return record.UUID, nil } } return "", errors.New("no such record") } // https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-get_records_list-v3/?btn_id=reference-dnsaas-get_domains_list-v3--sidebar_reference-dnsaas-get_records_list-v3 func (c *Client) getRecords(ctx context.Context, domainID string) (*RecordListResponse, error) { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } recordList := &RecordListResponse{} err = c.do(req, recordList) if err != nil { return nil, err } return recordList, nil } // CreateRecord adds new record. func (c *Client) CreateRecord(ctx context.Context, domainID string, record Record) error { _, err := c.createRecord(ctx, domainID, record) return err } // https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-create_record-v3/?btn_id=reference-dnsaas-get_records_list-v3--sidebar_reference-dnsaas-create_record-v3 func (c *Client) createRecord(ctx context.Context, domainID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } newRecord := &Record{} err = c.do(req, newRecord) if err != nil { return nil, err } return newRecord, nil } // DeleteRecord removes specified record. // https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-delete_record-v3/?btn_id=reference-dnsaas-create_record-v3--sidebar_reference-dnsaas-delete_record-v3 func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID string) error { endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { if c.token != "" { req.Header.Set("X-Auth-Token", c.token) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/conohav3/internal/client_test.go ================================================ package internal import ( "bytes" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("c3j1", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("X-Auth-Token", "secret")) } func TestClient_GetDomainID(t *testing.T) { type expected struct { domainID string error bool } testCases := []struct { desc string domainName string response string expected expected }{ { desc: "success", domainName: "domain1.com.", response: "domains_GET.json", expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"}, }, { desc: "non existing domain", domainName: "domain1.com.", response: "empty.json", expected: expected{error: true}, }, { desc: "marshaling error", domainName: "domain1.com.", response: "empty.json", expected: expected{error: true}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains", servermock.ResponseFromFixture(test.response)). Build(t) domainID, err := client.GetDomainID(t.Context(), test.domainName) if test.expected.error { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, test.expected.domainID, domainID) } }) } } func TestClient_CreateRecord(t *testing.T) { testCases := []struct { desc string handler http.HandlerFunc assert require.ErrorAssertionFunc }{ { desc: "success", handler: func(rw http.ResponseWriter, req *http.Request) { raw, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } defer func() { _ = req.Body.Close() }() if string(bytes.TrimSpace(raw)) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` { http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) return } file, err := os.Open(filepath.Join("fixtures", "domains-records_POST.json")) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } defer func() { _ = file.Close() }() _, _ = io.Copy(rw, file) }, assert: require.NoError, }, { desc: "bad request", handler: func(rw http.ResponseWriter, req *http.Request) { http.Error(rw, "OOPS", http.StatusBadRequest) }, assert: require.Error, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(). Route("POST /v1/domains/lego/records", test.handler). Build(t) domainID := "lego" record := Record{ Name: "lego.com.", Type: "TXT", Data: "txtTXTtxt", TTL: 300, } err := client.CreateRecord(t.Context(), domainID, record) test.assert(t, err) }) } } func TestClient_GetRecordID(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", servermock.ResponseFromFixture("domains-records_GET.json")). Build(t) recordID, err := client.GetRecordID(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153") require.NoError(t, err) assert.Equal(t, "2e32e609-3a4f-45ba-bdef-e50eacd345ad", recordID) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", servermock.ResponseFromFixture("domains-records_GET.json")). Build(t) err := client.DeleteRecord(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad") require.NoError(t, err) } ================================================ FILE: providers/dns/conohav3/internal/fixtures/domains-records_GET.json ================================================ { "records": [ { "uuid": "2e32e609-3a4f-45ba-bdef-e50eacd345ad", "name": "www.example.com.", "type": "A", "ttl": 3600, "created_at": "2012-11-02T19:56:26.000000", "updated_at": "2012-11-04T13:22:36.000000", "data": "15.185.172.153", "domain_id": "89acac79-38e7-497d-807c-a011e1310438", "version": 1, "gslb_region": "JP", "gslb_weight": 250, "gslb_check": 12300 }, { "uuid": "8e9ecf3e-fb92-4a3a-a8ae-7596f167bea3", "name": "host1.example.com.", "type": "A", "ttl": 3600, "created_at": "2012-11-04T13:57:50.000000", "updated_at": null, "data": "15.185.172.154", "domain_id": "89acac79-38e7-497d-807c-a011e1310438", "version": 1, "gslb_region": "US", "gslb_weight": 220, "gslb_check": 12200 }, { "uuid": "4ad19089-3e62-40f8-9482-17cc8ccb92cb", "name": "web.example.com.", "type": "CNAME", "ttl": 3600, "created_at": "2012-11-04T13:58:16.393735", "updated_at": null, "data": "www.example.com.", "domain_id": "89acac79-38e7-497d-807c-a011e1310438", "version": 1 } ] } ================================================ FILE: providers/dns/conohav3/internal/fixtures/domains-records_POST.json ================================================ { "uuid": "2e32e609-3a4f-45ba-bdef-e50eacd345ad", "name": "www.example.com.", "type": "A", "created_at": "2012-11-02T19:56:26.366792", "updated_at": null, "domain_id": "89acac79-38e7-497d-807c-a011e1310438", "ttl": null, "data": "192.0.2.3", "gslb_check": 1, "gslb_region": "JP", "gslb_weight": 250 } ================================================ FILE: providers/dns/conohav3/internal/fixtures/domains_GET.json ================================================ { "domains": [ { "uuid": "09494b72-b65b-4297-9efb-187f65a0553e", "name": "domain1.com.", "project_id": "cf661142-e577-40b5-b3eb-75795cdc0cd7", "serial": 1701909248, "ttl": 3600, "email": "nsadmin1@example.org", "created_at": "2023-12-07T00:34:08Z", "updated_at": "2023-12-07T00:34:08Z" }, { "uuid": "cf661142-e577-40b5-b3eb-75795cdc0cd7", "name": "domain2.com.", "project_id": "cf661144-e578-39b6-b4eb-75794cdc1cd8", "serial": 1351800670, "ttl": 7200, "email": "nsadmin2@example.org", "created_at": "2012-11-01T20:11:08Z", "updated_at": "2012-12-01T20:11:08Z" } ], "total_count": 1 } ================================================ FILE: providers/dns/conohav3/internal/fixtures/empty.json ================================================ {} ================================================ FILE: providers/dns/conohav3/internal/identity.go ================================================ // internal/identity.go package internal import ( "context" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const identityBaseURL = "https://identity.%s.conoha.io" type Identifier struct { baseURL *url.URL HTTPClient *http.Client } // NewIdentifier creates a new Identifier. func NewIdentifier(region string) (*Identifier, error) { baseURL, err := url.Parse(fmt.Sprintf(identityBaseURL, region)) if err != nil { return nil, err } return &Identifier{ baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, }, nil } // GetToken returns the x-subject-token from Identity API. // https://doc.conoha.jp/reference/api-vps3/api-identity-vps3/identity-post_tokens-v3/?btn_id=reference-api-guideline-v3--sidebar_reference-identity-post_tokens-v3 func (c *Identifier) GetToken(ctx context.Context, auth Auth) (string, error) { endpoint := c.baseURL.JoinPath("v3", "auth", "tokens") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, &IdentityRequest{Auth: auth}) if err != nil { return "", err } return c.do(req) } // do sends the request and returns the token from x-subject-token header. func (c *Identifier) do(req *http.Request) (string, error) { resp, err := c.HTTPClient.Do(req) if err != nil { return "", errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp) } token := resp.Header.Get("x-subject-token") if token == "" { return "", errors.New("x-subject-token header is missing in response") } _, _ = io.Copy(io.Discard, resp.Body) return token, nil } ================================================ FILE: providers/dns/conohav3/internal/identity_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupIdentifier(server *httptest.Server) (*Identifier, error) { identifier, err := NewIdentifier("c3j1") if err != nil { return nil, err } identifier.HTTPClient = server.Client() identifier.baseURL, _ = url.Parse(server.URL) return identifier, nil } func TestGetToken_HeaderToken(t *testing.T) { identifier := servermock.NewBuilder[*Identifier](setupIdentifier, servermock.CheckHeader().WithJSONHeaders(), ). Route("POST /v3/auth/tokens", servermock.ResponseFromFixture("empty.json"). WithStatusCode(http.StatusCreated). WithHeader("x-subject-token", "sample-header-token-123")). Build(t) auth := Auth{ Identity: Identity{ Methods: []string{"password"}, Password: Password{ User: User{ ID: "dummy-id", Password: "dummy-password", }, }, }, Scope: Scope{ Project: Project{ ID: "dummy-project-id", }, }, } token, err := identifier.GetToken(t.Context(), auth) require.NoError(t, err) assert.Equal(t, "sample-header-token-123", token) } ================================================ FILE: providers/dns/conohav3/internal/types.go ================================================ package internal // IdentityRequest is the top-level payload sent to the Identity v3. type IdentityRequest struct { Auth Auth `json:"auth"` } // Auth authentication credentials (Identity) and scope (Scope). type Auth struct { Identity Identity `json:"identity"` Scope Scope `json:"scope"` } // Identity describes how the client will authenticate. // In ConoHa v3.0, only support the "password" method. type Identity struct { Methods []string `json:"methods"` Password Password `json:"password"` } // Password nests the concrete user credentials used by the password auth method. type Password struct { User User `json:"user"` } // User holds the API User ID and password that will be verified by the Identity service. type User struct { ID string `json:"id"` Password string `json:"password"` } // Scope specifies which tenant the issued token should be scoped to. type Scope struct { Project Project `json:"project"` } // Project identifies the target tenant by UUID. type Project struct { ID string `json:"id"` } // DomainListResponse is returned by `GET /v1/domains` and contains all DNS zones (domains) owned by the project. type DomainListResponse struct { Domains []Domain `json:"domains"` } // Domain represents a single hosted DNS zone. type Domain struct { UUID string `json:"uuid"` Name string `json:"name"` } // RecordListResponse is returned by `GET /v1/domains/{domain_uuid}/records` and lists every record in the zone. type RecordListResponse struct { Records []Record `json:"records"` } // Record represents a DNS record inside a zone. type Record struct { UUID string `json:"uuid,omitempty"` Name string `json:"name"` Type string `json:"type"` Data string `json:"data"` TTL int `json:"ttl"` } ================================================ FILE: providers/dns/constellix/constellix.go ================================================ // Package constellix implements a DNS provider for solving the DNS-01 challenge using Constellix DNS. package constellix import ( "context" "errors" "fmt" "net/http" "slices" "strconv" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/constellix/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/hashicorp/go-retryablehttp" ) // Environment variables names. const ( envNamespace = "CONSTELLIX_" EnvAPIKey = envNamespace + "API_KEY" EnvSecretKey = envNamespace + "SECRET_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string SecretKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Constellix. // Credentials must be passed in the environment variables: // CONSTELLIX_API_KEY and CONSTELLIX_SECRET_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvSecretKey) if err != nil { return nil, fmt.Errorf("constellix: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.SecretKey = values[EnvSecretKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Constellix. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("constellix: the configuration of the DNS provider is nil") } if config.SecretKey == "" || config.APIKey == "" { return nil, errors.New("constellix: incomplete credentials, missing secret key and/or API key") } tr, err := internal.NewTokenTransport(config.APIKey, config.SecretKey) if err != nil { return nil, fmt.Errorf("constellix: %w", err) } retryClient := retryablehttp.NewClient() retryClient.RetryMax = 5 retryClient.HTTPClient = tr.Wrap(config.HTTPClient) retryClient.Backoff = backoff client := internal.NewClient(clientdebug.Wrap(retryClient.StandardClient())) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("constellix: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() dom, err := d.client.Domains.GetByName(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("constellix: failed to get domain (%s): %w", authZone, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("constellix: %w", err) } records, err := d.client.TxtRecords.Search(ctx, dom.ID, internal.Exact, recordName) if err != nil { return fmt.Errorf("constellix: failed to search TXT records: %w", err) } if len(records) > 1 { return errors.New("constellix: failed to get TXT records") } // TXT record entry already existing if len(records) == 1 { return d.appendRecordValue(ctx, dom, records[0].ID, info.Value) } err = d.createRecord(ctx, dom, info.EffectiveFQDN, recordName, info.Value) if err != nil { return fmt.Errorf("constellix: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("constellix: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() dom, err := d.client.Domains.GetByName(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("constellix: failed to get domain (%s): %w", authZone, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("constellix: %w", err) } records, err := d.client.TxtRecords.Search(ctx, dom.ID, internal.Exact, recordName) if err != nil { return fmt.Errorf("constellix: failed to search TXT records: %w", err) } if len(records) > 1 { return errors.New("constellix: failed to get TXT records") } if len(records) == 0 { return nil } record, err := d.client.TxtRecords.Get(ctx, dom.ID, records[0].ID) if err != nil { return fmt.Errorf("constellix: failed to get TXT records: %w", err) } if !containsValue(record, info.Value) { return nil } // only 1 record value, the whole record must be deleted. if len(record.Value) == 1 { _, err = d.client.TxtRecords.Delete(ctx, dom.ID, record.ID) if err != nil { return fmt.Errorf("constellix: failed to delete TXT records: %w", err) } return nil } err = d.removeRecordValue(ctx, dom, record, info.Value) if err != nil { return fmt.Errorf("constellix: %w", err) } return nil } func (d *DNSProvider) createRecord(ctx context.Context, dom internal.Domain, fqdn, recordName, value string) error { request := internal.RecordRequest{ Name: recordName, TTL: d.config.TTL, RoundRobin: []internal.RecordValue{ {Value: fmt.Sprintf(`%q`, value)}, }, } _, err := d.client.TxtRecords.Create(ctx, dom.ID, request) if err != nil { return fmt.Errorf("failed to create TXT record %s: %w", fqdn, err) } return nil } func (d *DNSProvider) appendRecordValue(ctx context.Context, dom internal.Domain, recordID int64, value string) error { record, err := d.client.TxtRecords.Get(ctx, dom.ID, recordID) if err != nil { return fmt.Errorf("failed to get TXT records: %w", err) } if containsValue(record, value) { return nil } request := internal.RecordRequest{ Name: record.Name, TTL: record.TTL, RoundRobin: append(record.RoundRobin, internal.RecordValue{Value: fmt.Sprintf(`%q`, value)}), } _, err = d.client.TxtRecords.Update(ctx, dom.ID, record.ID, request) if err != nil { return fmt.Errorf("failed to update TXT records: %w", err) } return nil } func (d *DNSProvider) removeRecordValue(ctx context.Context, dom internal.Domain, record *internal.Record, value string) error { request := internal.RecordRequest{ Name: record.Name, TTL: record.TTL, } for _, val := range record.Value { if val.Value != fmt.Sprintf(`%q`, value) { request.RoundRobin = append(request.RoundRobin, val) } } _, err := d.client.TxtRecords.Update(ctx, dom.ID, record.ID, request) if err != nil { return fmt.Errorf("failed to update TXT records: %w", err) } return nil } func containsValue(record *internal.Record, value string) bool { if record == nil { return false } qValue := fmt.Sprintf(`%q`, value) return slices.ContainsFunc(record.Value, func(val internal.RecordValue) bool { return val.Value == qValue }) } func backoff(minimum, maximum time.Duration, attemptNum int, resp *http.Response) time.Duration { if resp != nil { // https://api.dns.constellix.com/v4/docs#section/Using-the-API/Rate-Limiting if resp.StatusCode == http.StatusTooManyRequests { if s, ok := resp.Header["X-Ratelimit-Reset"]; ok { if sleep, err := strconv.ParseInt(s[0], 10, 64); err == nil { return time.Second * time.Duration(sleep) } } } } return retryablehttp.DefaultBackoff(minimum, maximum, attemptNum, resp) } ================================================ FILE: providers/dns/constellix/constellix.toml ================================================ Name = "Constellix" Description = '''''' URL = "https://constellix.com" Code = "constellix" Since = "v3.4.0" Example = ''' CONSTELLIX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ CONSTELLIX_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ lego --dns constellix -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CONSTELLIX_API_KEY = "User API key" CONSTELLIX_SECRET_KEY = "User secret key" [Configuration.Additional] CONSTELLIX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" CONSTELLIX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" CONSTELLIX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" CONSTELLIX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api-docs.constellix.com" ================================================ FILE: providers/dns/constellix/constellix_test.go ================================================ package constellix import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey, EnvSecretKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvSecretKey: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvSecretKey: "", }, expected: "constellix: some credentials information are missing: CONSTELLIX_API_KEY,CONSTELLIX_SECRET_KEY", }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", EnvSecretKey: "api_password", }, expected: "constellix: some credentials information are missing: CONSTELLIX_API_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAPIKey: "api_username", EnvSecretKey: "", }, expected: "constellix: some credentials information are missing: CONSTELLIX_SECRET_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string apiKey string secretKey string }{ { desc: "success", apiKey: "api_key", secretKey: "api_secret", }, { desc: "missing credentials", expected: "constellix: incomplete credentials, missing secret key and/or API key", }, { desc: "missing api key", apiKey: "", secretKey: "api_secret", expected: "constellix: incomplete credentials, missing secret key and/or API key", }, { desc: "missing secret key", apiKey: "api_key", secretKey: "", expected: "constellix: incomplete credentials, missing secret key and/or API key", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.SecretKey = test.secretKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/constellix/internal/auth.go ================================================ package internal import ( "crypto/hmac" "crypto/sha1" "encoding/base64" "errors" "fmt" "net/http" "strconv" "time" ) const securityTokenHeader = "x-cns-security-token" // TokenTransport HTTP transport for API authentication. type TokenTransport struct { apiKey string secretKey string // Transport is the underlying HTTP transport to use when making requests. // It will default to http.DefaultTransport if nil. Transport http.RoundTripper } // NewTokenTransport Creates an HTTP transport for API authentication. func NewTokenTransport(apiKey, secretKey string) (*TokenTransport, error) { if apiKey == "" { return nil, errors.New("credentials missing: API key") } if secretKey == "" { return nil, errors.New("credentials missing: secret key") } return &TokenTransport{apiKey: apiKey, secretKey: secretKey}, nil } // RoundTrip executes a single HTTP transaction. func (t *TokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { enrichedReq := &http.Request{} *enrichedReq = *req enrichedReq.Header = make(http.Header, len(req.Header)) for k, s := range req.Header { enrichedReq.Header[k] = append([]string(nil), s...) } if t.apiKey != "" && t.secretKey != "" { securityToken := createCnsSecurityToken(t.apiKey, t.secretKey) enrichedReq.Header.Set(securityTokenHeader, securityToken) } return t.transport().RoundTrip(enrichedReq) } func (t *TokenTransport) transport() http.RoundTripper { if t.Transport != nil { return t.Transport } return http.DefaultTransport } // Client Creates a new HTTP client. func (t *TokenTransport) Client() *http.Client { return &http.Client{Transport: t} } // Wrap wraps an HTTP client Transport with the TokenTransport. func (t *TokenTransport) Wrap(client *http.Client) *http.Client { backup := client.Transport t.Transport = backup client.Transport = t return client } func createCnsSecurityToken(apiKey, secretKey string) string { timestamp := time.Now().Round(time.Millisecond).UnixNano() / int64(time.Millisecond) hm := encodedHmac(timestamp, secretKey) requestDate := strconv.FormatInt(timestamp, 10) return fmt.Sprintf("%s:%s:%s", apiKey, hm, requestDate) } func encodedHmac(message int64, secret string) string { h := hmac.New(sha1.New, []byte(secret)) _, _ = h.Write([]byte(strconv.FormatInt(message, 10))) return base64.StdEncoding.EncodeToString(h.Sum(nil)) } ================================================ FILE: providers/dns/constellix/internal/auth_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewTokenTransport_success(t *testing.T) { apiKey := "api" secretKey := "secret" transport, err := NewTokenTransport(apiKey, secretKey) require.NoError(t, err) assert.NotNil(t, transport) } func TestNewTokenTransport_missing_credentials(t *testing.T) { apiKey := "" secretKey := "" transport, err := NewTokenTransport(apiKey, secretKey) require.Error(t, err) assert.Nil(t, transport) } func TestTokenTransport_RoundTrip(t *testing.T) { apiKey := "api" secretKey := "secret" transport, err := NewTokenTransport(apiKey, secretKey) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) resp, err := transport.RoundTrip(req) require.NoError(t, err) assert.Regexp(t, `api:[^:]{28}:\d{13}`, resp.Request.Header.Get(securityTokenHeader)) } ================================================ FILE: providers/dns/constellix/internal/client.go ================================================ package internal import ( "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const ( defaultBaseURL = "https://api.dns.constellix.com" defaultVersion = "v1" ) // Client the Constellix client. type Client struct { BaseURL string HTTPClient *http.Client common service // Reuse a single struct instead of allocating one for each service on the heap. // Services used for communicating with the API Domains *DomainService TxtRecords *TxtRecordService } // NewClient Creates a Constellix client. func NewClient(httpClient *http.Client) *Client { if httpClient == nil { httpClient = &http.Client{Timeout: 5 * time.Second} } client := &Client{ BaseURL: defaultBaseURL, HTTPClient: httpClient, } client.common.client = client client.Domains = (*DomainService)(&client.common) client.TxtRecords = (*TxtRecordService)(&client.common) return client } type service struct { client *Client } // do sends an API request and returns the API response. func (c *Client) do(req *http.Request, result any) error { req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() err = checkResponse(resp) if err != nil { return err } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if err = json.Unmarshal(raw, result); err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) createEndpoint(fragment ...string) (string, error) { return url.JoinPath(c.BaseURL, fragment...) } func checkResponse(resp *http.Response) error { if resp.StatusCode == http.StatusOK { return nil } raw, err := io.ReadAll(resp.Body) if err == nil && raw != nil { errAPI := &APIError{StatusCode: resp.StatusCode} if json.Unmarshal(raw, errAPI) != nil { return fmt.Errorf("API error: status code: %d: %v", resp.StatusCode, string(raw)) } switch resp.StatusCode { case http.StatusNotFound: return &NotFound{APIError: errAPI} case http.StatusBadRequest: return &BadRequest{APIError: errAPI} default: return errAPI } } return fmt.Errorf("API error, status code: %d", resp.StatusCode) } ================================================ FILE: providers/dns/constellix/internal/domains.go ================================================ package internal import ( "context" "errors" "fmt" "net/http" querystring "github.com/google/go-querystring/query" ) // DomainService API access to Domain. type DomainService service // GetAll domains. // https://api-docs.constellix.com/?version=latest#484c3f21-d724-4ee4-a6fa-ab22c8eb9e9b func (s *DomainService) GetAll(ctx context.Context, params *PaginationParameters) ([]Domain, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains") if err != nil { return nil, fmt.Errorf("failed to create request endpoint: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } if params != nil { v, errQ := querystring.Values(params) if errQ != nil { return nil, errQ } req.URL.RawQuery = v.Encode() } var domains []Domain err = s.client.do(req, &domains) if err != nil { return nil, err } return domains, nil } // GetByName Gets domain by name. func (s *DomainService) GetByName(ctx context.Context, domainName string) (Domain, error) { domains, err := s.Search(ctx, Exact, domainName) if err != nil { return Domain{}, err } if len(domains) == 0 { return Domain{}, fmt.Errorf("domain not found: %s", domainName) } if len(domains) > 1 { return Domain{}, fmt.Errorf("multiple domains found: %v", domains) } return domains[0], nil } // Search searches for a domain by name. // https://api-docs.constellix.com/?version=latest#3d7b2679-2209-49f3-b011-b7d24e512008 func (s *DomainService) Search(ctx context.Context, filter searchFilter, value string) ([]Domain, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains", "search") if err != nil { return nil, fmt.Errorf("failed to create request endpoint: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } query := req.URL.Query() query.Set(string(filter), value) req.URL.RawQuery = query.Encode() var domains []Domain err = s.client.do(req, &domains) if err != nil { var nf *NotFound if !errors.As(err, &nf) { return nil, err } } return domains, nil } ================================================ FILE: providers/dns/constellix/internal/domains_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(server.Client()) client.BaseURL = server.URL return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestDomainService_GetAll(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains", servermock.ResponseFromFixture("domains-GetAll.json")). Build(t) data, err := client.Domains.GetAll(t.Context(), nil) require.NoError(t, err) expected := []Domain{ {ID: 273301, Name: "aaa.example", TypeID: 1, Version: 9, Status: "ACTIVE"}, {ID: 273302, Name: "bbb.example", TypeID: 1, Version: 9, Status: "ACTIVE"}, {ID: 273303, Name: "ccc.example", TypeID: 1, Version: 9, Status: "ACTIVE"}, {ID: 273304, Name: "ddd.example", TypeID: 1, Version: 9, Status: "ACTIVE"}, } assert.Equal(t, expected, data) } func TestDomainService_Search(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/search", servermock.ResponseFromFixture("domains-Search.json"), servermock.CheckQueryParameter().Strict(). With("exact", "example.com")). Build(t) data, err := client.Domains.Search(t.Context(), Exact, "example.com") require.NoError(t, err) expected := []Domain{ {ID: 273302, Name: "example.com", TypeID: 1, Version: 9, Status: "ACTIVE"}, } assert.Equal(t, expected, data) } ================================================ FILE: providers/dns/constellix/internal/fixtures/domains-GetAll.json ================================================ [ { "id": 273301, "name": "aaa.example", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", "ttl": 86400, "serial": 2015010110, "refresh": 43200, "retry": 3600, "expire": 1209600, "negCache": 180 }, "createdTs": "2020-02-04T22:42:10Z", "modifiedTs": "2020-02-05T09:23:17Z", "typeId": 1, "domainTags": [], "folder": null, "hasGtdRegions": false, "hasGeoIP": false, "nameserverGroup": 1, "nameservers": [ "ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net." ], "note": "", "version": 9, "status": "ACTIVE", "tags": null, "contactIds": [] }, { "id": 273302, "name": "bbb.example", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", "ttl": 86400, "serial": 2015010110, "refresh": 43200, "retry": 3600, "expire": 1209600, "negCache": 180 }, "createdTs": "2020-02-04T22:42:10Z", "modifiedTs": "2020-02-05T09:23:17Z", "typeId": 1, "domainTags": [], "folder": null, "hasGtdRegions": false, "hasGeoIP": false, "nameserverGroup": 1, "nameservers": [ "ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net." ], "note": "", "version": 9, "status": "ACTIVE", "tags": null, "contactIds": [] }, { "id": 273303, "name": "ccc.example", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", "ttl": 86400, "serial": 2015010110, "refresh": 43200, "retry": 3600, "expire": 1209600, "negCache": 180 }, "createdTs": "2020-02-04T22:42:10Z", "modifiedTs": "2020-02-05T09:23:17Z", "typeId": 1, "domainTags": [], "folder": null, "hasGtdRegions": false, "hasGeoIP": false, "nameserverGroup": 1, "nameservers": [ "ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net." ], "note": "", "version": 9, "status": "ACTIVE", "tags": null, "contactIds": [] }, { "id": 273304, "name": "ddd.example", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", "ttl": 86400, "serial": 2015010110, "refresh": 43200, "retry": 3600, "expire": 1209600, "negCache": 180 }, "createdTs": "2020-02-04T22:42:10Z", "modifiedTs": "2020-02-05T09:23:17Z", "typeId": 1, "domainTags": [], "folder": null, "hasGtdRegions": false, "hasGeoIP": false, "nameserverGroup": 1, "nameservers": [ "ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net." ], "note": "", "version": 9, "status": "ACTIVE", "tags": null, "contactIds": [] } ] ================================================ FILE: providers/dns/constellix/internal/fixtures/domains-Search.json ================================================ [ { "id": 273302, "name": "example.com", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", "ttl": 86400, "serial": 2015010110, "refresh": 43200, "retry": 3600, "expire": 1209600, "negCache": 180 }, "createdTs": "2020-02-04T22:42:10Z", "modifiedTs": "2020-02-05T09:23:17Z", "typeId": 1, "domainTags": [], "folder": null, "hasGtdRegions": false, "hasGeoIP": false, "nameserverGroup": 1, "nameservers": [ "ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net." ], "note": "", "version": 9, "status": "ACTIVE", "tags": null, "contactIds": [] } ] ================================================ FILE: providers/dns/constellix/internal/fixtures/records-Create.json ================================================ [ { "id": 3557066, "type": "TXT", "recordType": "txt", "name": "test", "recordOption": "roundRobin", "ttl": 300, "gtdRegion": 1, "parentId": 273302, "parent": "domain", "source": "Domain", "modifiedTs": 1580908547865, "value": [ { "value": "\"test\"" } ], "roundRobin": [ { "value": "\"test\"" } ] } ] ================================================ FILE: providers/dns/constellix/internal/fixtures/records-Get.json ================================================ { "id": 3557066, "type": "TXT", "recordType": "txt", "name": "test", "recordOption": "roundRobin", "ttl": 300, "gtdRegion": 1, "parentId": 273302, "parent": "domain", "source": "Domain", "modifiedTs": 1580908547863, "value": [ { "value": "\"test\"" } ], "roundRobin": [ { "value": "\"test\"" } ] } ================================================ FILE: providers/dns/constellix/internal/fixtures/records-GetAll.json ================================================ [ { "id": 3557066, "type": "TXT", "recordType": "txt", "name": "test", "recordOption": "roundRobin", "ttl": 300, "gtdRegion": 1, "parentId": 273302, "parent": "domain", "source": "Domain", "modifiedTs": 1580908547865, "value": [ { "value": "\"test\"" } ], "roundRobin": [ { "value": "\"test\"" } ] } ] ================================================ FILE: providers/dns/constellix/internal/fixtures/records-Search.json ================================================ [ { "id": 3557066, "name": "test", "recordType": "", "type": "" } ] ================================================ FILE: providers/dns/constellix/internal/txtrecords.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "strconv" ) // TxtRecordService API access to Record. type TxtRecordService service // Create a TXT record. // https://api-docs.constellix.com/?version=latest#22e24d5b-9ec0-49a7-b2b0-5ff0a28e71be func (s *TxtRecordService) Create(ctx context.Context, domainID int64, record RecordRequest) ([]Record, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt") if err != nil { return nil, fmt.Errorf("failed to create request endpoint: %w", err) } body, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } var records []Record err = s.client.do(req, &records) if err != nil { return nil, err } return records, nil } // GetAll TXT records. // https://api-docs.constellix.com/?version=latest#e7103c53-2ad8-4bc8-b5b3-4c22c4b571b2 func (s *TxtRecordService) GetAll(ctx context.Context, domainID int64) ([]Record, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt") if err != nil { return nil, fmt.Errorf("failed to create endpoint: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } var records []Record err = s.client.do(req, &records) if err != nil { return nil, err } return records, nil } // Get a TXT record. // https://api-docs.constellix.com/?version=latest#e7103c53-2ad8-4bc8-b5b3-4c22c4b571b2 func (s *TxtRecordService) Get(ctx context.Context, domainID, recordID int64) (*Record, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) if err != nil { return nil, fmt.Errorf("failed to create request endpoint: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } var records Record err = s.client.do(req, &records) if err != nil { return nil, err } return &records, nil } // Update a TXT record. // https://api-docs.constellix.com/?version=latest#d4e9ab2e-fac0-45a6-b0e4-cf62a2d2e3da func (s *TxtRecordService) Update(ctx context.Context, domainID, recordID int64, record RecordRequest) (*SuccessMessage, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) if err != nil { return nil, fmt.Errorf("failed to create request endpoint: %w", err) } body, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } var msg SuccessMessage err = s.client.do(req, &msg) if err != nil { return nil, err } return &msg, nil } // Delete a TXT record. // https://api-docs.constellix.com/?version=latest#135947f7-d6c8-481a-83c7-4d387b0bdf9e func (s *TxtRecordService) Delete(ctx context.Context, domainID, recordID int64) (*SuccessMessage, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) if err != nil { return nil, fmt.Errorf("failed to create request endpoint: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } var msg *SuccessMessage err = s.client.do(req, &msg) if err != nil { return nil, err } return msg, nil } // Search searches for a TXT record by name. // https://api-docs.constellix.com/?version=latest#81003e4f-bd3f-413f-a18d-6d9d18f10201 func (s *TxtRecordService) Search(ctx context.Context, domainID int64, filter searchFilter, value string) ([]Record, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", "search") if err != nil { return nil, fmt.Errorf("failed to create request endpoint: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } query := req.URL.Query() query.Set(string(filter), value) req.URL.RawQuery = query.Encode() var records []Record err = s.client.do(req, &records) if err != nil { var nf *NotFound if !errors.As(err, &nf) { return nil, err } } return records, nil } ================================================ FILE: providers/dns/constellix/internal/txtrecords_test.go ================================================ package internal import ( "encoding/json" "os" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTxtRecordService_Create(t *testing.T) { client := mockBuilder(). Route("POST /v1/domains/12345/records/txt", servermock.ResponseFromFixture("records-Create.json"), servermock.CheckRequestJSONBody(`{"name":""}`)). Build(t) records, err := client.TxtRecords.Create(t.Context(), 12345, RecordRequest{}) require.NoError(t, err) recordsJSON, err := json.Marshal(records) require.NoError(t, err) expectedContent, err := os.ReadFile("./fixtures/records-Create.json") require.NoError(t, err) assert.JSONEq(t, string(expectedContent), string(recordsJSON)) } func TestTxtRecordService_GetAll(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/12345/records/txt", servermock.ResponseFromFixture("records-GetAll.json")). Build(t) records, err := client.TxtRecords.GetAll(t.Context(), 12345) require.NoError(t, err) recordsJSON, err := json.Marshal(records) require.NoError(t, err) expectedContent, err := os.ReadFile("./fixtures/records-GetAll.json") require.NoError(t, err) assert.JSONEq(t, string(expectedContent), string(recordsJSON)) } func TestTxtRecordService_Get(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/12345/records/txt/6789", servermock.ResponseFromFixture("records-Get.json")). Build(t) record, err := client.TxtRecords.Get(t.Context(), 12345, 6789) require.NoError(t, err) expected := &Record{ ID: 3557066, Type: "TXT", RecordType: "txt", Name: "test", TTL: 300, RecordOption: "roundRobin", GtdRegion: 1, ParentID: 273302, Parent: "domain", Source: "Domain", ModifiedTS: 1580908547863, Value: []RecordValue{{ Value: `"test"`, }}, RoundRobin: []RecordValue{{ Value: `"test"`, }}, } assert.Equal(t, expected, record) } func TestTxtRecordService_Update(t *testing.T) { client := mockBuilder(). Route("PUT /v1/domains/12345/records/txt/6789", servermock.RawStringResponse(`{"success":"Record updated successfully"}`)). Build(t) msg, err := client.TxtRecords.Update(t.Context(), 12345, 6789, RecordRequest{}) require.NoError(t, err) expected := &SuccessMessage{Success: "Record updated successfully"} assert.Equal(t, expected, msg) } func TestTxtRecordService_Delete(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/domains/12345/records/txt/6789", servermock.RawStringResponse(`{"success":"Record deleted successfully"}`)). Build(t) msg, err := client.TxtRecords.Delete(t.Context(), 12345, 6789) require.NoError(t, err) expected := &SuccessMessage{Success: "Record deleted successfully"} assert.Equal(t, expected, msg) } func TestTxtRecordService_Search(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/12345/records/txt/search", servermock.ResponseFromFixture("records-Search.json")). Build(t) records, err := client.TxtRecords.Search(t.Context(), 12345, Exact, "test") require.NoError(t, err) recordsJSON, err := json.Marshal(records) require.NoError(t, err) expectedContent, err := os.ReadFile("./fixtures/records-Search.json") require.NoError(t, err) assert.JSONEq(t, string(expectedContent), string(recordsJSON)) } ================================================ FILE: providers/dns/constellix/internal/types.go ================================================ package internal import ( "fmt" "strings" ) // Search filters. const ( StartsWith searchFilter = "startswith" Exact searchFilter = "exact" EndsWith searchFilter = "endswith" Contains searchFilter = "contains" ) type searchFilter string // NotFound Not found error. type NotFound struct { *APIError } func (e *NotFound) Unwrap() error { return e.APIError } // BadRequest Bad request error. type BadRequest struct { *APIError } func (e *BadRequest) Unwrap() error { return e.APIError } // APIError is the representation of an API error. type APIError struct { StatusCode int `json:"statusCode"` Errors []string `json:"errors"` } func (a APIError) Error() string { return fmt.Sprintf("%d: %s", a.StatusCode, strings.Join(a.Errors, ": ")) } // SuccessMessage is the representation of a success message. type SuccessMessage struct { Success string `json:"success"` } // RecordRequest is the representation of a request's record. type RecordRequest struct { Name string `json:"name"` TTL int `json:"ttl,omitempty"` RoundRobin []RecordValue `json:"roundRobin,omitempty"` } // RecordValue is the representation of a record's value. type RecordValue struct { Value string `json:"value,omitempty"` DisableFlag bool `json:"disableFlag,omitempty"` // only for the response } // Record is the representation of a record. type Record struct { ID int64 `json:"id"` Type string `json:"type"` RecordType string `json:"recordType"` Name string `json:"name"` RecordOption string `json:"recordOption,omitempty"` NoAnswer bool `json:"noAnswer,omitempty"` Note string `json:"note,omitempty"` TTL int `json:"ttl,omitempty"` GtdRegion int `json:"gtdRegion,omitempty"` ParentID int `json:"parentId,omitempty"` Parent string `json:"parent,omitempty"` Source string `json:"source,omitempty"` ModifiedTS int64 `json:"modifiedTs,omitempty"` Value []RecordValue `json:"value,omitempty"` RoundRobin []RecordValue `json:"roundRobin,omitempty"` } // Domain is the representation of a domain. type Domain struct { ID int64 `json:"id"` Name string `json:"name,omitempty"` TypeID int64 `json:"typeId,omitempty"` Version int64 `json:"version,omitempty"` Status string `json:"status,omitempty"` } // PaginationParameters is pagination parameters. type PaginationParameters struct { // Offset retrieves a subset of records starting with the offset value. Offset int `url:"offset"` // Max retrieves maximum number of dataset. Max int `url:"max"` // Sort on the basis of given property name. Sort string `url:"sort"` // Order Sort order. Possible values are asc / desc. Order string `url:"order"` } ================================================ FILE: providers/dns/corenetworks/corenetworks.go ================================================ package corenetworks import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/corenetworks/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CORENETWORKS_" EnvLogin = envNamespace + "LOGIN" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Login string Password string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Core-Networks. // Credentials must be passed in the environment variables: CORENETWORKS_LOGIN, CORENETWORKS_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvLogin, EnvPassword) if err != nil { return nil, fmt.Errorf("corenetworks: %w", err) } config := NewDefaultConfig() config.Login = values[EnvLogin] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Bluecat DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("corenetworks: the configuration of the DNS provider is nil") } if config.Login == "" || config.Password == "" { return nil, errors.New("corenetworks: credentials missing") } client := internal.NewClient(config.Login, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("create authentication token: %w", err) } zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("corenetworks: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("corenetworks: %w", err) } record := internal.Record{ Name: subDomain, TTL: d.config.TTL, Type: "TXT", Data: info.Value, } err = d.client.AddRecord(ctx, dns01.UnFqdn(zone), record) if err != nil { return fmt.Errorf("corenetworks: add record: %w", err) } err = d.client.CommitRecords(ctx, dns01.UnFqdn(zone)) if err != nil { return fmt.Errorf("corenetworks: commit records: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("create authentication token: %w", err) } zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("corenetworks: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("corenetworks: %w", err) } record := internal.Record{ Name: subDomain, TTL: d.config.TTL, Type: "TXT", Data: info.Value, } err = d.client.DeleteRecords(ctx, dns01.UnFqdn(zone), record) if err != nil { return fmt.Errorf("corenetworks: delete records: %w", err) } err = d.client.CommitRecords(ctx, dns01.UnFqdn(zone)) if err != nil { return fmt.Errorf("corenetworks: commit records: %w", err) } return nil } ================================================ FILE: providers/dns/corenetworks/corenetworks.toml ================================================ Name = "Core-Networks" Description = '''''' URL = "https://www.core-networks.de/" Code = "corenetworks" Since = "v4.20.0" Example = ''' CORENETWORKS_LOGIN="xxxx" \ CORENETWORKS_PASSWORD="yyyy" \ lego --dns corenetworks -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CORENETWORKS_LOGIN = "The username of the API account" CORENETWORKS_PASSWORD = "The password" [Configuration.Additional] CORENETWORKS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" CORENETWORKS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" CORENETWORKS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" CORENETWORKS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" CORENETWORKS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" [Links] API = "https://beta.api.core-networks.de/doc/" ================================================ FILE: providers/dns/corenetworks/corenetworks_test.go ================================================ package corenetworks import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvLogin, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvLogin: "user", EnvPassword: "secret", }, }, { desc: "missing login", envVars: map[string]string{ EnvPassword: "secret", }, expected: "corenetworks: some credentials information are missing: CORENETWORKS_LOGIN", }, { desc: "missing password", envVars: map[string]string{ EnvLogin: "user", }, expected: "corenetworks: some credentials information are missing: CORENETWORKS_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string login string password string expected string }{ { desc: "success", login: "user", password: "secret", }, { desc: "missing login", password: "secret", expected: "corenetworks: credentials missing", }, { desc: "missing password", login: "user", expected: "corenetworks: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Login = test.login config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func 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) } ================================================ FILE: providers/dns/corenetworks/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://beta.api.core-networks.de" // Client a Core-Networks client. type Client struct { login string password string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(login, password string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ login: login, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // ListZone gets a list of all DNS zones. // https://beta.api.core-networks.de/doc/#functon_dnszones func (c *Client) ListZone(ctx context.Context) ([]Zone, error) { endpoint := c.baseURL.JoinPath("dnszones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var zones []Zone err = c.do(req, &zones) if err != nil { return nil, err } return zones, nil } // GetZoneDetails provides detailed information about a DNS zone. // https://beta.api.core-networks.de/doc/#functon_dnszones_details func (c *Client) GetZoneDetails(ctx context.Context, zone string) (*ZoneDetails, error) { endpoint := c.baseURL.JoinPath("dnszones", zone) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var details ZoneDetails err = c.do(req, &details) if err != nil { return nil, err } return &details, nil } // ListRecords gets a list of DNS records belonging to the zone. // https://beta.api.core-networks.de/doc/#functon_dnszones_records func (c *Client) ListRecords(ctx context.Context, zone string) ([]Record, error) { endpoint := c.baseURL.JoinPath("dnszones", zone, "records") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var records []Record err = c.do(req, &records) if err != nil { return nil, err } return records, nil } // AddRecord adds a record. // https://beta.api.core-networks.de/doc/#functon_dnszones_records_add func (c *Client) AddRecord(ctx context.Context, zone string, record Record) error { endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "/") if record.Name == "" { record.Name = "@" } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } err = c.do(req, nil) if err != nil { return err } return nil } // DeleteRecords deletes all DNS records of a zone that match the DNS record passed. // https://beta.api.core-networks.de/doc/#functon_dnszones_records_delete func (c *Client) DeleteRecords(ctx context.Context, zone string, record Record) error { endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "delete") if record.Name == "" { record.Name = "@" } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } err = c.do(req, nil) if err != nil { return err } return nil } // CommitRecords sends a commit to the zone. // https://beta.api.core-networks.de/doc/#functon_dnszones_commit func (c *Client) CommitRecords(ctx context.Context, zone string) error { endpoint := c.baseURL.JoinPath("dnszones", zone, "records", "commit") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil) if err != nil { return err } err = c.do(req, nil) if err != nil { return err } return nil } func (c *Client) do(req *http.Request, result any) error { at := getToken(req.Context()) if at != "" { req.Header.Set(authorizationHeader, "Bearer "+at) } resp, errD := c.HTTPClient.Do(req) if errD != nil { return errutils.NewHTTPDoError(req, errD) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/corenetworks/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestClient_ListZone(t *testing.T) { client := mockBuilder(). Route("GET /dnszones/", servermock.ResponseFromFixture("ListZone.json")). Build(t) ctx := t.Context() zones, err := client.ListZone(ctx) require.NoError(t, err) expected := []Zone{ {Name: "example.com", Type: "master"}, {Name: "example.net", Type: "slave"}, } assert.Equal(t, expected, zones) } func TestClient_GetZoneDetails(t *testing.T) { client := mockBuilder(). Route("GET /dnszones/example.com", servermock.ResponseFromFixture("GetZoneDetails.json")). Build(t) zone, err := client.GetZoneDetails(t.Context(), "example.com") require.NoError(t, err) expected := &ZoneDetails{ Active: true, DNSSec: true, Name: "example.com", Type: "master", } assert.Equal(t, expected, zone) } func TestClient_ListRecords(t *testing.T) { client := mockBuilder(). Route("GET /dnszones/example.com/records/", servermock.ResponseFromFixture("ListRecords.json")). Build(t) records, err := client.ListRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ { Name: "@", TTL: 86400, Type: "NS", Data: "ns2.core-networks.eu.", }, { Name: "@", TTL: 86400, Type: "NS", Data: "ns3.core-networks.com.", }, { Name: "@", TTL: 86400, Type: "NS", Data: "ns1.core-networks.de.", }, } assert.Equal(t, expected, records) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /dnszones/example.com/records/", servermock.Noop().WithStatusCode(http.StatusNoContent)). Build(t) record := Record{Name: "www", TTL: 3600, Type: "A", Data: "127.0.0.1"} err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecords(t *testing.T) { client := mockBuilder(). Route("POST /dnszones/example.com/records/delete", servermock.Noop().WithStatusCode(http.StatusNoContent)). Build(t) record := Record{Name: "www", Type: "A", Data: "127.0.0.1"} err := client.DeleteRecords(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_CommitRecords(t *testing.T) { client := mockBuilder(). Route("POST /dnszones/example.com/records/commit", servermock.Noop().WithStatusCode(http.StatusNoContent)). Build(t) err := client.CommitRecords(t.Context(), "example.com") require.NoError(t, err) } ================================================ FILE: providers/dns/corenetworks/internal/fixtures/GetZoneDetails.json ================================================ { "active": true, "dnssec": true, "master": null, "name": "example.com", "tsig": null, "type": "master" } ================================================ FILE: providers/dns/corenetworks/internal/fixtures/ListRecords.json ================================================ [ { "name": "@", "ttl": 86400, "type": "NS", "data": "ns2.core-networks.eu." }, { "name": "@", "ttl": 86400, "type": "NS", "data": "ns3.core-networks.com." }, { "name": "@", "ttl": 86400, "type": "NS", "data": "ns1.core-networks.de." } ] ================================================ FILE: providers/dns/corenetworks/internal/fixtures/ListZone.json ================================================ [ { "name": "example.com", "type": "master" }, { "name": "example.net", "type": "slave" } ] ================================================ FILE: providers/dns/corenetworks/internal/fixtures/auth.json ================================================ { "token": "authsecret", "expires": 123 } ================================================ FILE: providers/dns/corenetworks/internal/identity.go ================================================ package internal import ( "context" "net/http" ) const authorizationHeader = "Authorization" type token string const tokenKey token = "token" // CreateAuthenticationToken gets an authentication token. // https://beta.api.core-networks.de/doc/#functon_auth_token func (c *Client) CreateAuthenticationToken(ctx context.Context) (*Token, error) { endpoint := c.baseURL.JoinPath("auth", "token") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, Auth{Login: c.login, Password: c.password}) if err != nil { return nil, err } var token Token err = c.do(req, &token) if err != nil { return nil, err } return &token, nil } func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { tok, err := c.CreateAuthenticationToken(ctx) if err != nil { return nil, err } return context.WithValue(ctx, tokenKey, tok.Token), nil } func getToken(ctx context.Context) string { tok, ok := ctx.Value(tokenKey).(string) if !ok { return "" } return tok } ================================================ FILE: providers/dns/corenetworks/internal/identity_test.go ================================================ package internal import ( "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClient_CreateAuthenticationToken(t *testing.T) { client := mockBuilder(). Route("POST /auth/token", servermock.ResponseFromFixture("auth.json")). Build(t) token, err := client.CreateAuthenticationToken(t.Context()) require.NoError(t, err) expected := &Token{ Token: "authsecret", Expires: 123, } assert.Equal(t, expected, token) } ================================================ FILE: providers/dns/corenetworks/internal/types.go ================================================ package internal type Auth struct { Login string `json:"login,omitempty"` Password string `json:"password,omitempty"` } type Token struct { Token string `json:"token,omitempty"` Expires int `json:"expires,omitempty"` } type Zone struct { Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` } type ZoneDetails struct { Active bool `json:"active,omitempty"` DNSSec bool `json:"dnssec,omitempty"` Master string `json:"master,omitempty"` Name string `json:"name,omitempty"` TSIG *TSIGKey `json:"tsig,omitempty"` Type string `json:"type,omitempty"` } type TSIGKey struct { Algo string `json:"algo,omitempty"` Secret string `json:"secret,omitempty"` } type Record struct { Name string `json:"name,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` Data string `json:"data,omitempty"` } ================================================ FILE: providers/dns/cpanel/cpanel.go ================================================ // Package cpanel implements a DNS provider for solving the DNS-01 challenge using CPanel. package cpanel import ( "context" "encoding/base64" "errors" "fmt" "net/http" "slices" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/cpanel" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/whm" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CPANEL_" EnvMode = envNamespace + "MODE" EnvUsername = envNamespace + "USERNAME" EnvToken = envNamespace + "TOKEN" EnvBaseURL = envNamespace + "BASE_URL" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) type apiClient interface { FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) } // Config is used to configure the creation of the DNSProvider. type Config struct { Mode string Username string Token string BaseURL 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{ Mode: env.GetOrDefaultString(EnvMode, "cpanel"), TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client apiClient } // NewDNSProvider returns a DNSProvider instance configured for CPanel. // Credentials must be passed in the environment variables: // CPANEL_USERNAME, CPANEL_TOKEN, CPANEL_BASE_URL, CPANEL_NAMESERVER. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvToken, EnvBaseURL) if err != nil { return nil, fmt.Errorf("cpanel: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Token = values[EnvToken] config.BaseURL = values[EnvBaseURL] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for CPanel. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("cpanel: the configuration of the DNS provider is nil") } if config.Username == "" || config.Token == "" { return nil, errors.New("cpanel: some credentials information are missing") } if config.BaseURL == "" { return nil, errors.New("cpanel: server information are missing") } client, err := createClient(config) if err != nil { return nil, fmt.Errorf("cpanel: create client error: %w", err) } return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("arvancloud: could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) zoneInfo, err := d.client.FetchZoneInformation(ctx, zone) if err != nil { return fmt.Errorf("cpanel[mode=%s]: fetch zone information: %w", d.config.Mode, err) } serial, err := getZoneSerial(authZone, zoneInfo) if err != nil { return fmt.Errorf("cpanel[mode=%s]: get zone serial: %w", d.config.Mode, err) } valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value)) var ( found bool existingRecord shared.ZoneRecord ) for _, record := range zoneInfo { if slices.Contains(record.DataB64, valueB64) { existingRecord = record found = true break } } record := shared.Record{ DName: info.EffectiveFQDN, TTL: d.config.TTL, RecordType: "TXT", } // New record. if !found { record.Data = []string{info.Value} _, err = d.client.AddRecord(ctx, serial, zone, record) if err != nil { return fmt.Errorf("cpanel[mode=%s]: add record: %w", d.config.Mode, err) } return nil } // Update existing record. record.LineIndex = existingRecord.LineIndex for _, dataB64 := range existingRecord.DataB64 { data, errD := base64.StdEncoding.DecodeString(dataB64) if errD != nil { return fmt.Errorf("cpanel[mode=%s]: decode base64 record value: %w", d.config.Mode, errD) } record.Data = append(record.Data, string(data)) } record.Data = append(record.Data, info.Value) _, err = d.client.EditRecord(ctx, serial, zone, record) if err != nil { return fmt.Errorf("cpanel[mode=%s]: edit record: %w", d.config.Mode, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("arvancloud: could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) zoneInfo, err := d.client.FetchZoneInformation(ctx, zone) if err != nil { return fmt.Errorf("cpanel[mode=%s]: fetch zone information: %w", d.config.Mode, err) } serial, err := getZoneSerial(authZone, zoneInfo) if err != nil { return fmt.Errorf("cpanel[mode=%s]: get zone serial: %w", d.config.Mode, err) } valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value)) var ( found bool existingRecord shared.ZoneRecord ) for _, record := range zoneInfo { if slices.Contains(record.DataB64, valueB64) { existingRecord = record found = true break } } if !found { return nil } var newData []string for _, dataB64 := range existingRecord.DataB64 { if dataB64 == valueB64 { continue } data, errD := base64.StdEncoding.DecodeString(dataB64) if errD != nil { return fmt.Errorf("cpanel[mode=%s]: decode base64 record value: %w", d.config.Mode, errD) } newData = append(newData, string(data)) } // Delete record. if len(newData) == 0 { _, err = d.client.DeleteRecord(ctx, serial, zone, existingRecord.LineIndex) if err != nil { return fmt.Errorf("cpanel[mode=%s]: delete record: %w", d.config.Mode, err) } return nil } // Remove one value. record := shared.Record{ DName: info.EffectiveFQDN, TTL: d.config.TTL, RecordType: "TXT", Data: newData, LineIndex: existingRecord.LineIndex, } _, err = d.client.EditRecord(ctx, serial, zone, record) if err != nil { return fmt.Errorf("cpanel[mode=%s]: edit record: %w", d.config.Mode, err) } return nil } func getZoneSerial(zoneFqdn string, zoneInfo []shared.ZoneRecord) (uint32, error) { nameB64 := base64.StdEncoding.EncodeToString([]byte(zoneFqdn)) for _, record := range zoneInfo { if record.Type != "record" || record.RecordType != "SOA" || record.DNameB64 != nameB64 { continue } // https://github.com/go-acme/lego/issues/1060#issuecomment-1925572386 // https://github.com/go-acme/lego/issues/1060#issuecomment-1925581832 data, err := base64.StdEncoding.DecodeString(record.DataB64[2]) if err != nil { return 0, fmt.Errorf("decode serial DNameB64: %w", err) } var newSerial uint32 _, err = fmt.Sscan(string(data), &newSerial) if err != nil { return 0, fmt.Errorf("decode serial DNameB64, invalid serial value %q: %w", string(data), err) } return newSerial, nil } return 0, errors.New("zone serial not found") } func createClient(config *Config) (apiClient, error) { switch strings.ToLower(config.Mode) { case "cpanel": client, err := cpanel.NewClient(config.BaseURL, config.Username, config.Token) if err != nil { return nil, fmt.Errorf("failed to create cPanel API client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return client, nil case "whm": client, err := whm.NewClient(config.BaseURL, config.Username, config.Token) if err != nil { return nil, fmt.Errorf("failed to create WHM API client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return client, nil default: return nil, fmt.Errorf("unsupported mode: %q", config.Mode) } } ================================================ FILE: providers/dns/cpanel/cpanel.toml ================================================ Name = "CPanel/WHM" Description = '''''' URL = "https://cpanel.net/" Code = "cpanel" Since = "v4.16.0" Example = ''' ### CPANEL (default) CPANEL_USERNAME="yyyy" \ CPANEL_TOKEN="xxxx" \ CPANEL_BASE_URL="https://example.com:2083" \ lego --dns cpanel -d '*.example.com' -d example.com run ## WHM CPANEL_MODE=whm \ CPANEL_USERNAME="yyyy" \ CPANEL_TOKEN="xxxx" \ CPANEL_BASE_URL="https://example.com:2087" \ lego --dns cpanel -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CPANEL_USERNAME = "username" CPANEL_TOKEN = "API token" CPANEL_BASE_URL = "API server URL" [Configuration.Additional] CPANEL_MODE = "use cpanel API or WHM API (Default: cpanel)" CPANEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" CPANEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" CPANEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" CPANEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API_CPANEL = "https://api.docs.cpanel.net/cpanel/introduction/" API_WHM = "https://api.docs.cpanel.net/whm/introduction/" ================================================ FILE: providers/dns/cpanel/cpanel_test.go ================================================ package cpanel import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvMode, EnvUsername, EnvToken, EnvBaseURL). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string expectedMode string }{ { desc: "success cpanel mode (default)", envVars: map[string]string{ EnvUsername: "user", EnvToken: "secret", EnvBaseURL: "https://example.com", }, expectedMode: "cpanel", }, { desc: "success whm mode", envVars: map[string]string{ EnvMode: "whm", EnvUsername: "user", EnvToken: "secret", EnvBaseURL: "https://example.com", }, expectedMode: "whm", }, { desc: "missing user", envVars: map[string]string{ EnvToken: "secret", EnvBaseURL: "https://example.com", }, expected: "cpanel: some credentials information are missing: CPANEL_USERNAME", }, { desc: "missing token", envVars: map[string]string{ EnvUsername: "user", EnvBaseURL: "https://example.com", }, expected: "cpanel: some credentials information are missing: CPANEL_TOKEN", }, { desc: "missing base URL", envVars: map[string]string{ EnvUsername: "user", EnvToken: "secret", EnvBaseURL: "", }, expected: "cpanel: some credentials information are missing: CPANEL_BASE_URL", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { assert.Equal(t, test.expectedMode, p.config.Mode) require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string mode string username string token string baseURL string expected string }{ { desc: "success", mode: "whm", username: "user", token: "secret", baseURL: "https://example.com", }, { desc: "missing mode", username: "user", token: "secret", baseURL: "https://example.com", expected: `cpanel: create client error: unsupported mode: ""`, }, { desc: "invalid mode", mode: "test", username: "user", token: "secret", baseURL: "https://example.com", expected: `cpanel: create client error: unsupported mode: "test"`, }, { desc: "missing username", mode: "whm", username: "", token: "secret", baseURL: "https://example.com", expected: "cpanel: some credentials information are missing", }, { desc: "missing token", mode: "whm", username: "user", token: "", baseURL: "https://example.com", expected: "cpanel: some credentials information are missing", }, { desc: "missing base URL", mode: "whm", username: "user", token: "secret", baseURL: "", expected: "cpanel: server information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Mode = test.mode config.Username = test.username config.Token = test.token config.BaseURL = test.baseURL p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func Test_getZoneSerial(t *testing.T) { zones := []shared.ZoneRecord{ { Type: "comment", LineIndex: 1, TextB64: "OyBab25lIGZpbGUgZm9yIGV4YW1wbGUuY29t", }, { Type: "control", LineIndex: 2, TextB64: "JFRUTCAxNDQwMA==", }, { DNameB64: "ZXhhbXBsZS5jb20u", LineIndex: 4, RecordType: "NS", Type: "record", TTL: 86400, DataB64: []string{"YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4="}, }, { DataB64: []string{ "YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4=", "ZW1haWwuaXB4Y29yZS5jb20u", "MjAyNDAyMDQwOQ==", "MzYwMA==", "MTgwMA==", "MTIwOTYwMA==", "ODY0MDA=", }, RecordType: "SOA", Type: "record", TTL: 86400, LineIndex: 3, DNameB64: "ZXhhbXBsZS5jb20u", }, { RecordType: "A", Type: "record", TTL: 3600, DataB64: []string{"MTAuMTAuMTAuMTA="}, LineIndex: 9, DNameB64: "ZXhhbXBsZS5jb20u", }, } serial, err := getZoneSerial("example.com.", zones) require.NoError(t, err) assert.EqualValues(t, 2024020409, serial) } func Test_getZoneSerial_error(t *testing.T) { zones := []shared.ZoneRecord{ { Type: "comment", LineIndex: 1, TextB64: "OyBab25lIGZpbGUgZm9yIGV4YW1wbGUuY29t", }, { Type: "control", LineIndex: 2, TextB64: "JFRUTCAxNDQwMA==", }, { DNameB64: "ZXhhbXBsZS5jb20u", LineIndex: 4, RecordType: "NS", Type: "record", TTL: 86400, DataB64: []string{"YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4="}, }, { DataB64: []string{ "YWxsMS5kbnNyb3VuZHJvYmluLm5ldC4=", "ZW1haWwuaXB4Y29yZS5jb20u", "MjAyNDAyMDQwOQ==", "MzYwMA==", "MTgwMA==", "MTIwOTYwMA==", "ODY0MDA=", }, RecordType: "SOA", Type: "record", TTL: 86400, LineIndex: 3, DNameB64: "ZXhhbXBsZS5vcmcu", }, { RecordType: "A", Type: "record", TTL: 3600, DataB64: []string{"MTAuMTAuMTAuMTA="}, LineIndex: 9, DNameB64: "ZXhhbXBsZS5jb20u", }, } serial, err := getZoneSerial("example.com.", zones) require.Error(t, err) assert.EqualValues(t, 0, serial) } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/cpanel/internal/cpanel/client.go ================================================ package cpanel import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const statusFailed = 0 type Client struct { username string token string baseURL *url.URL HTTPClient *http.Client } func NewClient(baseURL, username, token string) (*Client, error) { apiEndpoint, err := url.Parse(baseURL) if err != nil { return nil, err } return &Client{ username: username, token: token, baseURL: apiEndpoint.JoinPath("execute"), HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // FetchZoneInformation fetches zone information. // https://api.docs.cpanel.net/openapi/cpanel/operation/dns-parse_zone/ func (c *Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) { endpoint := c.baseURL.JoinPath("DNS", "parse_zone") query := endpoint.Query() query.Set("zone", domain) endpoint.RawQuery = query.Encode() var result APIResponse[[]shared.ZoneRecord] err := c.doRequest(ctx, endpoint, &result) if err != nil { return nil, err } if result.Status == statusFailed { return nil, toError(result) } return result.Data, nil } // AddRecord adds a new record. // // add='{"dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' func (c *Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { data, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON data: %w", err) } return c.updateZone(ctx, serial, domain, "add", string(data)) } // EditRecord edits an existing record. // // edit='{"line_index": 9, "dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' func (c *Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { data, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON data: %w", err) } return c.updateZone(ctx, serial, domain, "edit", string(data)) } // DeleteRecord deletes an existing record. // // remove=22 func (c *Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) { return c.updateZone(ctx, serial, domain, "remove", strconv.Itoa(lineIndex)) } // https://api.docs.cpanel.net/openapi/cpanel/operation/dns-mass_edit_zone/ func (c *Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) { endpoint := c.baseURL.JoinPath("DNS", "mass_edit_zone") query := endpoint.Query() query.Set("serial", strconv.FormatUint(uint64(serial), 10)) query.Set(action, data) query.Set("zone", domain) endpoint.RawQuery = query.Encode() var result APIResponse[shared.ZoneSerial] err := c.doRequest(ctx, endpoint, &result) if err != nil { return nil, err } if result.Status == statusFailed { return nil, toError(result) } return &result.Data, nil } func (c *Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } // https://api.docs.cpanel.net/cpanel/tokens/#using-an-api-token req.Header.Set("Authorization", fmt.Sprintf("cpanel %s:%s", c.username, c.token)) req.Header.Set("Accept", "application/json") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } ================================================ FILE: providers/dns/cpanel/internal/cpanel/client_test.go ================================================ package cpanel import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("cpanel user:secret")) } func TestClient_FetchZoneInformation(t *testing.T) { client := mockBuilder(). Route("GET /execute/DNS/parse_zone", servermock.ResponseFromFixture("zone-info.json"), servermock.CheckQueryParameter().Strict(). With("zone", "example.com")). Build(t) zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.NoError(t, err) expected := []shared.ZoneRecord{{ LineIndex: 22, Type: "record", DataB64: []string{"dGV4YXMuY29tLg=="}, DNameB64: "dGV4YXMuY29tLg==", RecordType: "MX", TTL: 14400, }} assert.Equal(t, expected, zoneInfo) } func TestClient_FetchZoneInformation_error(t *testing.T) { client := mockBuilder(). Route("GET /execute/DNS/parse_zone", servermock.ResponseFromFixture("zone-info_error.json")). Build(t) zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.EqualError(t, err, "error(0): You do not control a DNS zone named example.com.: a, b, c") assert.Nil(t, zoneInfo) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("GET /execute/DNS/mass_edit_zone", servermock.ResponseFromFixture("update-zone.json"), servermock.CheckQueryParameter().Strict(). With("zone", "example.com"). With("add", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"]}`). With("serial", "123456"). With("zone", "example.com")). Build(t) record := shared.Record{ DName: "example", TTL: 14400, RecordType: "TXT", Data: []string{"string1", "string2"}, } zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} assert.Equal(t, expected, zoneSerial) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /execute/DNS/mass_edit_zone", servermock.ResponseFromFixture("update-zone_error.json")). Build(t) record := shared.Record{ DName: "example", TTL: 14400, RecordType: "TXT", Data: []string{"string1", "string2"}, } zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record) require.Error(t, err) assert.Nil(t, zoneSerial) } func TestClient_EditRecord(t *testing.T) { client := mockBuilder(). Route("GET /execute/DNS/mass_edit_zone", servermock.ResponseFromFixture("update-zone.json"), servermock.CheckQueryParameter().Strict(). With("edit", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"],"line_index":9}`). With("serial", "123456"). With("zone", "example.com")). Build(t) record := shared.Record{ LineIndex: 9, DName: "example", TTL: 14400, RecordType: "TXT", Data: []string{"string1", "string2"}, } zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} assert.Equal(t, expected, zoneSerial) } func TestClient_EditRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /execute/DNS/mass_edit_zone", servermock.ResponseFromFixture("update-zone_error.json")). Build(t) record := shared.Record{ LineIndex: 9, DName: "example", TTL: 14400, RecordType: "TXT", Data: []string{"string1", "string2"}, } zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record) require.Error(t, err) assert.Nil(t, zoneSerial) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("GET /execute/DNS/mass_edit_zone", servermock.ResponseFromFixture("update-zone.json"), servermock.CheckQueryParameter().Strict(). With("remove", "0"). With("serial", "123456"). With("zone", "example.com")). Build(t) zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} assert.Equal(t, expected, zoneSerial) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /execute/DNS/mass_edit_zone", servermock.ResponseFromFixture("update-zone_error.json")). Build(t) zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.Error(t, err) assert.Nil(t, zoneSerial) } ================================================ FILE: providers/dns/cpanel/internal/cpanel/fixtures/update-zone.json ================================================ { "metadata": { "transformed": 1 }, "messages": null, "status": 1, "warnings": null, "errors": null, "data": { "new_serial": "2021031903" } } ================================================ FILE: providers/dns/cpanel/internal/cpanel/fixtures/update-zone_error.json ================================================ { "warnings": null, "messages": [ "a", "b", "c" ], "data": null, "errors": [ "You do not control a DNS zone named example.com." ], "metadata": {}, "status": 0 } ================================================ FILE: providers/dns/cpanel/internal/cpanel/fixtures/zone-info.json ================================================ { "metadata": { "transformed": 1 }, "messages": null, "status": 1, "warnings": null, "errors": null, "data": [ { "line_index": 22, "dname_b64": "dGV4YXMuY29tLg==", "data_b64": [ "dGV4YXMuY29tLg==" ], "type": "record", "ttl": 14400, "record_type": "MX" } ] } ================================================ FILE: providers/dns/cpanel/internal/cpanel/fixtures/zone-info_error.json ================================================ { "warnings": null, "messages": [ "a", "b", "c" ], "data": null, "errors": [ "You do not control a DNS zone named example.com." ], "metadata": {}, "status": 0 } ================================================ FILE: providers/dns/cpanel/internal/cpanel/types.go ================================================ package cpanel import ( "fmt" "strings" ) type APIResponse[T any] struct { Metadata Metadata `json:"metadata"` Data T `json:"data,omitempty"` Status int `json:"status,omitempty"` Messages []string `json:"messages,omitempty"` Warnings []string `json:"warnings,omitempty"` Errors []string `json:"errors,omitempty"` } type Metadata struct { Transformed int `json:"transformed,omitempty"` } func toError[T any](r APIResponse[T]) error { return fmt.Errorf("error(%d): %s: %s", r.Status, strings.Join(r.Errors, ", "), strings.Join(r.Messages, ", ")) } ================================================ FILE: providers/dns/cpanel/internal/shared/types.go ================================================ package shared type Record struct { DName string `json:"dname,omitempty"` TTL int `json:"ttl,omitempty"` RecordType string `json:"record_type,omitempty"` Data []string `json:"data,omitempty"` LineIndex int `json:"line_index,omitempty"` } type ZoneRecord struct { LineIndex int `json:"line_index,omitempty"` Type string `json:"type,omitempty"` DataB64 []string `json:"data_b64,omitempty"` DNameB64 string `json:"dname_b64,omitempty"` TextB64 string `json:"text_b64,omitempty"` RecordType string `json:"record_type,omitempty"` TTL int `json:"ttl,omitempty"` } type ZoneSerial struct { NewSerial string `json:"new_serial,omitempty"` } ================================================ FILE: providers/dns/cpanel/internal/whm/client.go ================================================ package whm import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const statusFailed = 0 type Client struct { username string token string baseURL *url.URL HTTPClient *http.Client } func NewClient(baseURL, username, token string) (*Client, error) { apiEndpoint, err := url.Parse(baseURL) if err != nil { return nil, err } return &Client{ username: username, token: token, baseURL: apiEndpoint.JoinPath("json-api"), HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // FetchZoneInformation fetches zone information. // https://api.docs.cpanel.net/openapi/whm/operation/parse_dns_zone/ func (c *Client) FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) { endpoint := c.baseURL.JoinPath("parse_dns_zone") query := endpoint.Query() query.Set("zone", domain) endpoint.RawQuery = query.Encode() var result APIResponse[ZoneData] err := c.doRequest(ctx, endpoint, &result) if err != nil { return nil, err } if result.Metadata.Result == statusFailed { return nil, toError(result.Metadata) } return result.Data.Payload, nil } // AddRecord adds a new record. // // add='{"dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' func (c *Client) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { data, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON data: %w", err) } return c.updateZone(ctx, serial, domain, "add", string(data)) } // EditRecord edits an existing record. // // edit='{"line_index": 9, "dname":"example", "ttl":14400, "record_type":"TXT", "data":["string1", "string2"]}' func (c *Client) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) { data, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("failed to create request JSON data: %w", err) } return c.updateZone(ctx, serial, domain, "edit", string(data)) } // DeleteRecord deletes an existing record. // // remove=22 func (c *Client) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) { return c.updateZone(ctx, serial, domain, "remove", strconv.Itoa(lineIndex)) } // https://api.docs.cpanel.net/openapi/whm/operation/mass_edit_dns_zone/ func (c *Client) updateZone(ctx context.Context, serial uint32, domain, action, data string) (*shared.ZoneSerial, error) { endpoint := c.baseURL.JoinPath("mass_edit_dns_zone") query := endpoint.Query() query.Set("serial", strconv.FormatUint(uint64(serial), 10)) query.Set(action, data) query.Set("zone", domain) endpoint.RawQuery = query.Encode() var result APIResponse[shared.ZoneSerial] err := c.doRequest(ctx, endpoint, &result) if err != nil { return nil, err } if result.Metadata.Result == statusFailed { return nil, toError(result.Metadata) } return &result.Data, nil } func (c *Client) doRequest(ctx context.Context, endpoint *url.URL, result any) error { query := endpoint.Query() query.Set("api.version", "1") endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } // https://api.docs.cpanel.net/whm/tokens/ req.Header.Set("Authorization", fmt.Sprintf("whm %s:%s", c.username, c.token)) req.Header.Set("Accept", "application/json") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } ================================================ FILE: providers/dns/cpanel/internal/whm/client_test.go ================================================ package whm import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("whm user:secret")) } func TestClient_FetchZoneInformation(t *testing.T) { client := mockBuilder(). Route("GET /json-api/parse_dns_zone", servermock.ResponseFromFixture("zone-info.json"), servermock.CheckQueryParameter().Strict(). With("api.version", "1"). With("zone", "example.com")). Build(t) zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.NoError(t, err) expected := []shared.ZoneRecord{{ LineIndex: 22, Type: "record", DataB64: []string{"dGV4YXMuY29tLg=="}, DNameB64: "dGV4YXMuY29tLg==", RecordType: "MX", TTL: 14400, }} assert.Equal(t, expected, zoneInfo) } func TestClient_FetchZoneInformation_error(t *testing.T) { client := mockBuilder(). Route("GET /json-api/parse_dns_zone", servermock.ResponseFromFixture("zone-info_error.json")). Build(t) zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.Error(t, err) assert.Nil(t, zoneInfo) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("GET /json-api/mass_edit_dns_zone", servermock.ResponseFromFixture("update-zone.json"), servermock.CheckQueryParameter().Strict(). With("add", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"]}`). With("api.version", "1"). With("serial", "123456"). With("zone", "example.com")). Build(t) record := shared.Record{ DName: "example", TTL: 14400, RecordType: "TXT", Data: []string{"string1", "string2"}, } zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} assert.Equal(t, expected, zoneSerial) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /json-api/mass_edit_dns_zone", servermock.ResponseFromFixture("update-zone_error.json")). Build(t) record := shared.Record{ DName: "example", TTL: 14400, RecordType: "TXT", Data: []string{"string1", "string2"}, } zoneSerial, err := client.AddRecord(t.Context(), 123456, "example.com", record) require.Error(t, err) assert.Nil(t, zoneSerial) } func TestClient_EditRecord(t *testing.T) { client := mockBuilder(). Route("GET /json-api/mass_edit_dns_zone", servermock.ResponseFromFixture("update-zone.json"), servermock.CheckQueryParameter().Strict(). With("edit", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"],"line_index":9}`). With("api.version", "1"). With("serial", "123456"). With("zone", "example.com")). Build(t) record := shared.Record{ LineIndex: 9, DName: "example", TTL: 14400, RecordType: "TXT", Data: []string{"string1", "string2"}, } zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} assert.Equal(t, expected, zoneSerial) } func TestClient_EditRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /json-api/mass_edit_dns_zone", servermock.ResponseFromFixture("update-zone_error.json")). Build(t) record := shared.Record{ LineIndex: 9, DName: "example", TTL: 14400, RecordType: "TXT", Data: []string{"string1", "string2"}, } zoneSerial, err := client.EditRecord(t.Context(), 123456, "example.com", record) require.Error(t, err) assert.Nil(t, zoneSerial) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("GET /json-api/mass_edit_dns_zone", servermock.ResponseFromFixture("update-zone.json"), servermock.CheckQueryParameter().Strict(). With("remove", "0"). With("api.version", "1"). With("serial", "123456"). With("zone", "example.com")). Build(t) zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.NoError(t, err) expected := &shared.ZoneSerial{NewSerial: "2021031903"} assert.Equal(t, expected, zoneSerial) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /json-api/mass_edit_dns_zone", servermock.ResponseFromFixture("update-zone_error.json")). Build(t) zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.Error(t, err) assert.Nil(t, zoneSerial) } ================================================ FILE: providers/dns/cpanel/internal/whm/fixtures/update-zone.json ================================================ { "data": { "new_serial": "2021031903" }, "metadata": { "command": "mass_edit_dns_zone", "reason": "OK", "result": 1, "version": 1 } } ================================================ FILE: providers/dns/cpanel/internal/whm/fixtures/update-zone_error.json ================================================ { "data": null, "metadata": { "command": "mass_edit_dns_zone", "reason": "There is a problem", "result": 0, "version": 1 } } ================================================ FILE: providers/dns/cpanel/internal/whm/fixtures/zone-info.json ================================================ { "data": { "payload": [ { "line_index": 22, "type": "record", "data_b64": [ "dGV4YXMuY29tLg==" ], "dname_b64": "dGV4YXMuY29tLg==", "record_type": "MX", "ttl": 14400 } ] }, "metadata": { "command": "parse_dns_zone", "reason": "OK", "result": 1, "version": 1 } } ================================================ FILE: providers/dns/cpanel/internal/whm/fixtures/zone-info_error.json ================================================ { "data": null, "metadata": { "command": "parse_dns_zone", "reason": "There is a problem", "result": 0, "version": 1 } } ================================================ FILE: providers/dns/cpanel/internal/whm/types.go ================================================ package whm import ( "fmt" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" ) type APIResponse[T any] struct { Metadata Metadata `json:"metadata"` Data T `json:"data,omitempty"` } type Metadata struct { Command string `json:"command,omitempty"` Reason string `json:"reason,omitempty"` Result int `json:"result,omitempty"` Version int `json:"version,omitempty"` } type ZoneData struct { Payload []shared.ZoneRecord `json:"payload,omitempty"` } func toError(m Metadata) error { return fmt.Errorf("%s error(%d): %s", m.Command, m.Result, m.Reason) } ================================================ FILE: providers/dns/czechia/czechia.go ================================================ // Package czechia implements a DNS provider for solving the DNS-01 challenge using Czechia. package czechia import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/czechia/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "CZECHIA_" EnvToken = envNamespace + "TOKEN" 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 { Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Czechia. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("czechia: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Czechia. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("czechia: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.Token) if err != nil { return nil, fmt.Errorf("czechia: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("czechia: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("czechia: %w", err) } record := internal.TXTRecord{ Hostname: subDomain, Text: info.Value, TTL: d.config.TTL, PublishZone: 1, } err = d.client.AddTXTRecord(ctx, dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("czechia: add TXT record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("czechia: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("czechia: %w", err) } record := internal.TXTRecord{ Hostname: subDomain, Text: info.Value, TTL: d.config.TTL, PublishZone: 1, } err = d.client.DeleteTXTRecord(ctx, dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("czechia: delete TXT record: %w", err) } return 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 } ================================================ FILE: providers/dns/czechia/czechia.toml ================================================ Name = "Czechia" Description = '''''' URL = "https://www.czechia.com/" Code = "czechia" Since = "v4.33.0" Example = ''' CZECHIA_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns czechia -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] CZECHIA_TOKEN = "Authorization token" [Configuration.Additional] CZECHIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" CZECHIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" CZECHIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" CZECHIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.czechia.com/swagger/index.html" ================================================ FILE: providers/dns/czechia/czechia_test.go ================================================ package czechia import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "czechia: some credentials information are missing: CZECHIA_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "secret", }, { desc: "missing credentials", expected: "czechia: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Token = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("AuthorizationToken", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /DNS/example.com/TXT", servermock.Noop(), servermock.CheckRequestJSONBodyFromInternal("add_txt_record-request.json"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /DNS/example.com/TXT", servermock.Noop(), servermock.CheckRequestJSONBodyFromInternal("add_txt_record-request.json"), ). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/czechia/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://api.czechia.com/api" const authorizationTokenHeader = "AuthorizationToken" // Client the Czechia API client. type Client struct { token string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(token string) (*Client, error) { if token == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddTXTRecord(ctx context.Context, domain string, record TXTRecord) error { endpoint := c.BaseURL.JoinPath("DNS", domain, "TXT") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } func (c *Client) DeleteTXTRecord(ctx context.Context, domain string, record TXTRecord) error { endpoint := c.BaseURL.JoinPath("DNS", domain, "TXT") req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, record) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) req.Header.Set(authorizationTokenHeader, c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/czechia/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). With(authorizationTokenHeader, "secret"), ) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /DNS/example.com/TXT", servermock.Noop(), servermock.CheckRequestJSONBodyFromFixture("add_txt_record-request.json"), ). Build(t) record := TXTRecord{ Hostname: "_acme-challenge", Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, PublishZone: 1, } err := client.AddTXTRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /DNS/example.com/TXT", servermock.Noop(), servermock.CheckRequestJSONBodyFromFixture("add_txt_record-request.json"), ). Build(t) record := TXTRecord{ Hostname: "_acme-challenge", Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, PublishZone: 1, } err := client.DeleteTXTRecord(t.Context(), "example.com", record) require.NoError(t, err) } ================================================ FILE: providers/dns/czechia/internal/fixtures/add_txt_record-request.json ================================================ { "hostName": "_acme-challenge", "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 120, "publishZone": 1 } ================================================ FILE: providers/dns/czechia/internal/fixtures/delete_txt_record-request.json ================================================ { "hostName": "_acme-challenge", "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 120, "publishZone": 1 } ================================================ FILE: providers/dns/czechia/internal/types.go ================================================ package internal type TXTRecord struct { Hostname string `json:"hostName,omitempty"` Text string `json:"text,omitempty"` TTL int `json:"ttl,omitempty"` PublishZone int `json:"publishZone,omitempty"` } ================================================ FILE: providers/dns/ddnss/ddnss.go ================================================ // Package ddnss implements a DNS provider for solving the DNS-01 challenge using DynDNS Service. package ddnss import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/ddnss/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DDNSS_" EnvKey = envNamespace + "KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) // Config is used to configure the creation of the DNSProvider. type Config struct { Key string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration TTL int 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), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for DynDNS Service. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvKey) if err != nil { return nil, fmt.Errorf("ddnss: %w", err) } config := NewDefaultConfig() config.Key = values[EnvKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DynDNS Service. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ddnss: the configuration of the DNS provider is nil") } client, err := internal.NewClient(&internal.Authentication{Key: config.Key}) if err != nil { return nil, fmt.Errorf("ddnss: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("ddnss: add TXT record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.RemoveTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("ddnss: remove TXT record: %w", err) } return 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/ddnss/ddnss.toml ================================================ Name = "DDnss (DynDNS Service)" Description = '''''' URL = "https://ddnss.de/" Code = "ddnss" Since = "v4.32.0" Example = ''' DDNSS_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns ddnss -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DDNSS_KEY = "Update key" [Configuration.Additional] DDNSS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" DDNSS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DDNSS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" DDNSS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" DDNSS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://ddnss.de/info.php" ================================================ FILE: providers/dns/ddnss/ddnss_test.go ================================================ package ddnss import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvKey: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "ddnss: some credentials information are missing: DDNSS_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string Key string expected string }{ { desc: "success", Key: "secret", }, { desc: "missing credentials", expected: "ddnss: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Key = test.Key p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Key = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL = server.URL return p, nil }, ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /", servermock.ResponseFromInternal("success.html"), servermock.CheckQueryParameter().Strict(). With("host", "_acme-challenge.example.com"). With("key", "secret"). With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). With("txtm", "1"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /", servermock.ResponseFromInternal("success.html"), servermock.CheckQueryParameter().Strict(). With("host", "_acme-challenge.example.com"). With("key", "secret"). With("txtm", "2"), ). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/ddnss/internal/client.go ================================================ package internal import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "golang.org/x/net/html" ) const defaultBaseURL = "https://ddnss.de/upd.php" // Client the DDns API client. type Client struct { auth *Authentication BaseURL string HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(auth *Authentication) (*Client, error) { if auth == nil { return nil, errors.New("credentials missing") } err := auth.validate() if err != nil { return nil, err } return &Client{ auth: auth, BaseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddTXTRecord(ctx context.Context, host, value string) error { return c.update(ctx, map[string]string{ "host": host, "txt": value, "txtm": "1", }) } func (c *Client) RemoveTXTRecord(ctx context.Context, host string) error { return c.update(ctx, map[string]string{ "host": host, "txtm": "2", }) } func (c *Client) update(ctx context.Context, params map[string]string) error { endpoint, err := url.Parse(c.BaseURL) if err != nil { return err } query := endpoint.Query() for k, v := range params { query.Set(k, v) } c.auth.set(query) endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return fmt.Errorf("unable to create request: %w", err) } useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } content, err := readPage(raw) if err != nil { return err } if strings.Contains(content, "Updated 1 hostname.") { return nil } return fmt.Errorf("unexpected response: %s", content) } func readPage(raw []byte) (string, error) { page, err := html.Parse(strings.NewReader(string(raw))) if err != nil { return "", err } var b strings.Builder extractText(page, &b) return strings.TrimSpace(b.String()), nil } func extractText(n *html.Node, b *strings.Builder) { if n.Type == html.TextNode { text := strings.TrimSpace(n.Data) if text != "" { b.WriteString(text + " ") } } for c := n.FirstChild; c != nil; c = c.NextSibling { extractText(c, b) } } ================================================ FILE: providers/dns/ddnss/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(&Authentication{Key: "secret"}) if err != nil { return nil, err } client.BaseURL = server.URL client.HTTPClient = server.Client() return client, nil }, ) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("success.html"), servermock.CheckQueryParameter().Strict(). With("host", "_acme-challenge.example.com"). With("key", "secret"). With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). With("txtm", "1"), ). Build(t) err := client.AddTXTRecord(t.Context(), "_acme-challenge.example.com", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY") require.NoError(t, err) } func TestClient_RemoveTXTRecord(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("success.html"), servermock.CheckQueryParameter().Strict(). With("host", "_acme-challenge.example.com"). With("key", "secret"). With("txtm", "2"), ). Build(t) err := client.RemoveTXTRecord(t.Context(), "_acme-challenge.example.com") require.NoError(t, err) } ================================================ FILE: providers/dns/ddnss/internal/fixtures/error.html ================================================ DDNSS - Kostenloser DynDNS Service : Re-ProutDNS v5.01v

Error Occurred While Processing Request :

- badysys : Der System Parameter ist ungültig.
- badauth : Die Authorisation ist fehlgeschlagen. Die Parameter username und/oder password sind falsch.
- notfqdn : Hostname fehlt oder ist falsch.
================================================ FILE: providers/dns/ddnss/internal/fixtures/success.html ================================================ DDNSS - Kostenloser DynDNS Service : Re-ProutDNS v5.01v

Updated 1 hostname.

================================================ FILE: providers/dns/ddnss/internal/types.go ================================================ package internal import ( "errors" "net/url" ) type Authentication struct { Username string `url:"user,omitempty"` Password string `url:"pwd,omitempty"` Key string `url:"key,omitempty"` } func (a *Authentication) validate() error { if a.Username == "" && a.Password == "" && a.Key == "" { return errors.New("missing credentials") } if a.Username != "" && a.Password != "" && a.Key != "" { return errors.New("only one of username, password or key can be set") } if (a.Username != "" && a.Password == "") || a.Username == "" && a.Password != "" { return errors.New("username and password must be set together") } return nil } func (a *Authentication) set(query url.Values) { if a.Key != "" { query.Set("key", a.Key) return } query.Set("user", a.Username) query.Set("pwd", a.Password) } ================================================ FILE: providers/dns/derak/derak.go ================================================ // Package derak implements a DNS provider for solving the DNS-01 challenge using Derak Cloud. package derak import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/derak/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/miekg/dns" ) // Environment variables names. const ( envNamespace = "DERAK_" EnvAPIKey = envNamespace + "API_KEY" EnvWebsiteID = envNamespace + "WEBSITE_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string WebsiteID string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Derak Cloud. // Credentials must be passed in the environment variable: DERAK_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("derak: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.WebsiteID = env.GetOrDefaultString(EnvWebsiteID, "") return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Derak Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("derak: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("derak: missing credentials") } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("derak: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("derak: %w", err) } zoneID, err := d.getZoneID(ctx, info) if err != nil { return fmt.Errorf("derak: get zone ID: %w", err) } r := internal.Record{ Type: "TXT", Host: recordName, Content: info.Value, TTL: d.config.TTL, } record, err := d.client.CreateRecord(ctx, zoneID, r) if err != nil { return fmt.Errorf("derak: create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = record.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zoneID, err := d.getZoneID(ctx, info) if err != nil { return fmt.Errorf("derak: get zone ID: %w", err) } // gets the record's unique ID d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("derak: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.DeleteRecord(ctx, zoneID, recordID) if err != nil { return fmt.Errorf("derak: delete record: %w", err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) getZoneID(ctx context.Context, info dns01.ChallengeInfo) (string, error) { zoneID := d.config.WebsiteID if zoneID != "" { return zoneID, nil } zones, err := d.client.GetZones(ctx) if err != nil { return "", fmt.Errorf("get zones: %w", err) } for _, zone := range zones { if strings.HasSuffix(info.EffectiveFQDN, dns.Fqdn(zone.HumanReadable)) { return zone.ID, nil } } return "", fmt.Errorf("zone/website not found %s", info.EffectiveFQDN) } ================================================ FILE: providers/dns/derak/derak.toml ================================================ Name = "Derak Cloud" Description = '''''' URL = "https://derak.cloud/" Code = "derak" Since = "v4.12.0" Example = ''' DERAK_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns derak -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DERAK_API_KEY = "The API key" [Configuration.Additional] DERAK_WEBSITE_ID = "Force the zone/website ID" DERAK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" DERAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" DERAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" DERAK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" ================================================ FILE: providers/dns/derak/derak_test.go ================================================ package derak import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvWebsiteID).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", }, }, { desc: "missing API key", envVars: map[string]string{}, expected: "derak: some credentials information are missing: DERAK_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "key", }, { desc: "missing API key", expected: "derak: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/derak/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://api.derak.cloud/v1.0" type Client struct { baseURL *url.URL HTTPClient *http.Client zoneEndpoint string apiKey string } func NewClient(apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ HTTPClient: &http.Client{Timeout: 10 * time.Second}, baseURL: baseURL, zoneEndpoint: "https://api.derak.cloud/api/v2/service/cdn/zones", apiKey: apiKey, } } // GetRecords gets all records. // Note: the response is not influenced by the query parameters, so the documentation seems wrong. func (c *Client) GetRecords(ctx context.Context, zoneID string, params *GetRecordsParameters) (*GetRecordsResponse, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords") v, err := querystring.Values(params) if err != nil { return nil, err } endpoint.RawQuery = v.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } response := &GetRecordsResponse{} err = c.do(req, response) if err != nil { return nil, err } return response, nil } // GetRecord gets a record by ID. func (c *Client) GetRecord(ctx context.Context, zoneID, recordID string) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } response := &Record{} err = c.do(req, response) if err != nil { return nil, err } return response, nil } // CreateRecord creates a new record. func (c *Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record) if err != nil { return nil, err } response := &Record{} err = c.do(req, response) if err != nil { return nil, err } return response, nil } // EditRecord edits an existing record. func (c *Client) EditRecord(ctx context.Context, zoneID, recordID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, record) if err != nil { return nil, err } response := &Record{} err = c.do(req, response) if err != nil { return nil, err } return response, nil } // DeleteRecord deletes an existing record. func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } response := &APIResponse[any]{} err = c.do(req, response) if err != nil { return err } if !response.Success { return fmt.Errorf("API error: %d %s", response.Error, codeText(response.Error)) } return nil } // GetZones gets zones. // Note: it's not a part of the official API, there is no documentation about this. // The endpoint comes from UI calls analysis. func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.zoneEndpoint, http.NoBody) if err != nil { return nil, err } response := &APIResponse[[]Zone]{} err = c.do(req, response) if err != nil { return nil, err } if !response.Success { return nil, fmt.Errorf("API error: %d %s", response.Error, codeText(response.Error)) } return response.Result, nil } func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth("api", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() switch req.Method { case http.MethodPut: if resp.StatusCode != http.StatusCreated { return parseError(req, resp) } default: if resp.StatusCode != http.StatusOK { return parseError(req, resp) } } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIResponse[any] err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %d: %s", resp.StatusCode, response.Error, codeText(response.Error)) } ================================================ FILE: providers/dns/derak/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.baseURL, _ = url.Parse(server.URL) client.zoneEndpoint = server.URL client.HTTPClient = server.Client() return client, nil } func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("api", "secret")) } func TestGetRecords(t *testing.T) { client := mockBuilder(). Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("records-GET.json")). Build(t) records, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) require.NoError(t, err) excepted := &GetRecordsResponse{Data: []Record{ { Type: "A", Host: "example.com", Content: "188.114.97.3", ID: "812bee17a0b440b0bd5ee099a78b839c", }, { Type: "A", Host: "example.com", Content: "188.114.96.3", ID: "90e6029da45d4a36bf31056cf85d0cab", }, { Type: "AAAA", Host: "example.com", Content: "2a06:98c1:3121::7", ID: "0ac0320da0d24b5ca4f1648986a17340", }, { Type: "AAAA", Host: "example.com", Content: "2a06:98c1:3120::7", ID: "c91599694aea413498a0b3cd0a54a585", }, { Type: "A", Host: "www", Content: "188.114.96.7", ID: "c21f974992d549499f92e768bc468374", }, { Type: "A", Host: "www", Content: "188.114.97.7", ID: "90c3c1f05dca426893f10f122d18ad7a", }, { Type: "AAAA", Host: "www", Content: "2a06:98c1:3121::", ID: "379ab0ac0e434bc9aee5287e497f88a5", }, { Type: "AAAA", Host: "www", Content: "2a06:98c1:3120::", ID: "a1c4f9e50ba74791a4d70dc96999474c", }, }, Count: 8} assert.Equal(t, excepted, records) } func TestGetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) require.Error(t, err) } func TestGetRecord(t *testing.T) { client := mockBuilder(). Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c", servermock.ResponseFromFixture("record-GET.json")). Build(t) record, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") require.NoError(t, err) excepted := &Record{ Type: "A", Host: "example.com", Content: "188.114.97.3", ID: "812bee17a0b440b0bd5ee099a78b839c", } assert.Equal(t, excepted, record) } func TestGetRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") require.Error(t, err) } func TestCreateRecord(t *testing.T) { client := mockBuilder(). Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("record-PUT.json"). WithStatusCode(http.StatusCreated)). Build(t) r := Record{ Type: "TXT", Host: "test", Content: "test", TTL: 120, } record, err := client.CreateRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", r) require.NoError(t, err) excepted := &Record{ Type: "A", Host: "example.com", Content: "188.114.97.3", ID: "812bee17a0b440b0bd5ee099a78b839c", } assert.Equal(t, excepted, record) } func TestCreateRecord_error(t *testing.T) { client := mockBuilder(). Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) r := Record{ Type: "TXT", Host: "test", Content: "test", TTL: 120, } _, err := client.CreateRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", r) require.Error(t, err) } func TestEditRecord(t *testing.T) { client := mockBuilder(). Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", servermock.ResponseFromFixture("record-PATCH.json")). Build(t) record, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ Content: "foo", }) require.NoError(t, err) excepted := &Record{ Type: "A", Host: "example.com", Content: "188.114.97.3", ID: "812bee17a0b440b0bd5ee099a78b839c", } assert.Equal(t, excepted, record) } func TestEditRecord_error(t *testing.T) { client := mockBuilder(). Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ Content: "foo", }) require.Error(t, err) } func TestDeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", servermock.ResponseFromFixture("record-DELETE.json")). Build(t) err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") require.NoError(t, err) } func TestDeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") require.Error(t, err) } func TestGetZones(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader(). WithBasicAuth("api", "secret"), ). Route("GET /", servermock.ResponseFromFixture("service-cdn-zones.json")). Build(t) zones, err := client.GetZones(t.Context()) require.NoError(t, err) excepted := []Zone{{ ID: "47c0ecf6c91243308c649ad1d2d618dd", Tags: []string{}, ContextID: "47c0ecf6c91243308c649ad1d2d618dd", ContextType: "CDN", HumanReadable: "example.com", Serial: "2301449956", CreationTime: 1679090659902, CreationTimeDate: time.Date(2023, time.March, 17, 22, 4, 19, 902000000, time.UTC), Status: "active", IsMoved: true, Paused: false, ServiceType: "CDN", Limbo: false, TeamName: "test", TeamID: "640ef58496738d38fa7246a4", MyTeam: true, RoleName: "owner", IsBoard: true, BoardRole: []string{"owner"}, }} assert.Equal(t, excepted, zones) } func TestGetZones_error(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetZones(t.Context()) require.Error(t, err) } ================================================ FILE: providers/dns/derak/internal/fixtures/error.json ================================================ {"success":false,"error":1010} ================================================ FILE: providers/dns/derak/internal/fixtures/record-DELETE.json ================================================ { "success": true } ================================================ FILE: providers/dns/derak/internal/fixtures/record-GET.json ================================================ { "recordId": "812bee17a0b440b0bd5ee099a78b839c", "type": "A", "host": "example.com", "content": "188.114.97.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" } ================================================ FILE: providers/dns/derak/internal/fixtures/record-PATCH.json ================================================ { "recordId": "812bee17a0b440b0bd5ee099a78b839c", "type": "A", "host": "example.com", "content": "188.114.97.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" } ================================================ FILE: providers/dns/derak/internal/fixtures/record-PUT.json ================================================ { "recordId": "812bee17a0b440b0bd5ee099a78b839c", "type": "A", "host": "example.com", "content": "188.114.97.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" } ================================================ FILE: providers/dns/derak/internal/fixtures/records-GET.json ================================================ { "data": [ { "recordId": "812bee17a0b440b0bd5ee099a78b839c", "type": "A", "host": "example.com", "content": "188.114.97.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "90e6029da45d4a36bf31056cf85d0cab", "type": "A", "host": "example.com", "content": "188.114.96.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "0ac0320da0d24b5ca4f1648986a17340", "type": "AAAA", "host": "example.com", "content": "2a06:98c1:3121::7", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "c91599694aea413498a0b3cd0a54a585", "type": "AAAA", "host": "example.com", "content": "2a06:98c1:3120::7", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "c21f974992d549499f92e768bc468374", "type": "A", "host": "www", "content": "188.114.96.7", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "90c3c1f05dca426893f10f122d18ad7a", "type": "A", "host": "www", "content": "188.114.97.7", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "379ab0ac0e434bc9aee5287e497f88a5", "type": "AAAA", "host": "www", "content": "2a06:98c1:3121::", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "a1c4f9e50ba74791a4d70dc96999474c", "type": "AAAA", "host": "www", "content": "2a06:98c1:3120::", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" } ], "count": 8 } ================================================ FILE: providers/dns/derak/internal/fixtures/service-cdn-zones.json ================================================ { "success": true, "result": [ { "zoneId": "47c0ecf6c91243308c649ad1d2d618dd", "tags": [], "contextId": "47c0ecf6c91243308c649ad1d2d618dd", "contextType": "CDN", "humanReadable": "example.com", "serial": "2301449956", "creationTime": 1679090659902, "creationTimeDate": "2023-03-17T22:04:19.902Z", "status": "active", "is_moved": true, "paused": false, "cache": { "developmentMode": false }, "securityOptions": { "level": "off" }, "ssl": { "active": true }, "dns": { "length": 8 }, "serviceType": "CDN", "limbo": false, "teamName": "test", "teamId": "640ef58496738d38fa7246a4", "myTeam": true, "roleName": "owner", "isBoard": true, "boardRole": [ "owner" ] } ] } ================================================ FILE: providers/dns/derak/internal/readme.md ================================================ # Notes ## Forum - https://derak.cloud/faq/programming/%da%86%da%af%d9%88%d9%86%d9%87-%d9%85%db%8c%d8%aa%d9%88%d8%a7%d9%86-%d8%a8%d9%87-api%d9%87%d8%a7-%d8%af%d8%b3%d8%aa%d8%b1%d8%b3%db%8c-%d8%af%d8%a7%d8%b4%d8%aa%d8%9f/ - https://derak.cloud/faq/programming/%d8%af%d8%b1%db%8c%d8%a7%d9%81%d8%aa-%da%a9%d9%84%db%8c%d8%af-api-api-key/ --- ## DNS records (API) ### GET: Get a list of all DNS records ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords` #### Query | The name of the parameter | Description | |---------------------------|----------------------------------| | dnsType | dnsType query | | content | The Host value of the DNS record | #### Errors | type error | Error code | |-------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | #### Example ```bash curl -X GET --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords ``` ```bash curl -X GET --user "api:api-MbmnxdpIBvk14nk5LFFdG1CV9PdMDfqi3tZAixBZLXYzM3qc187d7ede2de" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords \ -F dnsType="TXT" ``` ### PUT: Creating a new DNS record on the desired website ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords` #### parameters | The name of the parameter | Description | |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | *type | DNS record type [of types Aand AAAAand CNAMEand MXand NSand CAAand TXTand SPFand PTRand SRV] | | *host | The Host value of the DNS record | | *content | The Host value of the DNS record | | ttl | TTL of DNS record [default: 0] | | cloud | This parameter specifies whether the traffic of this record passes through the cloud or not [Default: false] | | priority | Priority of MX and SRV records [Default: 0] | | service | SRV record service | | protocol | SRV record protocol [default: _tcp] | | weight | SRV Record Weight [Default: 0] | | port | Priority of MX and SRV records [Default: 0] | | advanced | This parameter specifies whether this record has advanced settings or not [default: false] | | upstreamPort | Upstream Port of DNS record [Default: 80] | | upstreamProtocol | Upstream protocol related to DNS records. Note that if you change these settings for another record of the same subdomain, the settings will be overwritten. [Default: http] | | customSSLType | Custom SSL related DNS record. Note that if you change these settings for another record of the same subdomain, the settings will be overwritten. | #### Errors | type error | Error code | |--------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | | DNSValidationError | 1008 | #### Example ```bash curl -X PUT --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords \ -F type="A" \ -F host="app" \ -F content="1.2.3.4" ``` ### GET: Get the information of a single DNS record ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId` #### Errors | type error | Error code | |---------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | | RecordNotFoundError | 1021 | #### Example ```bash curl -X GET --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId ``` ### PATCH: Edit the parameters of a DNS record `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId` #### parameters | The name of the parameter | Description | |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | type | DNS record type [of types Aand AAAAand CNAMEand MXand NSand CAAand TXTand SPFand PTRand SRV] | | host | The Host value of the DNS record | | content | The Host value of the DNS record | | ttl | TTL of DNS record [default: 0] | | cloud | This parameter specifies whether the traffic of this record passes through the cloud or not [Default: false] | | priority | Priority of MX and SRV records [Default: 0] | | service | SRV record service | | protocol | SRV record protocol [default: _tcp] | | weight | SRV Record Weight [Default: 0] | | port | Priority of MX and SRV records [Default: 0] | | advanced | This parameter specifies whether this record has advanced settings or not [default: false] | | upstreamPort | Upstream Port of DNS record [Default: 80] | | upstreamProtocol | Upstream protocol related to DNS records. Note that if you change these settings for another record of the same subdomain, the settings will be overwritten. [Default: http] | | customSSLType | Custom SSL related DNS record. Note that if you change these settings for another record of the same subdomain, the settings will be overwritten. | #### Errors | type error | Error code | |---------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | | RecordNotFoundError | 1021 | | DNSValidationError | 1008 | #### Example ```bash curl -X PATCH --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId \ -F cloud="true" ``` ### DELETE: Delete a DNS record ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId` #### Errors | type error | Error code | |---------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | | RecordNotFoundError | 1021 | #### Example ```bash curl -X DELETE --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId ``` --- ## Cache clearing (API) ### POST: Clearing (Purge Cache) specified parameters, if no parameter is specified, the entire cache is deleted. ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/cache/purge` #### parameters | The name of the parameter | Description | |---------------------------|-------------------------------------| | hostname | The hostname to be deleted | | hostnames | An array of hostnames to be cleared | | url | The URL to be deleted | | urls | An array of URLs to be purged | #### Errors | type error | Error code | |-------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | #### Examples Purge URLS: ```bash curl -X POST --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/cache/purge \ -F urls[]="https://www.derak.cloud/post/1" \ -F urls[]="https://www.derak.cloud/post/2" ``` Purge HOSTNAMES: ```bash curl -X POST --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/cache/purge \ -F hostnames[]="www.derak.cloud" \ -F hostnames[]="app.derak.cloud" ``` Purge EVERYTHING: ```bash curl -X POST --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/cache/purge ``` --- ## API for SSL certificates ### PUT: Enable SSL for a domain ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/ssl/` #### Errors | type error | Error code | |----------------|------------| | ForbiddenError | 1003 | #### Example ```bash curl -X PUT --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/ssl/ ``` ### DELETE: Disable SSL for a domain ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/ssl/` #### Errors | type error | Error code | |----------------|------------| | ForbiddenError | 1003 | #### Example ```bash curl -X DELETE --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/ssl/ ``` ================================================ FILE: providers/dns/derak/internal/types.go ================================================ package internal import "time" type GetRecordsParameters struct { DNSType string `url:"dnsType,omitempty"` Content string `url:"content,omitempty"` } type GetRecordsResponse struct { Data []Record `json:"data"` Count int `json:"count"` } type Record struct { Type string `json:"type,omitempty"` Host string `json:"host,omitempty"` Content string `json:"content,omitempty"` ID string `json:"recordId,omitempty"` TTL int `json:"ttl,omitempty"` Cloud bool `json:"cloud,omitempty"` Priority int `json:"priority,omitempty"` Service string `json:"service,omitempty"` Protocol string `json:"protocol,omitempty"` Weight int `json:"weight,omitempty"` Port int `json:"port,omitempty"` Advanced bool `json:"advanced,omitempty"` UpstreamPort int `json:"upstreamPort,omitempty"` UpstreamProtocol string `json:"upstreamProtocol,omitempty"` CustomSSLType string `json:"customSSLType,omitempty"` } type APIResponse[T any] struct { Success bool `json:"success"` Result T `json:"result"` Error int `json:"error"` } type Zone struct { ID string `json:"zoneId,omitempty"` Tags []string `json:"tags,omitempty"` ContextID string `json:"contextId,omitempty"` ContextType string `json:"contextType,omitempty"` HumanReadable string `json:"humanReadable,omitempty"` Serial string `json:"serial,omitempty"` CreationTime int64 `json:"creationTime,omitempty"` CreationTimeDate time.Time `json:"creationTimeDate,omitzero"` Status string `json:"status,omitempty"` IsMoved bool `json:"is_moved,omitempty"` Paused bool `json:"paused,omitempty"` ServiceType string `json:"serviceType,omitempty"` Limbo bool `json:"limbo,omitempty"` TeamName string `json:"teamName,omitempty"` TeamID string `json:"teamId,omitempty"` MyTeam bool `json:"myTeam,omitempty"` RoleName string `json:"roleName,omitempty"` IsBoard bool `json:"isBoard,omitempty"` BoardRole []string `json:"boardRole,omitempty"` } func codeText(code int) string { switch code { case 1008: return "DNSValidationError" case 1003: return "ForbiddenError" case 1013: return "RateLimitExceeded" case 1021: return "RecordNotFoundError" default: return "" } } ================================================ FILE: providers/dns/desec/desec.go ================================================ // Package desec implements a DNS provider for solving the DNS-01 challenge using deSEC DNS. package desec import ( "context" "errors" "fmt" "log" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/desec" ) // Environment variables names. const ( envNamespace = "DESEC_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // https://github.com/desec-io/desec-stack/issues/216 // https://desec.readthedocs.io/_/downloads/en/latest/pdf/ const defaultTTL int = 3600 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *desec.Client } // NewDNSProvider returns a DNSProvider instance configured for deSEC. // Credentials must be passed in the environment variable: DESEC_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("desec: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for deSEC. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("desec: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("desec: incomplete credentials, missing token") } opts := desec.NewDefaultClientOptions() if config.HTTPClient != nil { opts.HTTPClient = config.HTTPClient } opts.HTTPClient = clientdebug.Wrap(opts.HTTPClient) opts.Logger = log.Default() client := desec.New(config.Token, opts) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("desec: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("desec: %w", err) } domainName := dns01.UnFqdn(authZone) quotedValue := fmt.Sprintf(`%q`, info.Value) rrSet, err := d.client.Records.Get(ctx, domainName, recordName, "TXT") if err != nil { var nf *desec.NotFoundError if !errors.As(err, &nf) { return fmt.Errorf("desec: failed to get records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } // Not found case -> create _, err = d.client.Records.Create(ctx, desec.RRSet{ Domain: domainName, SubName: recordName, Type: "TXT", Records: []string{quotedValue}, TTL: d.config.TTL, }) if err != nil { return fmt.Errorf("desec: failed to create records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } return nil } // update records := append(rrSet.Records, quotedValue) _, err = d.client.Records.Update(ctx, domainName, recordName, "TXT", desec.RRSet{Records: records}) if err != nil { return fmt.Errorf("desec: failed to update records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("desec: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("desec: %w", err) } domainName := dns01.UnFqdn(authZone) rrSet, err := d.client.Records.Get(ctx, domainName, recordName, "TXT") if err != nil { return fmt.Errorf("desec: failed to get records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } records := make([]string, 0) for _, record := range rrSet.Records { if record != fmt.Sprintf(`%q`, info.Value) { records = append(records, record) } } _, err = d.client.Records.Update(ctx, domainName, recordName, "TXT", desec.RRSet{Records: records}) if err != nil { return fmt.Errorf("desec: failed to update records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } return nil } ================================================ FILE: providers/dns/desec/desec.toml ================================================ Name = "deSEC.io" Description = '''''' URL = "https://desec.io" Code = "desec" Since = "v3.7.0" Example = ''' DESEC_TOKEN=x-xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns desec -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DESEC_TOKEN = "Domain token" [Configuration.Additional] DESEC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" DESEC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" DESEC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" DESEC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://desec.readthedocs.io/en/latest/" ================================================ FILE: providers/dns/desec/desec_test.go ================================================ package desec import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvToken: "", }, expected: "desec: some credentials information are missing: DESEC_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string token string }{ { desc: "success", token: "api_key", }, { desc: "missing credentials", expected: "desec: incomplete credentials, missing token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/designate/designate.go ================================================ // Package designate implements a DNS provider for solving the DNS-01 challenge using the Designate DNSaaS for Openstack. package designate import ( "errors" "fmt" "log" "os" "slices" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/gophercloud/gophercloud" "github.com/gophercloud/gophercloud/openstack" "github.com/gophercloud/gophercloud/openstack/dns/v2/recordsets" "github.com/gophercloud/gophercloud/openstack/dns/v2/zones" "github.com/gophercloud/utils/openstack/clientconfig" ) // Environment variables names. const ( envNamespace = "DESIGNATE_" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvZoneName = envNamespace + "ZONE_NAME" envNamespaceClient = "OS_" EnvAuthURL = envNamespaceClient + "AUTH_URL" EnvUsername = envNamespaceClient + "USERNAME" EnvPassword = envNamespaceClient + "PASSWORD" EnvUserID = envNamespaceClient + "USER_ID" EnvAppCredID = envNamespaceClient + "APPLICATION_CREDENTIAL_ID" EnvAppCredName = envNamespaceClient + "APPLICATION_CREDENTIAL_NAME" EnvAppCredSecret = envNamespaceClient + "APPLICATION_CREDENTIAL_SECRET" EnvTenantName = envNamespaceClient + "TENANT_NAME" EnvRegionName = envNamespaceClient + "REGION_NAME" EnvProjectID = envNamespaceClient + "PROJECT_ID" EnvCloud = envNamespaceClient + "CLOUD" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ZoneName string PropagationTimeout time.Duration PollingInterval time.Duration TTL int opts gophercloud.AuthOptions } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, 10), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *gophercloud.ServiceClient dnsEntriesMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Designate. // Credentials must be passed in the environment variables: // OS_AUTH_URL, OS_USERNAME, OS_PASSWORD, OS_REGION_NAME. // Or you can specify OS_CLOUD to read the credentials from the according cloud entry. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() val, err := env.Get(EnvCloud) if err == nil { opts, erro := clientconfig.AuthOptions(&clientconfig.ClientOpts{ Cloud: val[EnvCloud], }) if erro != nil { return nil, fmt.Errorf("designate: %w", erro) } config.opts = *opts } else { opts, err := openstack.AuthOptionsFromEnv() if err != nil { return nil, fmt.Errorf("designate: %w", err) } config.opts = opts } return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Designate. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("designate: the configuration of the DNS provider is nil") } provider, err := openstack.AuthenticatedClient(config.opts) if err != nil { return nil, fmt.Errorf("designate: failed to authenticate: %w", err) } dnsClient, err := openstack.NewDNSV2(provider, gophercloud.EndpointOpts{ Region: os.Getenv("OS_REGION_NAME"), }) if err != nil { return nil, fmt.Errorf("designate: failed to get DNS provider: %w", err) } return &DNSProvider{client: dnsClient, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("designate: %w", err) } zoneID, err := d.getZoneID(zone) if err != nil { return fmt.Errorf("designate: couldn't get zone ID in Present: %w", err) } // use mutex to prevent race condition between creating the record and verifying it d.dnsEntriesMu.Lock() defer d.dnsEntriesMu.Unlock() existingRecord, err := d.getRecord(zoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("designate: %w", err) } if existingRecord != nil { if slices.Contains(existingRecord.Records, info.Value) { log.Printf("designate: the record already exists: %s", info.Value) return nil } return d.updateRecord(existingRecord, info.Value) } err = d.createRecord(zoneID, info.EffectiveFQDN, info.Value) if err != nil { return fmt.Errorf("designate: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("designate: %w", err) } zoneID, err := d.getZoneID(zone) if err != nil { return fmt.Errorf("designate: couldn't get zone ID in CleanUp: %w", err) } // use mutex to prevent race condition between getting the record and deleting it d.dnsEntriesMu.Lock() defer d.dnsEntriesMu.Unlock() record, err := d.getRecord(zoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("designate: couldn't get Record ID in CleanUp: %w", err) } if record == nil { // Record is already deleted return nil } err = recordsets.Delete(d.client, zoneID, record.ID).ExtractErr() if err != nil { return fmt.Errorf("designate: error for %s in CleanUp: %w", info.EffectiveFQDN, err) } return nil } func (d *DNSProvider) createRecord(zoneID, fqdn, value string) error { createOpts := recordsets.CreateOpts{ Name: fqdn, Type: "TXT", TTL: d.config.TTL, Description: "ACME verification record", Records: []string{value}, } actual, err := recordsets.Create(d.client, zoneID, createOpts).Extract() if err != nil { return fmt.Errorf("error for %s in Present while creating record: %w", fqdn, err) } if actual.Name != fqdn || actual.TTL != d.config.TTL { return errors.New("the created record doesn't match what we wanted to create") } return nil } func (d *DNSProvider) updateRecord(record *recordsets.RecordSet, value string) error { if slices.Contains(record.Records, value) { log.Printf("skip: the record already exists: %s", value) return nil } values := append([]string{value}, record.Records...) updateOpts := recordsets.UpdateOpts{ Description: &record.Description, TTL: &record.TTL, Records: values, } result := recordsets.Update(d.client, record.ZoneID, record.ID, updateOpts) return result.Err } func (d *DNSProvider) getZoneID(wanted string) (string, error) { listOpts := zones.ListOpts{ Name: wanted, } allPages, err := zones.List(d.client, listOpts).AllPages() if err != nil { return "", err } allZones, err := zones.ExtractZones(allPages) if err != nil { return "", err } for _, zone := range allZones { if zone.Name == wanted { return zone.ID, nil } } return "", fmt.Errorf("zone id not found for %s", wanted) } func (d *DNSProvider) getRecord(zoneID, wanted string) (*recordsets.RecordSet, error) { listOpts := recordsets.ListOpts{ Name: wanted, Type: "TXT", } allPages, err := recordsets.ListByZone(d.client, zoneID, listOpts).AllPages() if err != nil { return nil, err } allRecords, err := recordsets.ExtractRecordSets(allPages) if err != nil { return nil, err } for _, record := range allRecords { if record.Name == wanted && record.Type == "TXT" { return &record, nil } } return nil, nil } func (d *DNSProvider) getZoneName(fqdn string) (string, error) { if d.config.ZoneName != "" { return d.config.ZoneName, nil } authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) } if authZone == "" { return "", errors.New("empty zone name") } return authZone, nil } ================================================ FILE: providers/dns/designate/designate.toml ================================================ Name = "Designate DNSaaS for Openstack" Description = '''''' URL = "https://docs.openstack.org/designate/latest/" Code = "designate" Since = "v2.2.0" Example = ''' # With a `clouds.yaml` OS_CLOUD=my_openstack \ lego --dns designate -d '*.example.com' -d example.com run # or OS_AUTH_URL=https://openstack.example.org \ OS_REGION_NAME=RegionOne \ OS_PROJECT_ID=23d4522a987d4ab529f722a007c27846 OS_USERNAME=myuser \ OS_PASSWORD=passw0rd \ lego --dns designate -d '*.example.com' -d example.com run # or OS_AUTH_URL=https://openstack.example.org \ OS_REGION_NAME=RegionOne \ OS_AUTH_TYPE=v3applicationcredential \ OS_APPLICATION_CREDENTIAL_ID=imn74uq0or7dyzz20dwo1ytls4me8dry \ OS_APPLICATION_CREDENTIAL_SECRET=68FuSPSdQqkFQYH5X1OoriEIJOwyLtQ8QSqXZOc9XxFK1A9tzZT6He2PfPw0OMja \ lego --dns designate -d '*.example.com' -d example.com run ''' Additional = ''' ## Description There are three main ways of authenticating with Designate: 1. The first one is by using the `OS_CLOUD` environment variable and a `clouds.yaml` file. 2. The second one is using your username and password, via the `OS_USERNAME`, `OS_PASSWORD` and `OS_PROJECT_NAME` environment variables. 3. The third one is by using an application credential, via the `OS_APPLICATION_CREDENTIAL_*` and `OS_USER_ID` environment variables. For the username/password and application methods, the `OS_AUTH_URL` and `OS_REGION_NAME` environment variables are required. For more information, you can read about the different methods of authentication with OpenStack in the Keystone's documentation and the gophercloud documentation: - [Keystone username/password](https://docs.openstack.org/keystone/latest/user/supported_clients.html) - [Keystone application credentials](https://docs.openstack.org/keystone/latest/user/application_credentials.html) Public cloud providers with support for Designate: - [Fuga Cloud](https://fuga.cloud/) ''' [Configuration] [Configuration.Credentials] OS_AUTH_URL = "Identity endpoint URL" OS_USERNAME = "Username" OS_PASSWORD = "Password" OS_USER_ID = "User ID" OS_APPLICATION_CREDENTIAL_ID = "Application credential ID" OS_APPLICATION_CREDENTIAL_NAME = "Application credential name" OS_APPLICATION_CREDENTIAL_SECRET = "Application credential secret" OS_PROJECT_NAME = "Project name" OS_REGION_NAME = "Region name" [Configuration.Additional] OS_PROJECT_ID = "Project ID" OS_TENANT_NAME = "Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID)" DESIGNATE_ZONE_NAME = "The zone name to use in the OpenStack Project to manage TXT records." DESIGNATE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" DESIGNATE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" DESIGNATE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" [Links] API = "https://docs.openstack.org/designate/latest/" GoClient = "https://pkg.go.dev/github.com/gophercloud/gophercloud/openstack/dns/v2" ================================================ FILE: providers/dns/designate/designate_test.go ================================================ package designate import ( "net/http" "net/http/httptest" "os" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/gophercloud/utils/openstack/clientconfig" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) const ( envDomain = envNamespace + "DOMAIN" envOSClientConfigFile = "OS_CLIENT_CONFIG_FILE" ) var envTest = tester.NewEnvTest( EnvCloud, EnvAuthURL, EnvUsername, EnvPassword, EnvUserID, EnvAppCredID, EnvAppCredName, EnvAppCredSecret, EnvTenantName, EnvRegionName, EnvProjectID, envOSClientConfigFile). WithDomain(envDomain) func TestNewDNSProvider_fromEnv(t *testing.T) { serverURL := setupTestProvider(t) testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthURL: serverURL + "/v2.0/", EnvUsername: "B", EnvPassword: "C", EnvRegionName: "D", EnvProjectID: "E", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAuthURL: "", EnvUsername: "", EnvPassword: "", EnvRegionName: "", }, expected: "designate: Missing environment variable [OS_AUTH_URL]", }, { desc: "missing auth url", envVars: map[string]string{ EnvAuthURL: "", EnvUsername: "B", EnvPassword: "C", EnvRegionName: "D", }, expected: "designate: Missing environment variable [OS_AUTH_URL]", }, { desc: "missing username", envVars: map[string]string{ EnvAuthURL: serverURL + "/v2.0/", EnvUsername: "", EnvPassword: "C", EnvRegionName: "D", }, expected: "designate: Missing one of the following environment variables [OS_USERID, OS_USERNAME]", }, { desc: "missing password", envVars: map[string]string{ EnvAuthURL: serverURL + "/v2.0/", EnvUsername: "B", EnvPassword: "", EnvRegionName: "D", }, expected: "designate: Missing environment variable [OS_PASSWORD]", }, { desc: "missing application credential secret", envVars: map[string]string{ EnvAuthURL: serverURL + "/v2.0/", EnvRegionName: "D", EnvAppCredID: "F", }, expected: "designate: Missing environment variable [OS_APPLICATION_CREDENTIAL_SECRET]", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProvider_fromCloud(t *testing.T) { serverURL := setupTestProvider(t) testCases := []struct { desc string osCloud string cloud clientconfig.Cloud expected string }{ { desc: "success", osCloud: "good_cloud", cloud: clientconfig.Cloud{ AuthInfo: &clientconfig.AuthInfo{ AuthURL: serverURL + "/v2.0/", Username: "B", Password: "C", ProjectName: "E", ProjectID: "F", }, RegionName: "D", }, }, { desc: "missing auth url", osCloud: "missing_auth_url", cloud: clientconfig.Cloud{ AuthInfo: &clientconfig.AuthInfo{ Username: "B", Password: "C", ProjectName: "E", ProjectID: "F", }, RegionName: "D", }, expected: "designate: Missing input for argument [auth_url]", }, { desc: "missing username", osCloud: "missing_username", cloud: clientconfig.Cloud{ AuthInfo: &clientconfig.AuthInfo{ AuthURL: serverURL + "/v2.0/", Password: "C", ProjectName: "E", ProjectID: "F", }, RegionName: "D", }, expected: "designate: failed to authenticate: Missing input for argument [Username]", }, { desc: "missing password", osCloud: "missing_auth_url", cloud: clientconfig.Cloud{ AuthInfo: &clientconfig.AuthInfo{ AuthURL: serverURL + "/v2.0/", Username: "B", ProjectName: "E", ProjectID: "F", }, RegionName: "D", }, expected: "designate: failed to authenticate: Exactly one of PasswordCredentials and TokenCredentials must be provided", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(map[string]string{ EnvCloud: test.osCloud, envOSClientConfigFile: createCloudsYaml(t, test.osCloud, test.cloud), }) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { serverURL := setupTestProvider(t) testCases := []struct { desc string tenantName string password string userName string authURL string expected string }{ { desc: "success", tenantName: "A", password: "B", userName: "C", authURL: serverURL + "/v2.0/", }, { desc: "wrong auth url", tenantName: "A", password: "B", userName: "C", authURL: serverURL, expected: "designate: failed to authenticate: No supported version available from endpoint " + serverURL + "/", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.opts.TenantName = test.tenantName config.opts.Password = test.password config.opts.Username = test.userName config.opts.IdentityEndpoint = test.authURL p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } // createCloudsYaml creates a temporary cloud file for testing purpose. func createCloudsYaml(t *testing.T, cloudName string, cloud clientconfig.Cloud) string { t.Helper() file, err := os.CreateTemp(t.TempDir(), "lego_test") require.NoError(t, err) t.Cleanup(func() { _ = file.Close() }) clouds := clientconfig.Clouds{ Clouds: map[string]clientconfig.Cloud{ cloudName: cloud, }, } err = yaml.NewEncoder(file).Encode(&clouds) require.NoError(t, err) return file.Name() } func setupTestProvider(t *testing.T) string { t.Helper() mux := http.NewServeMux() server := httptest.NewServer(mux) t.Cleanup(server.Close) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{ "access": { "token": { "id": "a", "expires": "9015-06-05T16:24:57.637Z" }, "user": { "name": "a", "roles": [ ], "role_links": [ ] }, "serviceCatalog": [ { "endpoints": [ { "adminURL": "http://23.253.72.207:9696/", "region": "D", "internalURL": "http://23.253.72.207:9696/", "id": "97c526db8d7a4c88bbb8d68db1bdcdb8", "publicURL": "http://23.253.72.207:9696/" } ], "endpoints_links": [ ], "type": "dns", "name": "designate" } ] } }`)) w.WriteHeader(http.StatusOK) }) return server.URL } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/digitalocean/digitalocean.go ================================================ // Package digitalocean implements a DNS provider for solving the DNS-01 challenge using digitalocean DNS. package digitalocean import ( "context" "errors" "fmt" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/digitalocean/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DO_" EnvAuthToken = envNamespace + "AUTH_TOKEN" EnvAPIUrl = envNamespace + "API_URL" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string AuthToken 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{ BaseURL: env.GetOrDefaultString(EnvAPIUrl, internal.DefaultBaseURL), TTL: env.GetOrDefaultInt(EnvTTL, 30), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Digital // Ocean. Credentials must be passed in the environment variable: // DO_AUTH_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAuthToken) if err != nil { return nil, fmt.Errorf("digitalocean: %w", err) } config := NewDefaultConfig() config.AuthToken = values[EnvAuthToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Digital Ocean. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("digitalocean: the configuration of the DNS provider is nil") } if config.AuthToken == "" { return nil, errors.New("digitalocean: credentials missing") } client := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), ), ) if config.BaseURL != "" { var err error client.BaseURL, err = url.Parse(config.BaseURL) if err != nil { return nil, fmt.Errorf("digitalocean: %w", err) } } return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("digitalocean: could not find zone for domain %q: %w", domain, err) } record := internal.Record{Type: "TXT", Name: info.EffectiveFQDN, Data: info.Value, TTL: d.config.TTL} respData, err := d.client.AddTxtRecord(context.Background(), authZone, record) if err != nil { return fmt.Errorf("digitalocean: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = respData.DomainRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("digitalocean: could not find zone for domain %q: %w", domain, err) } // get the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("digitalocean: unknown record ID for '%s'", info.EffectiveFQDN) } err = d.client.RemoveTxtRecord(context.Background(), authZone, recordID) if err != nil { return fmt.Errorf("digitalocean: %w", err) } // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/digitalocean/digitalocean.toml ================================================ Name = "Digital Ocean" Description = '''''' URL = "https://www.digitalocean.com/docs/networking/dns/" Code = "digitalocean" Since = "v0.3.0" Example = ''' DO_AUTH_TOKEN=xxxxxx \ lego --dns digitalocean -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DO_AUTH_TOKEN = "Authentication token" [Configuration.Additional] DO_API_URL = "The URL of the API" DO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" DO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" DO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.digitalocean.com/documentation/v2/#domain-records" ================================================ FILE: providers/dns/digitalocean/digitalocean_test.go ================================================ package digitalocean import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAuthToken) func mockProvider() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.AuthToken = "asdf1234" config.BaseURL = server.URL config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithJSONHeaders(). With("Authorization", "Bearer asdf1234")) } func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAuthToken: "", }, expected: "digitalocean: some credentials information are missing: DO_AUTH_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string authToken string expected string }{ { desc: "success", authToken: "123", }, { desc: "missing credentials", expected: "digitalocean: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AuthToken = test.authToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { provider := mockProvider(). Route("POST /v2/domains/example.com/records", servermock.RawStringResponse(`{ "domain_record": { "id": 1234567, "type": "TXT", "name": "_acme-challenge", "data": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", "priority": null, "port": null, "weight": null } }`). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)). Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockProvider(). Route("DELETE /v2/domains/example.com/records/1234567", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) provider.recordIDsMu.Lock() provider.recordIDs["token"] = 1234567 provider.recordIDsMu.Unlock() err := provider.CleanUp("example.com", "token", "") require.NoError(t, err) } ================================================ FILE: providers/dns/digitalocean/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) // DefaultBaseURL default API endpoint. const DefaultBaseURL = "https://api.digitalocean.com" // Client the Digital Ocean API client. type Client struct { BaseURL *url.URL httpClient *http.Client } // NewClient creates a new Client. func NewClient(hc *http.Client) *Client { baseURL, _ := url.Parse(DefaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 5 * time.Second} } return &Client{BaseURL: baseURL, httpClient: hc} } func (c *Client) AddTxtRecord(ctx context.Context, zone string, record Record) (*TxtRecordResponse, error) { endpoint := c.BaseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } respData := &TxtRecordResponse{} err = c.do(req, respData) if err != nil { return nil, err } return respData, nil } func (c *Client) RemoveTxtRecord(ctx context.Context, zone string, recordID int) error { endpoint := c.BaseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusBadRequest { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") // NOTE: Even though the body is empty, DigitalOcean API docs still show setting this Content-Type... req.Header.Set("Content-Type", "application/json") return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errInfo APIError err := json.Unmarshal(raw, &errInfo) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, errInfo) } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/digitalocean/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer secret")) } func TestClient_AddTxtRecord(t *testing.T) { client := mockBuilder(). Route("POST /v2/domains/example.com/records", servermock.ResponseFromFixture("domains-records_POST.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)). Build(t) record := Record{ Type: "TXT", Name: "_acme-challenge.example.com.", Data: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", TTL: 30, } newRecord, err := client.AddTxtRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &TxtRecordResponse{DomainRecord: Record{ ID: 1234567, Type: "TXT", Name: "_acme-challenge", Data: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", TTL: 0, }} assert.Equal(t, expected, newRecord) } func TestClient_RemoveTxtRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /v2/domains/example.com/records/1234567", servermock.ResponseFromFixture("domains-records_POST.json"). WithStatusCode(http.StatusNoContent)). Build(t) err := client.RemoveTxtRecord(t.Context(), "example.com", 1234567) require.NoError(t, err) } ================================================ FILE: providers/dns/digitalocean/internal/fixtures/domains-records_POST.json ================================================ { "domain_record": { "id": 1234567, "type": "TXT", "name": "_acme-challenge", "data": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", "priority": null, "port": null, "weight": null } } ================================================ FILE: providers/dns/digitalocean/internal/types.go ================================================ package internal import "fmt" // TxtRecordResponse represents a response from DO's API after making a TXT record. type TxtRecordResponse struct { DomainRecord Record `json:"domain_record"` } type Record struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Data string `json:"data,omitempty"` TTL int `json:"ttl,omitempty"` } type APIError struct { ID string `json:"id"` Message string `json:"message"` } func (a APIError) Error() string { return fmt.Sprintf("%s: %s", a.ID, a.Message) } ================================================ FILE: providers/dns/directadmin/directadmin.go ================================================ package directadmin import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/directadmin/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DIRECTADMIN_" EnvAPIURL = envNamespace + "API_URL" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvZoneName = envNamespace + "ZONE_NAME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string Username string Password string ZoneName 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{ ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, 30), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), 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 DirectAdmin. // Credentials must be passed in the environment variables: // DIRECTADMIN_API_URL, DIRECTADMIN_USERNAME, DIRECTADMIN_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIURL, EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("directadmin: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvAPIURL] config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DirectAdmin. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.BaseURL == "" { return nil, errors.New("directadmin: missing API URL") } if config.Username == "" || config.Password == "" { return nil, errors.New("directadmin: some credentials information are missing") } client, err := internal.NewClient(config.BaseURL, config.Username, config.Password) if err != nil { return nil, fmt.Errorf("directadmin: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.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 using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("directadmin: [domain: %q] %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("directadmin: %w", err) } record := internal.Record{ Name: subDomain, Type: "TXT", Value: info.Value, TTL: d.config.TTL, } err = d.client.SetRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("directadmin: set record for zone %s and subdomain %s: %w", authZone, subDomain, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("directadmin: [domain: %q] %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("directadmin: %w", err) } record := internal.Record{ Name: subDomain, Type: "TXT", Value: info.Value, } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("directadmin: delete record for zone %s and subdomain %s: %w", authZone, subDomain, err) } return nil } func (d *DNSProvider) getZoneName(fqdn string) (string, error) { if d.config.ZoneName != "" { return d.config.ZoneName, nil } authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) } if authZone == "" { return "", errors.New("empty zone name") } return authZone, nil } ================================================ FILE: providers/dns/directadmin/directadmin.toml ================================================ Name = "DirectAdmin" Description = '''''' URL = "https://www.directadmin.com" Code = "directadmin" Since = "v4.18.0" Example = ''' DIRECTADMIN_API_URL="http://example.com:2222" \ DIRECTADMIN_USERNAME=xxxx \ DIRECTADMIN_PASSWORD=yyy \ lego --dns directadmin -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DIRECTADMIN_API_URL = "URL of the API" DIRECTADMIN_USERNAME = "API username" DIRECTADMIN_PASSWORD = "API password" [Configuration.Additional] DIRECTADMIN_ZONE_NAME = "Zone name used to add the TXT record" DIRECTADMIN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" DIRECTADMIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DIRECTADMIN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" DIRECTADMIN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.directadmin.com/api.php" ================================================ FILE: providers/dns/directadmin/directadmin_test.go ================================================ package directadmin import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIURL, EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIURL: "https://example.com:2222", EnvUsername: "test", EnvPassword: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "directadmin: some credentials information are missing: DIRECTADMIN_API_URL,DIRECTADMIN_USERNAME,DIRECTADMIN_PASSWORD", }, { desc: "missing API URL", envVars: map[string]string{ EnvUsername: "test", EnvPassword: "secret", }, expected: "directadmin: some credentials information are missing: DIRECTADMIN_API_URL", }, { desc: "missing username", envVars: map[string]string{ EnvAPIURL: "https://example.com:2222", EnvPassword: "secret", }, expected: "directadmin: some credentials information are missing: DIRECTADMIN_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvAPIURL: "https://example.com:2222", EnvUsername: "test", }, expected: "directadmin: some credentials information are missing: DIRECTADMIN_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.client) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string baseURL string username string password string expected string }{ { desc: "success", baseURL: "https://example.com", username: "test", password: "secret", }, { desc: "missing API URL", expected: "directadmin: missing API URL", }, { desc: "missing username", baseURL: "https://example.com", expected: "directadmin: some credentials information are missing", }, { desc: "missing password", baseURL: "https://example.com", username: "test", expected: "directadmin: some credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.BaseURL = test.baseURL config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.client) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/directadmin/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) // Client the Direct Admin API client. type Client struct { baseURL *url.URL HTTPClient *http.Client username string password string } // NewClient creates a new Client. func NewClient(baseURL, username, password string) (*Client, error) { api, err := url.Parse(baseURL) if err != nil { return nil, err } return &Client{ baseURL: api, HTTPClient: &http.Client{Timeout: 10 * time.Second}, username: username, password: password, }, nil } func (c *Client) SetRecord(ctx context.Context, domain string, record Record) error { data, err := querystring.Values(record) if err != nil { return err } data.Set("action", "add") return c.do(ctx, domain, data) } func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { data, err := querystring.Values(record) if err != nil { return err } data.Set("action", "delete") return c.do(ctx, domain, data) } func (c *Client) do(ctx context.Context, domain string, data url.Values) error { endpoint := c.baseURL.JoinPath("CMD_API_DNS_CONTROL") query := endpoint.Query() query.Set("domain", domain) query.Set("json", "yes") endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(data.Encode())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.SetBasicAuth(c.username, c.password) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } return nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errInfo APIError err := json.Unmarshal(raw, &errInfo) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, errInfo) } ================================================ FILE: providers/dns/directadmin/internal/client_test.go ================================================ package internal import ( "fmt" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, _ := NewClient(server.URL, "user", "secret") client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func newAPIError(reason string, a ...any) APIError { return APIError{ Message: "Cannot View Dns Record", Result: fmt.Sprintf(reason, a...), } } func TestClient_SetRecord(t *testing.T) { client := mockBuilder(). Route("POST /CMD_API_DNS_CONTROL", nil, servermock.CheckQueryParameter().Strict(). With("domain", "example.com"). With("json", "yes"), servermock.CheckForm().UsePostForm().Strict(). With("action", "add"). With("name", "foo"). With("type", "TXT"). With("value", "txtTXTtxt"). With("ttl", "123"), ). Build(t) record := Record{ Name: "foo", Type: "TXT", Value: "txtTXTtxt", TTL: 123, } err := client.SetRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_SetRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /CMD_API_DNS_CONTROL", servermock.JSONEncode(newAPIError("OOPS")). WithStatusCode(http.StatusInternalServerError)). Build(t) record := Record{ Name: "foo", Type: "TXT", Value: "txtTXTtxt", TTL: 123, } err := client.SetRecord(t.Context(), "example.com", record) require.EqualError(t, err, "[status code 500] Cannot View Dns Record: OOPS") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /CMD_API_DNS_CONTROL", nil, servermock.CheckQueryParameter().Strict(). With("domain", "example.com"). With("json", "yes"), servermock.CheckForm().UsePostForm().Strict(). With("action", "delete"). With("name", "foo"). With("type", "TXT"). With("value", "txtTXTtxt"), ). Build(t) record := Record{ Name: "foo", Type: "TXT", Value: "txtTXTtxt", } err := client.DeleteRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /CMD_API_DNS_CONTROL", servermock.JSONEncode(newAPIError("OOPS")). WithStatusCode(http.StatusInternalServerError)). Build(t) record := Record{ Name: "foo", Type: "TXT", Value: "txtTXTtxt", } err := client.DeleteRecord(t.Context(), "example.com", record) require.EqualError(t, err, "[status code 500] Cannot View Dns Record: OOPS") } ================================================ FILE: providers/dns/directadmin/internal/types.go ================================================ package internal import "fmt" // Record represents a DNS record. type Record struct { Name string `url:"name,omitempty"` Type string `url:"type,omitempty"` Value string `url:"value,omitempty"` TTL int `url:"ttl,omitempty"` } // APIError represents a API error. type APIError struct { Message string `json:"error,omitempty"` Result string `json:"result,omitempty"` } func (a APIError) Error() string { return fmt.Sprintf("%s: %s", a.Message, a.Result) } ================================================ FILE: providers/dns/dns_providers_test.go ================================================ package dns import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/exec" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest("EXEC_PATH") func TestKnownDNSProviderSuccess(t *testing.T) { defer envTest.RestoreEnv() envTest.Apply(map[string]string{ "EXEC_PATH": "abc", }) provider, err := NewDNSChallengeProviderByName("exec") require.NoError(t, err) assert.NotNil(t, provider) assert.IsType(t, &exec.DNSProvider{}, provider, "The loaded DNS provider doesn't have the expected type.") } func TestKnownDNSProviderError(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() provider, err := NewDNSChallengeProviderByName("exec") require.Error(t, err) assert.Nil(t, provider) } func TestUnknownDNSProvider(t *testing.T) { provider, err := NewDNSChallengeProviderByName("foobar") require.Error(t, err) assert.Nil(t, provider) } ================================================ FILE: providers/dns/dnsexit/dnsexit.go ================================================ // Package dnsexit implements a DNS provider for solving the DNS-01 challenge using DNSExit. package dnsexit import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dnsexit/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DNSEXIT_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for DNSExit. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("dnsexit: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DNSExit. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dnsexit: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIKey) if err != nil { return nil, fmt.Errorf("dnsexit: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsexit: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("dnsexit: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Content: info.Value, TTL: toMinutes(d.config.TTL), } err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("dnsexit: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsexit: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("dnsexit: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Content: info.Value, } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("dnsexit: add record: %w", err) } return 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 } func toMinutes(seconds int) int { i := seconds / 60 if seconds%60 > 0 { i++ } return i } ================================================ FILE: providers/dns/dnsexit/dnsexit.toml ================================================ Name = "DNSExit" Description = '''''' URL = "https://dnsexit.com" Code = "dnsexit" Since = "v4.32.0" Example = ''' DNSEXIT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns dnsexit -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DNSEXIT_API_KEY = "API key" [Configuration.Additional] DNSEXIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" DNSEXIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" DNSEXIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" DNSEXIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://dnsexit.com/dns/dns-api/" ================================================ FILE: providers/dns/dnsexit/dnsexit_test.go ================================================ package dnsexit import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "dnsexit: some credentials information are missing: DNSEXIT_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "key", }, { desc: "missing credentials", expected: "dnsexit: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("apikey", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /", servermock.ResponseFromInternal("success.json"), servermock.CheckRequestJSONBodyFromInternal("add_record-request.json"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("POST /", servermock.ResponseFromInternal("success.json"), servermock.CheckRequestJSONBodyFromInternal("delete_record-request.json"), ). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dnsexit/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://api.dnsexit.com/dns/" // Client the DNSExit API client. type Client struct { apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey string) (*Client, error) { if apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // AddRecord adds a record. // https://dnsexit.com/dns/dns-api/#example-add-spf // https://dnsexit.com/dns/dns-api/#example-lse func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { payload := APIRequest{ Domain: domain, Add: []Record{record}, } req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL, payload) if err != nil { return err } err = c.do(req) if err != nil { return err } return nil } // DeleteRecord deletes a record. // https://dnsexit.com/dns/dns-api/#delete-a-record func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { payload := APIRequest{ Domain: domain, Delete: []Record{record}, } req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL, payload) if err != nil { return err } err = c.do(req) if err != nil { return err } return nil } func (c *Client) do(req *http.Request) error { useragent.SetHeader(req.Header) req.Header.Set("apikey", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode > http.StatusBadRequest { return parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } result := &APIResponse{} err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if result.Code != 0 { return result } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIResponse err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/dnsexit/internal/client_test.go ================================================ package internal import ( "context" "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("apikey", "secret"), ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("success.json"), servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"), ). Build(t) record := Record{ Type: "TXT", Name: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 2, } err := client.AddRecord(context.Background(), "example.com", record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest), ). Build(t) record := Record{ Type: "TXT", Name: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 480, Overwrite: true, } err := client.AddRecord(context.Background(), "example.com", record) require.Error(t, err) require.EqualError(t, err, "JSON Defined Record Type not Supported (code=6)") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("success.json"), servermock.CheckRequestJSONBodyFromFixture("delete_record-request.json"), ). Build(t) record := Record{ Type: "TXT", Name: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", } err := client.DeleteRecord(context.Background(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest), ). Build(t) record := Record{ Type: "TXT", Name: "foo", Content: "txtTXTtxt", } err := client.DeleteRecord(context.Background(), "example.com", record) require.Error(t, err) require.EqualError(t, err, "JSON Defined Record Type not Supported (code=6)") } ================================================ FILE: providers/dns/dnsexit/internal/fixtures/add_record-request.json ================================================ { "domain": "example.com", "add": [ { "type": "TXT", "name": "_acme-challenge", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 2 } ] } ================================================ FILE: providers/dns/dnsexit/internal/fixtures/delete_record-request.json ================================================ { "domain": "example.com", "delete": [ { "type": "TXT", "name": "_acme-challenge", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" } ] } ================================================ FILE: providers/dns/dnsexit/internal/fixtures/error.json ================================================ { "code": 6, "message": "JSON Defined Record Type not Supported" } ================================================ FILE: providers/dns/dnsexit/internal/fixtures/success.json ================================================ { "code": 0, "details": [ "UPDATE Record A example.com. TTL(hh:mm) 08:00 IP 1.1.1.10" ], "message": "Success" } ================================================ FILE: providers/dns/dnsexit/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type Record struct { Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` // NOTE: ttl value is in minutes. Overwrite bool `json:"overwrite,omitempty"` } type APIRequest struct { Domain string `json:"domain,omitempty"` Add []Record `json:"add,omitempty"` Delete []Record `json:"delete,omitempty"` Update []Record `json:"update,omitempty"` } // https://dnsexit.com/dns/dns-api/#server-reply type APIResponse struct { Code int `json:"code,omitempty"` Details []string `json:"details,omitempty"` Message string `json:"message,omitempty"` } func (a APIResponse) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "%s (code=%d)", a.Message, a.Code) for _, detail := range a.Details { _, _ = fmt.Fprintf(msg, ", %s", detail) } return msg.String() } ================================================ FILE: providers/dns/dnshomede/dnshomede.go ================================================ // Package dnshomede implements a DNS provider for solving the DNS-01 challenge using dnsHome.de. package dnshomede import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dnshomede/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DNSHOMEDE_" EnvCredentials = envNamespace + "CREDENTIALS" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) // Config is used to configure the creation of the DNSProvider. type Config struct { Credentials map[string]string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 20*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 2*time.Minute), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for dnsHome.de. // Credentials must be passed in the environment variable: DNSHOMEDE_CREDENTIALS. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() values, err := env.Get(EnvCredentials) if err != nil { return nil, fmt.Errorf("dnshomede: %w", err) } credentials, err := env.ParsePairs(values[EnvCredentials]) if err != nil { return nil, fmt.Errorf("dnshomede: credentials: %w", err) } config.Credentials = credentials return NewDNSProviderConfig(config) } func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dnshomede: the configuration of the DNS provider is nil") } if len(config.Credentials) == 0 { return nil, errors.New("dnshomede: missing credentials") } for domain, password := range config.Credentials { if domain == "" { return nil, fmt.Errorf(`dnshomede: missing domain: "%s:%s"`, domain, password) } if password == "" { return nil, fmt.Errorf(`dnshomede: missing password: "%s:%s"`, domain, password) } } client := internal.NewClient(config.Credentials) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present updates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.Add(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("dnshomede: %w", err) } return nil } // CleanUp updates the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.Remove(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("dnshomede: %w", err) } return 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/dnshomede/dnshomede.toml ================================================ Name = "dnsHome.de" Description = '''''' URL = "https://www.dnshome.de" Code = "dnshomede" Since = "v4.10.0" Example = ''' DNSHOMEDE_CREDENTIALS=example.org:password \ lego --dns dnshomede -d '*.example.com' -d example.com run DNSHOMEDE_CREDENTIALS=my.example.org:password1,demo.example.org:password2 \ lego --dns dnshomede -d my.example.org -d demo.example.org ''' [Configuration] [Configuration.Credentials] DNSHOMEDE_CREDENTIALS = "Comma-separated list of domain:password credential pairs" [Configuration.Additional] DNSHOMEDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 1200)" DNSHOMEDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2)" DNSHOMEDE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)" DNSHOMEDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" ================================================ FILE: providers/dns/dnshomede/dnshomede_test.go ================================================ package dnshomede import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvCredentials).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvCredentials: "example.org:123", }, }, { desc: "success multiple domains", envVars: map[string]string{ EnvCredentials: "example.org:123,example.com:456,example.net:789", }, }, { desc: "invalid credentials", envVars: map[string]string{ EnvCredentials: ",", }, expected: `dnshomede: credentials: incorrect pair: `, }, { desc: "missing password", envVars: map[string]string{ EnvCredentials: "example.org:", }, expected: `dnshomede: missing password: "example.org:"`, }, { desc: "missing domain", envVars: map[string]string{ EnvCredentials: ":123", }, expected: `dnshomede: missing domain: ":123"`, }, { desc: "invalid credentials, partial", envVars: map[string]string{ EnvCredentials: "example.org:123,example.net", }, expected: "dnshomede: credentials: incorrect pair: example.net", }, { desc: "missing credentials", envVars: map[string]string{ EnvCredentials: "", }, expected: "dnshomede: some credentials information are missing: DNSHOMEDE_CREDENTIALS", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string creds map[string]string expected string }{ { desc: "success", creds: map[string]string{"example.org": "123"}, }, { desc: "success multiple domains", creds: map[string]string{ "example.org": "123", "example.com": "456", "example.net": "789", }, }, { desc: "missing credentials", expected: "dnshomede: missing credentials", }, { desc: "missing domain", creds: map[string]string{"": "123"}, expected: `dnshomede: missing domain: ":123"`, }, { desc: "missing password", creds: map[string]string{"example.org": ""}, expected: `dnshomede: missing password: "example.org:"`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Credentials = test.creds p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dnshomede/internal/client.go ================================================ package internal import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const ( removeAction = "rm" addAction = "add" ) const successCode = "successfully" const defaultBaseURL = "https://www.dnshome.de/dyndns.php" // Client the dnsHome.de client. type Client struct { baseURL string HTTPClient *http.Client credentials map[string]string credMu sync.Mutex } // NewClient Creates a new Client. func NewClient(credentials map[string]string) *Client { return &Client{ HTTPClient: &http.Client{Timeout: 10 * time.Second}, baseURL: defaultBaseURL, credentials: credentials, } } // Add adds a TXT record. // only one TXT record for ACME is allowed, so it will update the "current" TXT record. func (c *Client) Add(ctx context.Context, hostname, value string) error { domain := strings.TrimPrefix(hostname, "_acme-challenge.") return c.doAction(ctx, domain, addAction, value) } // Remove removes a TXT record. // only one TXT record for ACME is allowed, so it will remove "all" the TXT records. func (c *Client) Remove(ctx context.Context, hostname, value string) error { domain := strings.TrimPrefix(hostname, "_acme-challenge.") return c.doAction(ctx, domain, removeAction, value) } func (c *Client) doAction(ctx context.Context, domain, action, value string) error { endpoint, err := c.createEndpoint(domain, action, value) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } output := string(raw) if !strings.HasPrefix(output, successCode) { return errors.New(output) } return nil } func (c *Client) createEndpoint(domain, action, value string) (*url.URL, error) { if len(value) < 12 { return nil, fmt.Errorf("the TXT value must have more than 12 characters: %s", value) } endpoint, err := url.Parse(c.baseURL) if err != nil { return nil, err } c.credMu.Lock() password, ok := c.credentials[domain] c.credMu.Unlock() if !ok { return nil, fmt.Errorf("domain %s not found in credentials, check your credentials map", domain) } endpoint.User = url.UserPassword(domain, password) query := endpoint.Query() query.Set("acme", action) query.Set("txt", value) endpoint.RawQuery = query.Encode() return endpoint, nil } ================================================ FILE: providers/dns/dnshomede/internal/client_test.go ================================================ package internal import ( "fmt" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func setupClient(credentials map[string]string) func(server *httptest.Server) (*Client, error) { return func(server *httptest.Server) (*Client, error) { client := NewClient(credentials) client.HTTPClient = server.Client() client.baseURL = server.URL return client, nil } } func TestClient_Add(t *testing.T) { txtValue := "123456789012" client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})). Route("POST /", servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), servermock.CheckQueryParameter().Strict(). With("acme", addAction).With("txt", txtValue)). Build(t) err := client.Add(t.Context(), "example.org", txtValue) require.NoError(t, err) } func TestClient_Add_error(t *testing.T) { txtValue := "123456789012" client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})). Route("POST /", servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), servermock.CheckQueryParameter().Strict(). With("acme", addAction).With("txt", txtValue)). Build(t) err := client.Add(t.Context(), "example.org", txtValue) require.EqualError(t, err, "domain example.org not found in credentials, check your credentials map") } func TestClient_Remove(t *testing.T) { txtValue := "ABCDEFGHIJKL" client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})). Route("POST /", servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), servermock.CheckQueryParameter().Strict(). With("acme", removeAction).With("txt", txtValue)). Build(t) err := client.Remove(t.Context(), "example.org", txtValue) require.NoError(t, err) } func TestClient_Remove_error(t *testing.T) { txtValue := "ABCDEFGHIJKL" testCases := []struct { desc string hostname string response string expected string }{ { desc: "response error - txt", hostname: "example.com", response: "error - no valid acme txt record", expected: "error - no valid acme txt record", }, { desc: "response error - acme", hostname: "example.com", response: "nochg 1234:1234:1234:1234:1234:1234:1234:1234", expected: "nochg 1234:1234:1234:1234:1234:1234:1234:1234", }, { desc: "credential error", hostname: "example.org", response: fmt.Sprintf("%s %s", successCode, txtValue), expected: "domain example.org not found in credentials, check your credentials map", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})). Route("POST /", servermock.RawStringResponse(test.response), servermock.CheckQueryParameter().Strict(). With("acme", removeAction).With("txt", txtValue)). Build(t) err := client.Remove(t.Context(), test.hostname, txtValue) require.EqualError(t, err, test.expected) }) } } ================================================ FILE: providers/dns/dnshomede/internal/readme.md ================================================ # dnshome.de API ## Add TXT record ``` https://:@www.dnshome.de/dyndns.php?acme=add&txt= ``` - ``: the subdomain (ex: `lego.dnshome.de`). - ``: the subdomain password. - ``: the value of the TXT record (12 characters minimum) Only one TXT record can be used for a subdomain. Always returns StatusOK (200) If the API call works the first word of the response body is `successfully`. If an error occurs the response body is `error - `. Can be a POST or a GET. ## Remove TXT record ``` https://:@www.dnshome.de/dyndns.php?acme=rm ``` - ``: the subdomain (ex: `lego.dnshome.de`). - ``: the subdomain password. Only one TXT record can be used for a subdomain. Always returns StatusOK (200) If the API call works the first word of the response body is `successfully`. If an error occurs the response body is `error - `. Can be a POST or a GET. ================================================ FILE: providers/dns/dnsimple/dnsimple.go ================================================ // Package dnsimple implements a DNS provider for solving the DNS-01 challenge using dnsimple DNS. package dnsimple import ( "context" "errors" "fmt" "strconv" "time" "github.com/dnsimple/dnsimple-go/v4/dnsimple" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "golang.org/x/oauth2" ) // Environment variables names. const ( envNamespace = "DNSIMPLE_" EnvOAuthToken = envNamespace + "OAUTH_TOKEN" EnvBaseURL = envNamespace + "BASE_URL" EnvDebug = envNamespace + "DEBUG" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Debug bool AccessToken string BaseURL string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), Debug: env.GetOrDefaultBool(EnvDebug, false), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *dnsimple.Client } // NewDNSProvider returns a DNSProvider instance configured for dnsimple. // Credentials must be passed in the environment variable: DNSIMPLE_OAUTH_TOKEN. // // See: https://developer.dnsimple.com/v2/#authentication func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.AccessToken = env.GetOrFile(EnvOAuthToken) config.BaseURL = env.GetOrFile(EnvBaseURL) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DNSimple. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dnsimple: the configuration of the DNS provider is nil") } if config.AccessToken == "" { return nil, errors.New("dnsimple: OAuth token is missing") } client := dnsimple.NewClient( clientdebug.Wrap( oauth2.NewClient( context.Background(), oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.AccessToken}), ), ), ) client.SetUserAgent(useragent.Get()) if config.BaseURL != "" { client.BaseURL = config.BaseURL } client.Debug = config.Debug return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsimple: %w", err) } accountID, err := d.getAccountID(ctx) if err != nil { return fmt.Errorf("dnsimple: %w", err) } recordAttributes, err := newTxtRecord(zoneName, info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("dnsimple: %w", err) } _, err = d.client.Zones.CreateRecord(ctx, accountID, zoneName, recordAttributes) if err != nil { return fmt.Errorf("dnsimple: API call failed: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) records, err := d.findTxtRecords(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsimple: %w", err) } accountID, err := d.getAccountID(ctx) if err != nil { return fmt.Errorf("dnsimple: %w", err) } var lastErr error for _, rec := range records { _, err := d.client.Zones.DeleteRecord(ctx, accountID, rec.ZoneID, rec.ID) if err != nil { lastErr = fmt.Errorf("dnsimple: %w", err) } } return lastErr } // 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 } func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) } accountID, err := d.getAccountID(ctx) if err != nil { return "", err } hostedZone, err := d.client.Zones.GetZone(ctx, accountID, dns01.UnFqdn(authZone)) if err != nil { return "", fmt.Errorf("get zone: %w", err) } if hostedZone == nil || hostedZone.Data == nil || hostedZone.Data.ID == 0 { return "", fmt.Errorf("zone %s not found in DNSimple for domain %s", authZone, domain) } return hostedZone.Data.Name, nil } func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]dnsimple.ZoneRecord, error) { zoneName, err := d.getHostedZone(ctx, fqdn) if err != nil { return nil, err } accountID, err := d.getAccountID(ctx) if err != nil { return nil, err } subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) if err != nil { return nil, err } result, err := d.client.Zones.ListRecords(ctx, accountID, zoneName, &dnsimple.ZoneRecordListOptions{Name: &subDomain, Type: dnsimple.String("TXT"), ListOptions: dnsimple.ListOptions{}}) if err != nil { return nil, fmt.Errorf("API call has failed: %w", err) } return result.Data, nil } func newTxtRecord(zoneName, fqdn, value string, ttl int) (dnsimple.ZoneRecordAttributes, error) { subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) if err != nil { return dnsimple.ZoneRecordAttributes{}, err } return dnsimple.ZoneRecordAttributes{ Type: "TXT", Name: &subDomain, Content: value, TTL: ttl, }, nil } func (d *DNSProvider) getAccountID(ctx context.Context) (string, error) { whoamiResponse, err := d.client.Identity.Whoami(ctx) if err != nil { return "", err } if whoamiResponse.Data.Account == nil { return "", errors.New("user tokens are not supported, please use an account token") } return strconv.FormatInt(whoamiResponse.Data.Account.ID, 10), nil } ================================================ FILE: providers/dns/dnsimple/dnsimple.toml ================================================ Name = "DNSimple" Description = '''''' URL = "https://dnsimple.com/" Code = "dnsimple" Since = "v0.3.0" Example = ''' DNSIMPLE_OAUTH_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz \ lego --dns dnsimple -d '*.example.com' -d example.com run ''' Additional = ''' ## Description `DNSIMPLE_BASE_URL` is optional and must be set to production (https://api.dnsimple.com). if `DNSIMPLE_BASE_URL` is not defined or empty, the production URL is used by default. While you can manage DNS records in the [DNSimple Sandbox environment](https://developer.dnsimple.com/sandbox/), DNS records will not resolve, and you will not be able to satisfy the ACME DNS challenge. To authenticate you need to provide a valid API token. HTTP Basic Authentication is intentionally not supported. ### API tokens You can [generate a new API token](https://support.dnsimple.com/articles/api-access-token/) from your account page. Only Account API tokens are supported, if you try to use a User API token you will receive an error message. ''' [Configuration] [Configuration.Credentials] DNSIMPLE_OAUTH_TOKEN = "OAuth token" [Configuration.Additional] DNSIMPLE_BASE_URL = "API endpoint URL" DNSIMPLE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" DNSIMPLE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DNSIMPLE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://developer.dnsimple.com/v2/" GoClient = "https://github.com/dnsimple/dnsimple-go" ================================================ FILE: providers/dns/dnsimple/dnsimple_test.go ================================================ package dnsimple import ( "os" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const sandboxURL = "https://api.sandbox.fake.com" const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvOAuthToken, EnvBaseURL). WithDomain(envDomain). WithLiveTestRequirements(EnvOAuthToken, envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvOAuthToken: "my_token", }, }, { desc: "success: base url", envVars: map[string]string{ EnvOAuthToken: "my_token", EnvBaseURL: "https://api.dnsimple.test", }, }, { desc: "missing oauth token", envVars: map[string]string{ EnvOAuthToken: "", }, expected: "dnsimple: OAuth token is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) baseURL := os.Getenv(EnvBaseURL) if baseURL != "" { assert.Equal(t, baseURL, p.client.BaseURL) } } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accessToken string baseURL string expected string }{ { desc: "success", accessToken: "my_token", baseURL: "", }, { desc: "success: base url", accessToken: "my_token", baseURL: "https://api.dnsimple.test", }, { desc: "missing oauth token", expected: "dnsimple: OAuth token is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessToken = test.accessToken config.BaseURL = test.baseURL p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) if test.baseURL != "" { assert.Equal(t, test.baseURL, p.client.BaseURL) } } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() if os.Getenv(EnvBaseURL) == "" { os.Setenv(EnvBaseURL, sandboxURL) } 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() if os.Getenv(EnvBaseURL) == "" { os.Setenv(EnvBaseURL, sandboxURL) } provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dnsmadeeasy/dnsmadeeasy.go ================================================ // Package dnsmadeeasy implements a DNS provider for solving the DNS-01 challenge using DNS Made Easy. package dnsmadeeasy import ( "context" "crypto/tls" "errors" "fmt" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dnsmadeeasy/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DNSMADEEASY_" EnvAPIKey = envNamespace + "API_KEY" EnvAPISecret = envNamespace + "API_SECRET" EnvSandbox = envNamespace + "SANDBOX" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIKey string APISecret string Sandbox bool HTTPClient *http.Client PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { tr := &http.Transport{} defaultTransport, ok := http.DefaultTransport.(*http.Transport) if ok { tr = defaultTransport.Clone() } tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 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, 10*time.Second), Transport: tr, }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for DNSMadeEasy DNS. // Credentials must be passed in the environment variables: // DNSMADEEASY_API_KEY and DNSMADEEASY_API_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvAPISecret) if err != nil { return nil, fmt.Errorf("dnsmadeeasy: %w", err) } config := NewDefaultConfig() config.Sandbox = env.GetOrDefaultBool(EnvSandbox, false) config.APIKey = values[EnvAPIKey] config.APISecret = values[EnvAPISecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DNS Made Easy. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dnsmadeeasy: the configuration of the DNS provider is nil") } var baseURL string if config.Sandbox { baseURL = internal.DefaultSandboxBaseURL } else { if config.BaseURL == "" { baseURL = internal.DefaultProdBaseURL } else { baseURL = config.BaseURL } } client, err := internal.NewClient(config.APIKey, config.APISecret) if err != nil { return nil, fmt.Errorf("dnsmadeeasy: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) client.BaseURL, err = url.Parse(baseURL) if err != nil { return nil, err } return &DNSProvider{ client: client, config: config, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domainName, token, keyAuth string) error { info := dns01.GetChallengeInfo(domainName, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q: %w", domainName, err) } ctx := context.Background() // fetch the domain details domain, err := d.client.GetDomain(ctx, authZone) if err != nil { return fmt.Errorf("dnsmadeeasy: unable to get domain for zone %s: %w", authZone, err) } // create the TXT record name := strings.Replace(info.EffectiveFQDN, "."+authZone, "", 1) record := &internal.Record{Type: "TXT", Name: name, Value: info.Value, TTL: d.config.TTL} err = d.client.CreateRecord(ctx, domain, record) if err != nil { return fmt.Errorf("dnsmadeeasy: unable to create record for %s: %w", name, err) } return nil } // CleanUp removes the TXT records matching the specified parameters. func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { info := dns01.GetChallengeInfo(domainName, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q: %w", domainName, err) } ctx := context.Background() // fetch the domain details domain, err := d.client.GetDomain(ctx, authZone) if err != nil { return fmt.Errorf("dnsmadeeasy: unable to get domain for zone %s: %w", authZone, err) } // find matching records name := strings.Replace(info.EffectiveFQDN, "."+authZone, "", 1) records, err := d.client.GetRecords(ctx, domain, name, "TXT") if err != nil { return fmt.Errorf("dnsmadeeasy: unable to get records for domain %s: %w", domain.Name, err) } // delete records var lastError error for _, record := range *records { err = d.client.DeleteRecord(ctx, record) if err != nil { lastError = fmt.Errorf("dnsmadeeasy: unable to delete record [id=%d, name=%s]: %w", record.ID, record.Name, err) } } return lastError } // 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 } ================================================ FILE: providers/dns/dnsmadeeasy/dnsmadeeasy.toml ================================================ Name = "DNS Made Easy" Description = '''''' URL = "https://dnsmadeeasy.com/" Code = "dnsmadeeasy" Since = "v0.4.0" Example = ''' DNSMADEEASY_API_KEY=xxxxxx \ DNSMADEEASY_API_SECRET=yyyyy \ lego --dns dnsmadeeasy -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DNSMADEEASY_API_KEY = "The API key" DNSMADEEASY_API_SECRET = "The API Secret key" [Configuration.Additional] DNSMADEEASY_SANDBOX = "Activate the sandbox (boolean)" DNSMADEEASY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" DNSMADEEASY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DNSMADEEASY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" DNSMADEEASY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api-docs.dnsmadeeasy.com/" ================================================ FILE: providers/dns/dnsmadeeasy/dnsmadeeasy_test.go ================================================ package dnsmadeeasy import ( "os" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey, EnvAPISecret). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { os.Setenv(EnvSandbox, "true") testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvAPISecret: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvAPISecret: "", }, expected: "dnsmadeeasy: some credentials information are missing: DNSMADEEASY_API_KEY,DNSMADEEASY_API_SECRET", }, { desc: "missing access key", envVars: map[string]string{ EnvAPIKey: "", EnvAPISecret: "456", }, expected: "dnsmadeeasy: some credentials information are missing: DNSMADEEASY_API_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAPIKey: "123", EnvAPISecret: "", }, expected: "dnsmadeeasy: some credentials information are missing: DNSMADEEASY_API_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { os.Setenv(EnvSandbox, "true") testCases := []struct { desc string apiKey string apiSecret string expected string }{ { desc: "success", apiKey: "123", apiSecret: "456", }, { desc: "missing credentials", expected: "dnsmadeeasy: credentials missing: API key", }, { desc: "missing api key", apiSecret: "456", expected: "dnsmadeeasy: credentials missing: API key", }, { desc: "missing secret key", apiKey: "123", expected: "dnsmadeeasy: credentials missing: API secret", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.APISecret = test.apiSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresentAndCleanup(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } os.Setenv(EnvSandbox, "true") envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dnsmadeeasy/internal/client.go ================================================ package internal import ( "bytes" "context" "crypto/hmac" "crypto/sha1" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // Default API endpoints. const ( DefaultSandboxBaseURL = "https://api.sandbox.dnsmadeeasy.com/V2.0" DefaultProdBaseURL = "https://api.dnsmadeeasy.com/V2.0" ) // Client DNSMadeEasy client. type Client struct { apiKey string apiSecret string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a DNSMadeEasy client. func NewClient(apiKey, apiSecret string) (*Client, error) { if apiKey == "" { return nil, errors.New("credentials missing: API key") } if apiSecret == "" { return nil, errors.New("credentials missing: API secret") } baseURL, _ := url.Parse(DefaultProdBaseURL) return &Client{ apiKey: apiKey, apiSecret: apiSecret, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, }, nil } // GetDomain gets a domain. func (c *Client) GetDomain(ctx context.Context, authZone string) (*Domain, error) { endpoint := c.BaseURL.JoinPath("dns", "managed", "name") query := endpoint.Query() query.Set("domainname", dns01.UnFqdn(authZone)) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } domain := &Domain{} err = c.do(req, domain) if err != nil { return nil, err } return domain, nil } // GetRecords gets all TXT records. func (c *Client) GetRecords(ctx context.Context, domain *Domain, recordName, recordType string) (*[]Record, error) { endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(domain.ID), "records") query := endpoint.Query() query.Set("recordName", recordName) query.Set("type", recordType) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } records := &recordsResponse{} err = c.do(req, records) if err != nil { return nil, err } return records.Records, nil } // CreateRecord creates a TXT records. func (c *Client) CreateRecord(ctx context.Context, domain *Domain, record *Record) error { endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(domain.ID), "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } // DeleteRecord deletes a TXT records. func (c *Client) DeleteRecord(ctx context.Context, record Record) error { endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(record.SourceID), "records", strconv.Itoa(record.ID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { err := c.sign(req, time.Now().UTC().Format(time.RFC1123)) if err != nil { return err } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if err = json.Unmarshal(raw, result); err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) sign(req *http.Request, timestamp string) error { signature, err := computeHMAC(timestamp, c.apiSecret) if err != nil { return err } req.Header.Set("x-dnsme-apiKey", c.apiKey) req.Header.Set("x-dnsme-requestDate", timestamp) req.Header.Set("x-dnsme-hmac", signature) return nil } func computeHMAC(message, secret string) (string, error) { key := []byte(secret) h := hmac.New(sha1.New, key) _, err := h.Write([]byte(message)) if err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/dnsmadeeasy/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("key", "secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With("x-dnsme-apiKey", "key"). WithRegexp("x-dnsme-requestDate", `\w+, \d+ \w+ \d+ \d+:\d+:\d+ UTC`). WithRegexp("x-dnsme-hmac", `[a-z0-9]+`), ) } func TestClient_GetDomain(t *testing.T) { client := mockBuilder(). Route("GET /dns/managed/name", servermock.RawStringResponse(`{"id": 1, "name": "foo"}`), servermock.CheckQueryParameter().Strict(). With("domainname", "example.com")). Build(t) domain, err := client.GetDomain(t.Context(), "example.com.") require.NoError(t, err) expected := &Domain{ ID: 1, Name: "foo", } assert.Equal(t, expected, domain) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/managed/1/records", servermock.ResponseFromFixture("get_records.json"), servermock.CheckQueryParameter().Strict(). With("recordName", "foo"). With("type", "TXT"), ). Build(t) domain := &Domain{ID: 1, Name: "foo"} records, err := client.GetRecords(t.Context(), domain, "foo", "TXT") require.NoError(t, err) expected := []Record{ { ID: 1, Type: "TXT", Name: "foo", Value: "aaa", TTL: 60, SourceID: 123, }, { ID: 2, Type: "TXT", Name: "bar", Value: "bbb", TTL: 120, SourceID: 456, }, } assert.Equal(t, &expected, records) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/managed/1/records", nil, servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) domain := &Domain{ID: 1, Name: "foo"} record := &Record{ ID: 1, Type: "TXT", Name: "foo", Value: "aaa", TTL: 60, SourceID: 123, } err := client.CreateRecord(t.Context(), domain, record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/managed/123/records/1", nil). Build(t) record := Record{ ID: 1, Type: "TXT", Name: "foo", Value: "aaa", TTL: 60, SourceID: 123, } err := client.DeleteRecord(t.Context(), record) require.NoError(t, err) } func TestClient_sign(t *testing.T) { apiKey := "key" client := Client{apiKey: apiKey, apiSecret: "secret"} req, err := http.NewRequest(http.MethodGet, "", http.NoBody) require.NoError(t, err) timestamp := time.Date(2015, time.June, 2, 2, 36, 7, 0, time.UTC).Format(time.RFC1123) err = client.sign(req, timestamp) require.NoError(t, err) assert.Equal(t, apiKey, req.Header.Get("x-dnsme-apiKey")) assert.Equal(t, timestamp, req.Header.Get("x-dnsme-requestDate")) assert.Equal(t, "6b6c8432119c31e1d3776eb4cd3abd92fae4a71c", req.Header.Get("x-dnsme-hmac")) } ================================================ FILE: providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json ================================================ { "id": 1, "type": "TXT", "name": "foo", "value": "aaa", "ttl": 60, "sourceId": 123 } ================================================ FILE: providers/dns/dnsmadeeasy/internal/fixtures/get_records.json ================================================ { "data": [ { "id": 1, "type": "TXT", "name": "foo", "value": "aaa", "ttl": 60, "sourceId": 123 }, { "id": 2, "type": "TXT", "name": "bar", "value": "bbb", "ttl": 120, "sourceId": 456 } ] } ================================================ FILE: providers/dns/dnsmadeeasy/internal/types.go ================================================ package internal // Domain holds the DNSMadeEasy API representation of a Domain. type Domain struct { ID int `json:"id"` Name string `json:"name"` } // Record holds the DNSMadeEasy API representation of a Domain Record. type Record struct { ID int `json:"id"` Type string `json:"type"` Name string `json:"name"` Value string `json:"value"` TTL int `json:"ttl"` SourceID int `json:"sourceId"` } type recordsResponse struct { Records *[]Record `json:"data"` } ================================================ FILE: providers/dns/dnspod/dnspod.go ================================================ // Package dnspod implements a DNS provider for solving the DNS-01 challenge using dnspod DNS. package dnspod import ( "errors" "fmt" "net/http" "strconv" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/dnspod-go" ) // Environment variables names. const ( envNamespace = "DNSPOD_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { LoginToken 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, 600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *dnspod.Client } // NewDNSProvider returns a DNSProvider instance configured for dnspod. // Credentials must be passed in the environment variables: DNSPOD_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("dnspod: %w", err) } config := NewDefaultConfig() config.LoginToken = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for dnspod. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dnspod: the configuration of the DNS provider is nil") } if config.LoginToken == "" { return nil, errors.New("dnspod: credentials missing") } params := dnspod.CommonParams{LoginToken: config.LoginToken, Format: "json"} client := dnspod.NewClient(params) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zoneID, zoneName, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return err } recordAttributes, err := d.newTxtRecord(zoneName, info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return err } _, _, err = d.client.Records.Create(zoneID, *recordAttributes) if err != nil { return fmt.Errorf("API call failed: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zoneID, zoneName, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return err } records, err := d.findTxtRecords(info.EffectiveFQDN, zoneID, zoneName) if err != nil { return err } for _, rec := range records { _, err := d.client.Records.Delete(zoneID, rec.ID) if err != nil { return err } } return 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 } func (d *DNSProvider) getHostedZone(domain string) (string, string, error) { zones, _, err := d.client.Domains.List() if err != nil { return "", "", fmt.Errorf("API call failed: %w", err) } authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return "", "", fmt.Errorf("could not find zone: %w", err) } var hostedZone dnspod.Domain for _, zone := range zones { if zone.Name == dns01.UnFqdn(authZone) { hostedZone = zone } } if hostedZone.ID == "" || hostedZone.ID == "0" { return "", "", fmt.Errorf("zone %s not found for domain %s", authZone, domain) } return hostedZone.ID.String(), hostedZone.Name, nil } func (d *DNSProvider) newTxtRecord(zone, fqdn, value string, ttl int) (*dnspod.Record, error) { subDomain, err := dns01.ExtractSubDomain(fqdn, zone) if err != nil { return nil, err } return &dnspod.Record{ Type: "TXT", Name: subDomain, Value: value, Line: "默认", TTL: strconv.Itoa(ttl), }, nil } func (d *DNSProvider) findTxtRecords(fqdn, zoneID, zoneName string) ([]dnspod.Record, error) { subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName) if err != nil { return nil, err } var records []dnspod.Record result, _, err := d.client.Records.List(zoneID, subDomain) if err != nil { return records, fmt.Errorf("API call has failed: %w", err) } for _, record := range result { if record.Name == subDomain { records = append(records, record) } } return records, nil } ================================================ FILE: providers/dns/dnspod/dnspod.toml ================================================ Name = "DNSPod (deprecated)" Description = ''' Use the Tencent Cloud provider instead. ''' URL = "https://www.dnspod.com/" Code = "dnspod" Since = "v0.4.0" Example = ''' DNSPOD_API_KEY=xxxxxx \ lego --dns dnspod -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DNSPOD_API_KEY = "The user token" [Configuration.Additional] DNSPOD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" DNSPOD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DNSPOD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" DNSPOD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.dnspod.com/api/" GoClient = "https://github.com/nrdcg/dnspod-go" ================================================ FILE: providers/dns/dnspod/dnspod_test.go ================================================ package dnspod import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "dnspod: some credentials information are missing: DNSPOD_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string loginToken string expected string }{ { desc: "success", loginToken: "123", }, { desc: "missing credentials", expected: "dnspod: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.LoginToken = test.loginToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dode/dode.go ================================================ // Package dode implements a DNS provider for solving the DNS-01 challenge using do.de. package dode import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dode/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DODE_" EnvToken = envNamespace + "TOKEN" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a new DNS provider using // environment variable DODE_TOKEN for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("do.de: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for do.de. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("do.de: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("do.de: credentials missing") } client := internal.NewClient(config.Token) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) return d.client.UpdateTxtRecord(context.Background(), info.EffectiveFQDN, info.Value, false) } // CleanUp clears TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) return d.client.UpdateTxtRecord(context.Background(), info.EffectiveFQDN, "", true) } // 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/dode/dode.toml ================================================ Name = "Domain Offensive (do.de)" Description = '''''' URL = "https://www.do.de/" Code = "dode" Since = "v2.4.0" Example = ''' DODE_TOKEN=xxxxxx \ lego --dns dode -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DODE_TOKEN = "API token" [Configuration.Additional] DODE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" DODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DODE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" DODE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" [Links] API = "https://www.do.de/wiki/freie-ssl-tls-zertifikate-ueber-acme/" ================================================ FILE: providers/dns/dode/dode_test.go ================================================ package dode import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvToken: "", }, expected: "do.de: some credentials information are missing: DODE_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "123", }, { desc: "missing credentials", expected: "do.de: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dode/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://my.do.de/api" // Client the do.de API client. type Client struct { token string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(token string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // UpdateTxtRecord Update the domains TXT record // To update the TXT record we just need to make one simple get request. func (c *Client) UpdateTxtRecord(ctx context.Context, fqdn, txt string, clearRecord bool) error { endpoint := c.baseURL.JoinPath("letsencrypt") query := endpoint.Query() query.Set("token", c.token) query.Set("domain", dns01.UnFqdn(fqdn)) // api call differs per set/delete if clearRecord { query.Set("action", "delete") } else { query.Set("value", txt) } endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } var response apiResponse err = json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } body := string(raw) if !response.Success { return fmt.Errorf("request to change TXT record for do.de returned the following error result (%s); used url [%s]", body, endpoint) } return nil } ================================================ FILE: providers/dns/dode/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil } func TestClient_UpdateTxtRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /letsencrypt", servermock.ResponseFromFixture("success.json"), servermock.CheckQueryParameter().Strict(). With("domain", "example.com"). With("token", "secret"). With("value", "value")). Build(t) err := client.UpdateTxtRecord(t.Context(), "example.com.", "value", false) require.NoError(t, err) } func TestClient_UpdateTxtRecord_clear(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /letsencrypt", servermock.ResponseFromFixture("success.json"), servermock.CheckQueryParameter().Strict(). With("action", "delete"). With("domain", "example.com"). With("token", "secret")). Build(t) err := client.UpdateTxtRecord(t.Context(), "example.com.", "value", true) require.NoError(t, err) } ================================================ FILE: providers/dns/dode/internal/fixtures/success.json ================================================ { "Domain" : "example.com", "Success": true } ================================================ FILE: providers/dns/dode/internal/types.go ================================================ package internal type apiResponse struct { Domain string Success bool } ================================================ FILE: providers/dns/domeneshop/domeneshop.go ================================================ // Package domeneshop implements a DNS provider for solving the DNS-01 challenge using domeneshop DNS. package domeneshop import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/domeneshop/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DOMENESHOP_" EnvAPIToken = envNamespace + "API_TOKEN" EnvAPISecret = envNamespace + "API_SECRET" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIToken string APISecret string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 20*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for domeneshop. // Credentials must be passed in the environment variables: // DOMENESHOP_API_TOKEN, DOMENESHOP_API_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken, EnvAPISecret) if err != nil { return nil, fmt.Errorf("domeneshop: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvAPIToken] config.APISecret = values[EnvAPISecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Domeneshop. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("domeneshop: the configuration of the DNS provider is nil") } if config.APIToken == "" || config.APISecret == "" { return nil, errors.New("domeneshop: credentials missing") } client := internal.NewClient(config.APIToken, config.APISecret) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, host, err := d.splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("domeneshop: %w", err) } ctx := context.Background() domainInstance, err := d.client.GetDomainByName(ctx, zone) if err != nil { return fmt.Errorf("domeneshop: %w", err) } err = d.client.CreateTXTRecord(ctx, domainInstance, host, info.Value) if err != nil { return fmt.Errorf("domeneshop: failed to create record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, host, err := d.splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("domeneshop: %w", err) } ctx := context.Background() domainInstance, err := d.client.GetDomainByName(ctx, zone) if err != nil { return fmt.Errorf("domeneshop: %w", err) } if err := d.client.DeleteTXTRecord(ctx, domainInstance, host, info.Value); err != nil { return fmt.Errorf("domeneshop: failed to create record: %w", err) } return nil } // splitDomain splits the hostname from the authoritative zone, and returns both parts (non-fqdn). func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) { zone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", "", fmt.Errorf("could not find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, zone) if err != nil { return "", "", err } return dns01.UnFqdn(zone), subDomain, nil } ================================================ FILE: providers/dns/domeneshop/domeneshop.toml ================================================ Name = "Domeneshop" Description = '''''' URL = "https://domene.shop" Code = "domeneshop" Aliases = ["domainnameshop"] Since = "v4.3.0" Example = ''' DOMENESHOP_API_TOKEN= \ DOMENESHOP_API_SECRET= \ lego --dns domeneshop -d '*.example.com' -d example.com run ''' Additional = ''' ### API credentials Visit the following page for information on how to create API credentials with Domeneshop: https://api.domeneshop.no/docs/#section/Authentication ''' [Configuration] [Configuration.Credentials] DOMENESHOP_API_TOKEN = "API token" DOMENESHOP_API_SECRET = "API secret" [Configuration.Additional] DOMENESHOP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" DOMENESHOP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" DOMENESHOP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.domeneshop.no/docs" ================================================ FILE: providers/dns/domeneshop/domeneshop_test.go ================================================ package domeneshop import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIToken, EnvAPISecret). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "A", EnvAPISecret: "B", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIToken: "", EnvAPISecret: "", }, expected: "domeneshop: some credentials information are missing: DOMENESHOP_API_TOKEN,DOMENESHOP_API_SECRET", }, { desc: "missing api token", envVars: map[string]string{ EnvAPIToken: "", EnvAPISecret: "A", }, expected: "domeneshop: some credentials information are missing: DOMENESHOP_API_TOKEN", }, { desc: "missing api secret", envVars: map[string]string{ EnvAPIToken: "A", EnvAPISecret: "", }, expected: "domeneshop: some credentials information are missing: DOMENESHOP_API_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiSecret string apiToken string expected string }{ { desc: "success", apiToken: "A", apiSecret: "B", }, { desc: "missing credentials", expected: "domeneshop: credentials missing", }, { desc: "missing api token", apiToken: "", apiSecret: "B", expected: "domeneshop: credentials missing", }, { desc: "missing api secret", apiToken: "A", apiSecret: "", expected: "domeneshop: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken config.APISecret = test.apiSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/domeneshop/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL string = "https://api.domeneshop.no/v0" // Client implements a very simple wrapper around the Domeneshop API. // For now, it will only deal with adding and removing TXT records, as required by ACME providers. // https://api.domeneshop.no/docs/ type Client struct { apiToken string apiSecret string baseURL *url.URL HTTPClient *http.Client } // NewClient returns an instance of the Domeneshop API wrapper. func NewClient(apiToken, apiSecret string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiToken: apiToken, apiSecret: apiSecret, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetDomainByName fetches the domain list and returns the Domain object for the matching domain. // https://api.domeneshop.no/docs/#operation/getDomains func (c *Client) GetDomainByName(ctx context.Context, domain string) (*Domain, error) { endpoint := c.baseURL.JoinPath("domains") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var domains []Domain err = c.do(req, &domains) if err != nil { return nil, err } for _, d := range domains { if !d.Services.DNS { // Domains without DNS service cannot have DNS record added. continue } if d.Name == domain { return &d, nil } } return nil, fmt.Errorf("failed to find matching domain name: %s", domain) } // CreateTXTRecord creates a TXT record with the provided host (subdomain) and data. // https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns/post func (c *Client) CreateTXTRecord(ctx context.Context, domain *Domain, host, data string) error { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns") record := DNSRecord{ Data: data, Host: host, TTL: 300, Type: "TXT", } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } // DeleteTXTRecord deletes the DNS record matching the provided host and data. // https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns~1{recordId}/delete func (c *Client) DeleteTXTRecord(ctx context.Context, domain *Domain, host, data string) error { record, err := c.getDNSRecordByHostData(ctx, *domain, host, data) if err != nil { return err } endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns", strconv.Itoa(record.ID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } // getDNSRecordByHostData finds the first matching DNS record with the provided host and data. // https://api.domeneshop.no/docs/#operation/getDnsRecords func (c *Client) getDNSRecordByHostData(ctx context.Context, domain Domain, host, data string) (*DNSRecord, error) { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var records []DNSRecord err = c.do(req, &records) if err != nil { return nil, err } for _, r := range records { if r.Host == host && r.Data == data { return &r, nil } } return nil, fmt.Errorf("failed to find record with host %s for domain %s", host, domain.Name) } // do a request against the API, // and makes sure that the required Authorization header is set using `setBasicAuth`. func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth(c.apiToken, c.apiSecret) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusBadRequest { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/domeneshop/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("token", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("token", "secret"), ) } func TestClient_CreateTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/1/dns", servermock.ResponseFromFixture("create_record.json"), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) err := client.CreateTXTRecord(t.Context(), &Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { client := mockBuilder(). Route("GET /domains/1/dns", servermock.ResponseFromFixture("delete_record.json")). Route("DELETE /domains/1/dns/1", nil). Build(t) err := client.DeleteTXTRecord(t.Context(), &Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) } func TestClient_getDNSRecordByHostData(t *testing.T) { client := mockBuilder(). Route("GET /domains/1/dns", servermock.ResponseFromFixture("getDnsRecords.json")). Build(t) record, err := client.getDNSRecordByHostData(t.Context(), Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) expected := &DNSRecord{ ID: 1, Type: "TXT", Host: "example.com", Data: "txtTXTtxt", TTL: 3600, } assert.Equal(t, expected, record) } func TestClient_GetDomainByName(t *testing.T) { client := mockBuilder(). Route("GET /domains/", servermock.ResponseFromFixture("getDomains.json")). Build(t) domain, err := client.GetDomainByName(t.Context(), "example.com") require.NoError(t, err) expected := &Domain{ Name: "example.com", ID: 1, ExpiryDate: "2019-08-24", Nameservers: []string{"ns1.hyp.net", "ns2.hyp.net", "ns3.hyp.net"}, RegisteredDate: "2019-08-24", Registrant: "Ola Nordmann", Renew: true, Services: Service{ DNS: true, Email: true, Registrar: true, Webhotel: "none", }, Status: "active", } assert.Equal(t, expected, domain) } ================================================ FILE: providers/dns/domeneshop/internal/fixtures/create_record-request.json ================================================ { "data": "txtTXTtxt", "host": "example.com", "id": 0, "ttl": 300, "type": "TXT" } ================================================ FILE: providers/dns/domeneshop/internal/fixtures/create_record.json ================================================ { "id": 1 } ================================================ FILE: providers/dns/domeneshop/internal/fixtures/delete_record.json ================================================ [ { "id": 1, "host": "example.com", "ttl": 3600, "type": "TXT", "data": "txtTXTtxt" } ] ================================================ FILE: providers/dns/domeneshop/internal/fixtures/getDnsRecords.json ================================================ [ { "id": 1, "host": "example.com", "ttl": 3600, "type": "TXT", "data": "txtTXTtxt" } ] ================================================ FILE: providers/dns/domeneshop/internal/fixtures/getDomains.json ================================================ [ { "id": 1, "domain": "example.com", "expiry_date": "2019-08-24", "registered_date": "2019-08-24", "renew": true, "registrant": "Ola Nordmann", "status": "active", "nameservers": [ "ns1.hyp.net", "ns2.hyp.net", "ns3.hyp.net" ], "services": { "registrar": true, "dns": true, "email": true, "webhotel": "none" } } ] ================================================ FILE: providers/dns/domeneshop/internal/types.go ================================================ package internal // Domain JSON data structure. type Domain struct { Name string `json:"domain"` ID int `json:"id"` ExpiryDate string `json:"expiry_date"` Nameservers []string `json:"nameservers"` RegisteredDate string `json:"registered_date"` Registrant string `json:"registrant"` Renew bool `json:"renew"` Services Service `json:"services"` Status string } type Service struct { DNS bool `json:"dns"` Email bool `json:"email"` Registrar bool `json:"registrar"` Webhotel string `json:"webhotel"` } // DNSRecord JSON data structure. type DNSRecord struct { Data string `json:"data"` Host string `json:"host"` ID int `json:"id"` TTL int `json:"ttl"` Type string `json:"type"` } ================================================ FILE: providers/dns/dreamhost/dreamhost.go ================================================ // Package dreamhost implements a DNS provider for solving the DNS-01 challenge using DreamHost. // See https://help.dreamhost.com/hc/en-us/articles/217560167-API_overview // and https://help.dreamhost.com/hc/en-us/articles/217555707-DNS-API-commands for the API spec. package dreamhost import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dreamhost/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DREAMHOST_" EnvAPIKey = envNamespace + "API_KEY" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ BaseURL: internal.DefaultBaseURL, PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 1*time.Minute), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a new DNS provider using // environment variable DREAMHOST_API_KEY for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("dreamhost: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DreamHost. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dreamhost: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("dreamhost: credentials missing") } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) if config.BaseURL != "" { client.BaseURL = config.BaseURL } return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.AddRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("dreamhost: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.RemoveRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("dreamhost: %w", err) } return 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 } ================================================ FILE: providers/dns/dreamhost/dreamhost.toml ================================================ Name = "DreamHost" Description = '''''' URL = "https://www.dreamhost.com" Code = "dreamhost" Since = "v1.1.0" Example = ''' DREAMHOST_API_KEY="YOURAPIKEY" \ lego --dns dreamhost -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DREAMHOST_API_KEY = "The API key" [Configuration.Additional] DREAMHOST_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" DREAMHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 3600)" DREAMHOST_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://help.dreamhost.com/hc/en-us/articles/217560167-API_overview" ================================================ FILE: providers/dns/dreamhost/dreamhost_test.go ================================================ package dreamhost import ( "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey). WithDomain(envDomain) const ( fakeAPIKey = "asdf1234" fakeChallengeToken = "foobar" fakeKeyAuth = "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI" ) func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = fakeAPIKey config.BaseURL = server.URL config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }) } func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing API key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "dreamhost: some credentials information are missing: DREAMHOST_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { assert.NoError(t, err) assert.NotNil(t, p) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "123", }, { desc: "missing credentials", expected: "dreamhost: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { assert.NoError(t, err) assert.NotNil(t, p) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /", servermock.RawStringResponse(`{"data":"record_added","result":"success"}`), servermock.CheckQueryParameter().Strict(). With("cmd", "dns-add_record"). With("comment", "Managed+By+lego"). With("format", "json"). With("record", "_acme-challenge.example.com"). With("type", "TXT"). With("key", fakeAPIKey). With("value", fakeKeyAuth), ). Build(t) err := provider.Present("example.com", "", fakeChallengeToken) require.NoError(t, err) } func TestDNSProvider_PresentFailed(t *testing.T) { provider := mockBuilder(). Route("GET /", servermock.RawStringResponse(`{"data":"record_already_exists_remove_first","result":"error"}`)). Build(t) err := provider.Present("example.com", "", fakeChallengeToken) require.EqualError(t, err, "dreamhost: add TXT record failed: record_already_exists_remove_first") } func TestDNSProvider_Cleanup(t *testing.T) { provider := mockBuilder(). Route("GET /", servermock.RawStringResponse(`{"data":"record_removed","result":"success"}`), servermock.CheckQueryParameter().Strict(). With("cmd", "dns-remove_record"). With("comment", "Managed+By+lego"). With("format", "json"). With("record", "_acme-challenge.example.com"). With("type", "TXT"). With("key", fakeAPIKey). With("value", fakeKeyAuth), ). Build(t) err := provider.CleanUp("example.com", "", fakeChallengeToken) require.NoError(t, err) } func TestLivePresentAndCleanUp(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) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dreamhost/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultBaseURL the default API endpoint. const DefaultBaseURL = "https://api.dreamhost.com" const ( cmdAddRecord = "dns-add_record" cmdRemoveRecord = "dns-remove_record" ) // Client the Dreamhost API client. type Client struct { apiKey string BaseURL string HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(apiKey string) *Client { return &Client{ apiKey: apiKey, BaseURL: DefaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // AddRecord adds a TXT record. func (c *Client) AddRecord(ctx context.Context, domain, value string) error { query, err := c.buildEndpoint(cmdAddRecord, domain, value) if err != nil { return err } return c.updateTxtRecord(ctx, query) } // RemoveRecord removes a TXT record. func (c *Client) RemoveRecord(ctx context.Context, domain, value string) error { query, err := c.buildEndpoint(cmdRemoveRecord, domain, value) if err != nil { return err } return c.updateTxtRecord(ctx, query) } // action is either cmdAddRecord or cmdRemoveRecord. func (c *Client) buildEndpoint(action, domain, txt string) (*url.URL, error) { endpoint, err := url.Parse(c.BaseURL) if err != nil { return nil, err } query := endpoint.Query() query.Set("key", c.apiKey) query.Set("cmd", action) query.Set("format", "json") query.Set("record", domain) query.Set("type", "TXT") query.Set("value", txt) query.Set("comment", url.QueryEscape("Managed By lego")) endpoint.RawQuery = query.Encode() return endpoint, nil } // updateTxtRecord will either add or remove a TXT record. func (c *Client) updateTxtRecord(ctx context.Context, endpoint *url.URL) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } var response apiResponse err = json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if response.Result == "error" { return fmt.Errorf("add TXT record failed: %s", response.Data) } return nil } ================================================ FILE: providers/dns/dreamhost/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.BaseURL = server.URL client.HTTPClient = server.Client() return client, nil } func TestClient_AddRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.RawStringResponse(`{}`), servermock.CheckQueryParameter().Strict(). With("cmd", "dns-add_record"). With("comment", "Managed+By+lego"). With("format", "json"). With("key", "secret"). With("record", "example.com"). With("type", "TXT"). With("value", "aaa")). Build(t) err := client.AddRecord(t.Context(), "example.com", "aaa") require.NoError(t, err) } func TestClient_RemoveRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.RawStringResponse(`{}`), servermock.CheckQueryParameter().Strict(). With("cmd", "dns-remove_record"). With("comment", "Managed+By+lego"). With("format", "json"). With("key", "secret"). With("record", "example.com"). With("type", "TXT"). With("value", "aaa")). Build(t) err := client.RemoveRecord(t.Context(), "example.com", "aaa") require.NoError(t, err) } func TestClient_buildQuery(t *testing.T) { const fakeAPIKey = "asdf1234" testCases := []struct { desc string apiKey string baseURL string action string domain string txt string expected string }{ { desc: "success", apiKey: fakeAPIKey, action: cmdAddRecord, domain: "domain", txt: "TXTtxtTXT", expected: "https://api.dreamhost.com?cmd=dns-add_record&comment=Managed%2BBy%2Blego&format=json&key=asdf1234&record=domain&type=TXT&value=TXTtxtTXT", }, { desc: "Invalid base URL", apiKey: fakeAPIKey, baseURL: ":", action: cmdAddRecord, domain: "domain", txt: "TXTtxtTXT", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := NewClient(test.apiKey) if test.baseURL != "" { client.BaseURL = test.baseURL } endpoint, err := client.buildEndpoint(test.action, test.domain, test.txt) if test.expected == "" { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, test.expected, endpoint.String()) } }) } } ================================================ FILE: providers/dns/dreamhost/internal/types.go ================================================ package internal type apiResponse struct { Data string `json:"data"` Result string `json:"result"` } ================================================ FILE: providers/dns/duckdns/duckdns.go ================================================ // Package duckdns implements a DNS provider for solving the DNS-01 challenge using DuckDNS. // See http://www.duckdns.org/spec.jsp for more info on updating TXT records. package duckdns import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/duckdns/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DUCKDNS_" EnvToken = envNamespace + "TOKEN" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a new DNS provider using // environment variable DUCKDNS_TOKEN for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("duckdns: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DuckDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("duckdns: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("duckdns: credentials missing") } client := internal.NewClient(config.Token) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) return d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) } // CleanUp clears DuckDNS TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) return d.client.RemoveTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN)) } // 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/duckdns/duckdns.toml ================================================ Name = "Duck DNS" Description = '''''' URL = "https://www.duckdns.org/" Code = "duckdns" Since = "v0.5.0" Example = ''' DUCKDNS_TOKEN=xxxxxx \ lego --dns duckdns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DUCKDNS_TOKEN = "Account token" [Configuration.Additional] DUCKDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" DUCKDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DUCKDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" DUCKDNS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" [Links] API = "https://www.duckdns.org/spec.jsp" ================================================ FILE: providers/dns/duckdns/duckdns_test.go ================================================ package duckdns import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvToken: "", }, expected: "duckdns: some credentials information are missing: DUCKDNS_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "123", }, { desc: "missing credentials", expected: "duckdns: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/duckdns/internal/client.go ================================================ package internal import ( "context" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/miekg/dns" ) const defaultBaseURL = "https://www.duckdns.org/update" // Client the DuckDNS API client. type Client struct { token string baseURL string HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(token string) *Client { return &Client{ token: token, baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) AddTXTRecord(ctx context.Context, domain, value string) error { return c.UpdateTxtRecord(ctx, domain, value, false) } func (c *Client) RemoveTXTRecord(ctx context.Context, domain string) error { return c.UpdateTxtRecord(ctx, domain, "", true) } // UpdateTxtRecord Update the domains TXT record // To update the TXT record we just need to make one simple get request. // In DuckDNS you only have one TXT record shared with the domain and all subdomains. func (c *Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clearRecord bool) error { endpoint, _ := url.Parse(c.baseURL) mainDomain := getMainDomain(domain) if mainDomain == "" { return fmt.Errorf("unable to find the main domain for: %s", domain) } query := endpoint.Query() query.Set("domains", mainDomain) query.Set("token", c.token) query.Set("clear", strconv.FormatBool(clearRecord)) query.Set("txt", txt) endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } body := string(raw) if body != "OK" { return fmt.Errorf("request to change TXT record for DuckDNS returned the following result (%s) this does not match expectation (OK) used url [%s]", body, endpoint) } return nil } // DuckDNS only lets you write to your subdomain. // It must be in format subdomain.duckdns.org, // not in format subsubdomain.subdomain.duckdns.org. // So strip off everything that is not top 3 levels. func getMainDomain(domain string) string { domain = dns01.UnFqdn(domain) split := dns.Split(domain) if strings.HasSuffix(strings.ToLower(domain), "duckdns.org") { if len(split) < 3 { return "" } firstSubDomainIndex := split[len(split)-3] return domain[firstSubDomainIndex:] } return domain[split[len(split)-1]:] } ================================================ FILE: providers/dns/duckdns/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.baseURL = server.URL client.HTTPClient = server.Client() return client, nil } func TestClient_AddTXTRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.RawStringResponse("OK"), servermock.CheckQueryParameter().Strict(). With("clear", "false"). With("domains", "com"). With("token", "secret"). With("txt", "value")). Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "value") require.NoError(t, err) } func TestClient_RemoveTXTRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.RawStringResponse("OK"), servermock.CheckQueryParameter().Strict(). With("clear", "true"). With("domains", "com"). With("token", "secret"). With("txt", "")). Build(t) err := client.RemoveTXTRecord(t.Context(), "example.com") require.NoError(t, err) } func Test_getMainDomain(t *testing.T) { testCases := []struct { desc string domain string expected string }{ { desc: "empty", domain: "", expected: "", }, { desc: "missing sub domain", domain: "duckdns.org", expected: "", }, { desc: "explicit domain: sub domain", domain: "_acme-challenge.sub.duckdns.org", expected: "sub.duckdns.org", }, { desc: "explicit domain: subsub domain", domain: "_acme-challenge.my.sub.duckdns.org", expected: "sub.duckdns.org", }, { desc: "explicit domain: subsubsub domain", domain: "_acme-challenge.my.sub.sub.duckdns.org", expected: "sub.duckdns.org", }, { desc: "only subname: sub domain", domain: "_acme-challenge.sub", expected: "sub", }, { desc: "only subname: subsub domain", domain: "_acme-challenge.my.sub", expected: "sub", }, { desc: "only subname: subsubsub domain", domain: "_acme-challenge.my.sub.sub", expected: "sub", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() wDomain := getMainDomain(test.domain) assert.Equal(t, test.expected, wDomain) }) } } ================================================ FILE: providers/dns/dyn/dyn.go ================================================ // Package dyn implements a DNS provider for solving the DNS-01 challenge using Dyn Managed DNS. package dyn import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dyn/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DYN_" EnvCustomerName = envNamespace + "CUSTOMER_NAME" EnvUserName = envNamespace + "USER_NAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { CustomerName string UserName string Password string HTTPClient *http.Client PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // 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, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Dyn DNS. // Credentials must be passed in the environment variables: // DYN_CUSTOMER_NAME, DYN_USER_NAME and DYN_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvCustomerName, EnvUserName, EnvPassword) if err != nil { return nil, fmt.Errorf("dyn: %w", err) } config := NewDefaultConfig() config.CustomerName = values[EnvCustomerName] config.UserName = values[EnvUserName] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Dyn DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dyn: the configuration of the DNS provider is nil") } if config.CustomerName == "" || config.UserName == "" || config.Password == "" { return nil, errors.New("dyn: credentials missing") } client := internal.NewClient(config.CustomerName, config.UserName, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dyn: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("dyn: %w", err) } err = d.client.AddTXTRecord(ctx, authZone, info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("dyn: %w", err) } err = d.client.Publish(ctx, authZone, "Added TXT record for ACME dns-01 challenge using lego client") if err != nil { return fmt.Errorf("dyn: %w", err) } return d.client.Logout(ctx) } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dyn: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("dyn: %w", err) } err = d.client.RemoveTXTRecord(ctx, authZone, info.EffectiveFQDN) if err != nil { return fmt.Errorf("dyn: %w", err) } err = d.client.Publish(ctx, authZone, "Removed TXT record for ACME dns-01 challenge using lego client") if err != nil { return fmt.Errorf("dyn: %w", err) } return d.client.Logout(ctx) } // 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 } ================================================ FILE: providers/dns/dyn/dyn.toml ================================================ Name = "Dyn" Description = '''''' URL = "https://dyn.com/" Code = "dyn" Since = "v0.3.0" Example = ''' DYN_CUSTOMER_NAME=xxxxxx \ DYN_USER_NAME=yyyyy \ DYN_PASSWORD=zzzz \ lego --dns dyn -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DYN_CUSTOMER_NAME = "Customer name" DYN_USER_NAME = "User name" DYN_PASSWORD = "Password" [Configuration.Additional] DYN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" DYN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DYN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" DYN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://help.dyn.com/rest/" ================================================ FILE: providers/dns/dyn/dyn_test.go ================================================ package dyn import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvCustomerName, EnvUserName, EnvPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvCustomerName: "A", EnvUserName: "B", EnvPassword: "C", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvCustomerName: "", EnvUserName: "", EnvPassword: "", }, expected: "dyn: some credentials information are missing: DYN_CUSTOMER_NAME,DYN_USER_NAME,DYN_PASSWORD", }, { desc: "missing customer name", envVars: map[string]string{ EnvCustomerName: "", EnvUserName: "B", EnvPassword: "C", }, expected: "dyn: some credentials information are missing: DYN_CUSTOMER_NAME", }, { desc: "missing password", envVars: map[string]string{ EnvCustomerName: "A", EnvUserName: "", EnvPassword: "C", }, expected: "dyn: some credentials information are missing: DYN_USER_NAME", }, { desc: "missing username", envVars: map[string]string{ EnvCustomerName: "A", EnvUserName: "B", EnvPassword: "", }, expected: "dyn: some credentials information are missing: DYN_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string customerName string password string userName string expected string }{ { desc: "success", customerName: "A", password: "B", userName: "C", }, { desc: "missing credentials", expected: "dyn: credentials missing", }, { desc: "missing customer name", customerName: "", password: "B", userName: "C", expected: "dyn: credentials missing", }, { desc: "missing password", customerName: "A", password: "", userName: "C", expected: "dyn: credentials missing", }, { desc: "missing username", customerName: "A", password: "B", userName: "", expected: "dyn: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.CustomerName = test.customerName config.Password = test.password config.UserName = test.userName p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dyn/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.dynect.net/REST" // Client the Dyn API client. type Client struct { customerName string username string password string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(customerName, username, password string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ customerName: customerName, username: username, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // Publish updating Zone settings. // https://help.dyn.com/update-zone-api/ func (c *Client) Publish(ctx context.Context, zone, notes string) error { endpoint := c.baseURL.JoinPath("Zone", zone) payload := &publish{Publish: true, Notes: notes} req, err := newJSONRequest(ctx, http.MethodPut, endpoint, payload) if err != nil { return err } _, err = c.do(req) if err != nil { return err } return nil } // AddTXTRecord creating TXT Records. // https://help.dyn.com/create-txt-record-api/ func (c *Client) AddTXTRecord(ctx context.Context, authZone, fqdn, value string, ttl int) error { endpoint := c.baseURL.JoinPath("TXTRecord", authZone, fqdn) payload := map[string]any{ "rdata": map[string]string{ "txtdata": value, }, "ttl": strconv.Itoa(ttl), } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return err } _, err = c.do(req) if err != nil { return err } return nil } // RemoveTXTRecord deleting one or all existing TXT Records. // https://help.dyn.com/delete-txt-records-api/ func (c *Client) RemoveTXTRecord(ctx context.Context, authZone, fqdn string) error { endpoint := c.baseURL.JoinPath("TXTRecord", authZone, fqdn) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } func (c *Client) do(req *http.Request) (*APIResponse, error) { resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusInternalServerError { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var response APIResponse err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if resp.StatusCode >= http.StatusBadRequest { return nil, fmt.Errorf("%s: %w", response.Messages, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)) } if resp.StatusCode == http.StatusTemporaryRedirect { // TODO add support for HTTP 307 response and long running jobs return nil, errors.New("API request returned HTTP 307. This is currently unsupported") } if response.Status == "failure" { return nil, fmt.Errorf("%s: %w", response.Messages, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)) } return &response, nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } tok := getToken(req.Context()) if tok != "" { req.Header.Set(authTokenHeader, tok) } return req, nil } ================================================ FILE: providers/dns/dyn/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("bob", "user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil } func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("bob", "user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders()) } func TestClient_Publish(t *testing.T) { client := mockBuilder(). Route("PUT /Zone/example.com", servermock.ResponseFromFixture("publish.json"), servermock.CheckRequestJSONBody(`{"publish":true,"notes":"my message"}`)). Build(t) err := client.Publish(t.Context(), "example.com", "my message") require.NoError(t, err) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /TXTRecord/example.com/example.com.", servermock.ResponseFromFixture("create-txt-record.json"), servermock.CheckRequestJSONBody(`{"rdata":{"txtdata":"txt"},"ttl":"120"}`)). Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "example.com.", "txt", 120) require.NoError(t, err) } func TestClient_RemoveTXTRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /TXTRecord/example.com/example.com.", nil). Build(t) err := client.RemoveTXTRecord(t.Context(), "example.com", "example.com.") require.NoError(t, err) } ================================================ FILE: providers/dns/dyn/internal/fixtures/create-txt-record.json ================================================ { "fqdn": "example.com.", "rdata": { "txtdata": "txt" }, "record_type": "TXT", "ttl": 120, "zone": "example.com" } ================================================ FILE: providers/dns/dyn/internal/fixtures/login.json ================================================ { "status": "success", "data": { "token": "tok", "version": "456" }, "job_id": 123, "msgs": [] } ================================================ FILE: providers/dns/dyn/internal/fixtures/publish.json ================================================ { "status": "success", "data": {}, "job_id": 123, "msgs": [] } ================================================ FILE: providers/dns/dyn/internal/session.go ================================================ package internal import ( "context" "encoding/json" "net/http" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) type token string const tokenKey token = "token" const authTokenHeader = "Auth-Token" // login Starts a new Dyn API Session. Authenticates using customerName, username, password // and receives a token to be used in for subsequent requests. // https://help.dyn.com/session-log-in/ func (c *Client) login(ctx context.Context) (session, error) { endpoint := c.baseURL.JoinPath("Session") payload := &credentials{Customer: c.customerName, User: c.username, Pass: c.password} req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return session{}, err } dynRes, err := c.do(req) if err != nil { return session{}, err } var s session err = json.Unmarshal(dynRes.Data, &s) if err != nil { return session{}, errutils.NewUnmarshalError(req, http.StatusOK, dynRes.Data, err) } return s, nil } // Logout Destroys Dyn Session. // https://help.dyn.com/session-log-out/ func (c *Client) Logout(ctx context.Context) error { endpoint := c.baseURL.JoinPath("Session") req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } tok := getToken(ctx) if tok != "" { req.Header.Set(authTokenHeader, tok) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { tok, err := c.login(ctx) if err != nil { return nil, err } return context.WithValue(ctx, tokenKey, tok.Token), nil } func getToken(ctx context.Context) string { tok, ok := ctx.Value(tokenKey).(string) if !ok { return "" } return tok } ================================================ FILE: providers/dns/dyn/internal/session_test.go ================================================ package internal import ( "context" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockContext(t *testing.T) context.Context { t.Helper() return context.WithValue(t.Context(), tokenKey, "tok") } func TestClient_login(t *testing.T) { client := mockBuilder(). Route("POST /Session", servermock.ResponseFromFixture("login.json"), servermock.CheckRequestJSONBody(`{"customer_name":"bob","user_name":"user","password":"secret"}`)). Build(t) sess, err := client.login(t.Context()) require.NoError(t, err) expected := session{Token: "tok", Version: "456"} assert.Equal(t, expected, sess) } func TestClient_Logout(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). With(authTokenHeader, "tok"), ). Route("DELETE /Session", nil). Build(t) err := client.Logout(mockContext(t)) require.NoError(t, err) } func TestClient_CreateAuthenticatedContext(t *testing.T) { client := mockBuilder(). Route("POST /Session", servermock.ResponseFromFixture("login.json"), servermock.CheckRequestJSONBody(`{"customer_name":"bob","user_name":"user","password":"secret"}`)). Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) at := getToken(ctx) assert.Equal(t, "tok", at) } ================================================ FILE: providers/dns/dyn/internal/types.go ================================================ package internal import "encoding/json" type APIResponse struct { // One of 'success', 'failure', or 'incomplete' Status string `json:"status"` // The structure containing the actual results of the request Data json.RawMessage `json:"data"` // The ID of the job that was created in response to a request. JobID int `json:"job_id"` // A list of zero or more messages Messages json.RawMessage `json:"msgs"` } type credentials struct { Customer string `json:"customer_name"` User string `json:"user_name"` Pass string `json:"password"` } type session struct { Token string `json:"token"` Version string `json:"version"` } type publish struct { Publish bool `json:"publish"` Notes string `json:"notes"` } ================================================ FILE: providers/dns/dyndnsfree/dyndnsfree.go ================================================ // Package dyndnsfree implements a DNS provider for solving the DNS-01 challenge using DynDnsFree.de API. package dyndnsfree import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dyndnsfree/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DYNDNSFREE_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" 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 { Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for DynDNSFree. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("dyndnsfree: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DynDNSFree. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dyndnsfree: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.Username, config.Password) if err != nil { return nil, fmt.Errorf("dyndnsfree: new client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dyndnsforfree: could not find zone for domain %q: %w", domain, err) } err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("dyndnsfree: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // Records are deleted automatically. return 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 } ================================================ FILE: providers/dns/dyndnsfree/dyndnsfree.toml ================================================ Name = "DynDnsFree.de" Description = '''''' URL = "https://www.dyndnsfree.de" Code = "dyndnsfree" Since = "v4.23.0" Example = ''' DYNDNSFREE_USERNAME="xxx" \ DYNDNSFREE_PASSWORD="yyy" \ lego --dns dyndnsfree -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DYNDNSFREE_USERNAME = "Username" DYNDNSFREE_PASSWORD = "Password" [Configuration.Additional] DYNDNSFREE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" DYNDNSFREE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DYNDNSFREE_HTTP_TIMEOUT = "Request timeout in seconds (Default: 30)" [Links] API = "https://www.dyndnsfree.de/user/hilfe.php?hsm=2" ================================================ FILE: providers/dns/dyndnsfree/dyndnsfree_test.go ================================================ package dyndnsfree import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", }, }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "secret", }, expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "", }, expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "dyndnsfree: some credentials information are missing: DYNDNSFREE_USERNAME,DYNDNSFREE_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "user", password: "secret", }, { desc: "missing username", username: "", password: "secret", expected: "dyndnsfree: new client: credentials missing", }, { desc: "missing password", username: "user", password: "", expected: "dyndnsfree: new client: credentials missing", }, { desc: "missing credentials", expected: "dyndnsfree: new client: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dyndnsfree/internal/client.go ================================================ package internal import ( "bytes" "context" "errors" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://dynup.de/acme.php" type Client struct { username string password string baseURL string HTTPClient *http.Client } func NewClient(username, password string) (*Client, error) { if username == "" || password == "" { return nil, errors.New("credentials missing") } return &Client{ username: username, password: password, baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddTXTRecord(ctx context.Context, zone, hostname, value string) error { baseURL, err := url.Parse(c.baseURL) if err != nil { return err } query := baseURL.Query() query.Set("username", c.username) query.Set("password", c.password) query.Set("hostname", zone) query.Set("add_hostname", hostname) query.Set("txt", value) baseURL.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), http.NoBody) if err != nil { return err } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if !bytes.Equal(raw, []byte("success")) { return errors.New(string(raw)) } return nil } ================================================ FILE: providers/dns/dyndnsfree/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") if err != nil { return nil, err } client.baseURL = server.URL client.HTTPClient = server.Client() return client, nil } func TestAddTXTRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.RawStringResponse("success"), servermock.CheckQueryParameter().Strict(). With("add_hostname", "sub.example.com"). With("hostname", "example.com"). With("password", "secret"). With("txt", "value"). With("username", "user")). Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "sub.example.com", "value") require.NoError(t, err) } func TestAddTXTRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.RawStringResponse("error: authentification failed")). Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "sub.example.com", "value") require.EqualError(t, err, "error: authentification failed") } ================================================ FILE: providers/dns/dynu/dynu.go ================================================ // Package dynu implements a DNS provider for solving the DNS-01 challenge using Dynu DNS. package dynu import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dynu/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DYNU_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 3*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Dynu. // Credentials must be passed in the environment variables. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("dynu: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Dynu. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dynu: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("dynu: incomplete credentials, missing API key") } tr, err := internal.NewTokenTransport(config.APIKey) if err != nil { return nil, fmt.Errorf("dynu: %w", err) } client := internal.NewClient() client.HTTPClient = clientdebug.Wrap(tr.Wrap(config.HTTPClient)) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() rootDomain, err := d.client.GetRootDomain(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("dynu: could not find root domain for %s: %w", domain, err) } records, err := d.client.GetRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN), "TXT") if err != nil { return fmt.Errorf("dynu: failed to get records for %s: %w", domain, err) } for _, record := range records { // the record already exist if record.Hostname == dns01.UnFqdn(info.EffectiveFQDN) && record.TextData == info.Value { return nil } } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, rootDomain.DomainName) if err != nil { return fmt.Errorf("dynu: %w", err) } record := internal.DNSRecord{ Type: "TXT", DomainName: rootDomain.DomainName, Hostname: dns01.UnFqdn(info.EffectiveFQDN), NodeName: subDomain, TextData: info.Value, State: true, TTL: d.config.TTL, } err = d.client.AddNewRecord(ctx, rootDomain.ID, record) if err != nil { return fmt.Errorf("dynu: failed to add record to %s: %w", domain, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() rootDomain, err := d.client.GetRootDomain(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("dynu: could not find root domain for %s: %w", domain, err) } records, err := d.client.GetRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN), "TXT") if err != nil { return fmt.Errorf("dynu: failed to get records for %s: %w", domain, err) } for _, record := range records { if record.Hostname == dns01.UnFqdn(info.EffectiveFQDN) && record.TextData == info.Value { err = d.client.DeleteRecord(ctx, rootDomain.ID, record.ID) if err != nil { return fmt.Errorf("dynu: failed to remove TXT record for %s: %w", domain, err) } } } return nil } ================================================ FILE: providers/dns/dynu/dynu.toml ================================================ Name = "Dynu" Description = '''''' URL = "https://www.dynu.com/" Code = "dynu" Since = "v3.5.0" Example = ''' DYNU_API_KEY=1234567890abcdefghijklmnopqrstuvwxyz \ lego --dns dynu -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DYNU_API_KEY = "API key" [Configuration.Additional] DYNU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" DYNU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)" DYNU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" DYNU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.dynu.com/en-US/Support/API" ================================================ FILE: providers/dns/dynu/dynu_test.go ================================================ package dynu import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "dynu: some credentials information are missing: DYNU_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string apiKey string }{ { desc: "success", apiKey: "api_key", }, { desc: "missing api key", apiKey: "", expected: "dynu: incomplete credentials, missing API key", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dynu/internal/auth.go ================================================ package internal import ( "errors" "net/http" ) const apiKeyHeader = "Api-Key" // TokenTransport HTTP transport for API authentication. type TokenTransport struct { apiKey string // Transport is the underlying HTTP transport to use when making requests. // It will default to http.DefaultTransport if nil. Transport http.RoundTripper } // NewTokenTransport Creates an HTTP transport for API authentication. func NewTokenTransport(apiKey string) (*TokenTransport, error) { if apiKey == "" { return nil, errors.New("credentials missing: API key") } return &TokenTransport{apiKey: apiKey}, nil } // RoundTrip executes a single HTTP transaction. func (t *TokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { enrichedReq := &http.Request{} *enrichedReq = *req enrichedReq.Header = make(http.Header, len(req.Header)) for k, s := range req.Header { enrichedReq.Header[k] = append([]string(nil), s...) } if t.apiKey != "" { enrichedReq.Header.Set(apiKeyHeader, t.apiKey) } return t.transport().RoundTrip(enrichedReq) } func (t *TokenTransport) transport() http.RoundTripper { if t.Transport != nil { return t.Transport } return http.DefaultTransport } // Client Creates a new HTTP client. func (t *TokenTransport) Client() *http.Client { return &http.Client{Transport: t} } // Wrap wraps an HTTP client Transport with the TokenTransport. func (t *TokenTransport) Wrap(client *http.Client) *http.Client { backup := client.Transport t.Transport = backup client.Transport = t return client } ================================================ FILE: providers/dns/dynu/internal/auth_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewTokenTransport_success(t *testing.T) { apiKey := "api" transport, err := NewTokenTransport(apiKey) require.NoError(t, err) assert.NotNil(t, transport) } func TestNewTokenTransport_missing_credentials(t *testing.T) { apiKey := "" transport, err := NewTokenTransport(apiKey) require.Error(t, err) assert.Nil(t, transport) } func TestTokenTransport_RoundTrip(t *testing.T) { apiKey := "api" transport, err := NewTokenTransport(apiKey) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) resp, err := transport.RoundTrip(req) require.NoError(t, err) assert.Equal(t, "api", resp.Request.Header.Get(apiKeyHeader)) } ================================================ FILE: providers/dns/dynu/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.dynu.com/v2" type Client struct { baseURL *url.URL HTTPClient *http.Client } func NewClient() *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ HTTPClient: &http.Client{Timeout: 5 * time.Second}, baseURL: baseURL, } } // GetRecords Get DNS records based on a hostname and resource record type. func (c *Client) GetRecords(ctx context.Context, hostname, recordType string) ([]DNSRecord, error) { endpoint := c.baseURL.JoinPath("dns", "record", hostname) query := endpoint.Query() query.Set("recordType", recordType) endpoint.RawQuery = query.Encode() apiResp := RecordsResponse{} err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp) if err != nil { return nil, err } if apiResp.StatusCode/100 != 2 { return nil, fmt.Errorf("API error: %w", apiResp.APIException) } return apiResp.DNSRecords, nil } // AddNewRecord Add a new DNS record for DNS service. func (c *Client) AddNewRecord(ctx context.Context, domainID int64, record DNSRecord) error { endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record") reqBody, err := json.Marshal(record) if err != nil { return fmt.Errorf("failed to create request JSON body: %w", err) } apiResp := RecordResponse{} err = c.doRetry(ctx, http.MethodPost, endpoint.String(), reqBody, &apiResp) if err != nil { return err } if apiResp.StatusCode/100 != 2 { return fmt.Errorf("API error: %w", apiResp.APIException) } return nil } // DeleteRecord Remove a DNS record from DNS service. func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int64) error { endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record", strconv.FormatInt(recordID, 10)) apiResp := APIException{} err := c.doRetry(ctx, http.MethodDelete, endpoint.String(), nil, &apiResp) if err != nil { return err } if apiResp.StatusCode/100 != 2 { return fmt.Errorf("API error: %w", apiResp) } return nil } // GetRootDomain Get the root domain name based on a hostname. func (c *Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostname, error) { endpoint := c.baseURL.JoinPath("dns", "getroot", hostname) apiResp := DNSHostname{} err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp) if err != nil { return nil, err } if apiResp.StatusCode/100 != 2 { return nil, fmt.Errorf("API error: %w", apiResp.APIException) } return &apiResp, nil } // doRetry the API is really unstable, so we need to retry on EOF. func (c *Client) doRetry(ctx context.Context, method, uri string, body []byte, result any) error { operation := func() error { return c.do(ctx, method, uri, body, result) } notify := func(err error, duration time.Duration) { log.Printf("client retries because of %v", err) } bo := backoff.NewExponentialBackOff() bo.InitialInterval = 1 * time.Second return wait.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithNotify(notify)) } func (c *Client) do(ctx context.Context, method, uri string, body []byte, result any) error { var reqBody io.Reader if len(body) > 0 { reqBody = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, uri, reqBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") resp, err := c.HTTPClient.Do(req) if errors.Is(err, io.EOF) { return err } if err != nil { return backoff.Permanent(fmt.Errorf("client error: %w", errutils.NewHTTPDoError(req, err))) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return backoff.Permanent(errutils.NewReadResponseError(req, resp.StatusCode, err)) } err = json.Unmarshal(raw, result) if err != nil { return backoff.Permanent(errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)) } return nil } ================================================ FILE: providers/dns/dynu/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient() client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestGetRootDomain(t *testing.T) { type expected struct { domain *DNSHostname error string } testCases := []struct { desc string pattern string status int file string expected expected }{ { desc: "success", pattern: "GET /dns/getroot/test.lego.freeddns.org", status: http.StatusOK, file: "get_root_domain.json", expected: expected{ domain: &DNSHostname{ APIException: &APIException{ StatusCode: 200, }, ID: 9007481, DomainName: "lego.freeddns.org", Hostname: "test.lego.freeddns.org", Node: "test", }, }, }, { desc: "invalid", pattern: "GET /dns/getroot/test.lego.freeddns.org", status: http.StatusNotImplemented, file: "get_root_domain_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status)). Build(t) domain, err := client.GetRootDomain(t.Context(), "test.lego.freeddns.org") if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) return } require.NoError(t, err) assert.NotNil(t, domain) assert.Equal(t, test.expected.domain, domain) }) } } func TestGetRecords(t *testing.T) { type expected struct { records []DNSRecord error string } testCases := []struct { desc string pattern string status int file string expected expected }{ { desc: "success", pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusOK, file: "get_records.json", expected: expected{ records: []DNSRecord{ { ID: 6041417, Type: "TXT", DomainID: 9007481, DomainName: "lego.freeddns.org", NodeName: "_acme-challenge", Hostname: "_acme-challenge.lego.freeddns.org", State: true, Content: `_acme-challenge.lego.freeddns.org. 300 IN TXT "txt_txt_txt_txt_txt_txt_txt"`, TextData: "txt_txt_txt_txt_txt_txt_txt", TTL: 300, }, { ID: 6041422, Type: "TXT", DomainID: 9007481, DomainName: "lego.freeddns.org", NodeName: "_acme-challenge", Hostname: "_acme-challenge.lego.freeddns.org", State: true, Content: `_acme-challenge.lego.freeddns.org. 300 IN TXT "txt_txt_txt_txt_txt_txt_txt_2"`, TextData: "txt_txt_txt_txt_txt_txt_txt_2", TTL: 300, }, }, }, }, { desc: "empty", pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusOK, file: "get_records_empty.json", expected: expected{ records: []DNSRecord{}, }, }, { desc: "invalid", pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusNotImplemented, file: "get_records_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status), servermock.CheckQueryParameter().Strict(). With("recordType", "TXT")). Build(t) records, err := client.GetRecords(t.Context(), "_acme-challenge.lego.freeddns.org", "TXT") if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) return } require.NoError(t, err) assert.NotNil(t, records) assert.Equal(t, test.expected.records, records) }) } } func TestAddNewRecord(t *testing.T) { type expected struct { error string } testCases := []struct { desc string pattern string status int file string expected expected }{ { desc: "success", pattern: "POST /dns/9007481/record", status: http.StatusOK, file: "add_new_record.json", }, { desc: "invalid", pattern: "POST /dns/9007481/record", status: http.StatusNotImplemented, file: "add_new_record_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status), servermock.CheckRequestJSONBodyFromFixture("add_new_record-request.json")). Build(t) record := DNSRecord{ Type: "TXT", DomainName: "lego.freeddns.org", Hostname: "_acme-challenge.lego.freeddns.org", NodeName: "_acme-challenge", TextData: "txt_txt_txt_txt_txt_txt_txt_2", State: true, TTL: 300, } err := client.AddNewRecord(t.Context(), 9007481, record) if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) return } require.NoError(t, err) }) } } func TestDeleteRecord(t *testing.T) { type expected struct { error string } testCases := []struct { desc string pattern string status int file string expected expected }{ { desc: "success", pattern: "DELETE /", status: http.StatusOK, file: "delete_record.json", }, { desc: "invalid", pattern: "DELETE /", status: http.StatusNotImplemented, file: "delete_record_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status)). Build(t) err := client.DeleteRecord(t.Context(), 9007481, 6041418) if test.expected.error != "" { assert.EqualError(t, err, test.expected.error) return } require.NoError(t, err) }) } } ================================================ FILE: providers/dns/dynu/internal/fixtures/add_new_record-request.json ================================================ { "recordType": "TXT", "domainName": "lego.freeddns.org", "nodeName": "_acme-challenge", "hostname": "_acme-challenge.lego.freeddns.org", "state": true, "textData": "txt_txt_txt_txt_txt_txt_txt_2", "ttl": 300 } ================================================ FILE: providers/dns/dynu/internal/fixtures/add_new_record.json ================================================ { "statusCode": 200, "id": 6041417, "domainId": 9007481, "domainName": "lego.freeddns.org", "nodeName": "_acme-challenge", "hostname": "_acme-challenge.lego.freeddns.org", "recordType": "TXT", "ttl": 300, "state": true, "content": "_acme-challenge.lego.freeddns.org. 300 IN TXT \"txt_txt_txt_txt_txt_txt_txt\"", "updatedOn": "2020-03-10T04:00:36.923", "textData": "txt_txt_txt_txt_txt_txt_txt" } ================================================ FILE: providers/dns/dynu/internal/fixtures/add_new_record_invalid.json ================================================ { "statusCode": 501, "type": "Argument Exception", "message": "Invalid." } ================================================ FILE: providers/dns/dynu/internal/fixtures/delete_record.json ================================================ { "statusCode": 200 } ================================================ FILE: providers/dns/dynu/internal/fixtures/delete_record_invalid.json ================================================ { "statusCode": 501, "type": "Argument Exception", "message": "Invalid." } ================================================ FILE: providers/dns/dynu/internal/fixtures/get_records.json ================================================ { "statusCode": 200, "dnsRecords": [ { "id": 6041417, "domainId": 9007481, "domainName": "lego.freeddns.org", "nodeName": "_acme-challenge", "hostname": "_acme-challenge.lego.freeddns.org", "recordType": "TXT", "ttl": 300, "state": true, "content": "_acme-challenge.lego.freeddns.org. 300 IN TXT \"txt_txt_txt_txt_txt_txt_txt\"", "updatedOn": "2020-03-10T04:00:36.923", "textData": "txt_txt_txt_txt_txt_txt_txt" }, { "id": 6041422, "domainId": 9007481, "domainName": "lego.freeddns.org", "nodeName": "_acme-challenge", "hostname": "_acme-challenge.lego.freeddns.org", "recordType": "TXT", "ttl": 300, "state": true, "content": "_acme-challenge.lego.freeddns.org. 300 IN TXT \"txt_txt_txt_txt_txt_txt_txt_2\"", "updatedOn": "2020-03-10T04:03:17.563", "textData": "txt_txt_txt_txt_txt_txt_txt_2" } ] } ================================================ FILE: providers/dns/dynu/internal/fixtures/get_records_empty.json ================================================ { "statusCode": 200, "dnsRecords": [] } ================================================ FILE: providers/dns/dynu/internal/fixtures/get_records_invalid.json ================================================ { "statusCode": 501, "type": "Argument Exception", "message": "Invalid." } ================================================ FILE: providers/dns/dynu/internal/fixtures/get_root_domain.json ================================================ { "statusCode": 200, "id": 9007481, "domainName": "lego.freeddns.org", "hostname": "test.lego.freeddns.org", "node": "test" } ================================================ FILE: providers/dns/dynu/internal/fixtures/get_root_domain_invalid.json ================================================ { "statusCode": 501, "type": "Argument Exception", "message": "Invalid." } ================================================ FILE: providers/dns/dynu/internal/types.go ================================================ package internal import "fmt" // APIException defines model for apiException. type APIException struct { Message string `json:"message,omitempty"` StatusCode int32 `json:"statusCode,omitempty"` Type string `json:"type,omitempty"` } func (a APIException) Error() string { return fmt.Sprintf("%d: %s: %s", a.StatusCode, a.Type, a.Message) } // APIResponse defines model for apiResponse. type APIResponse struct { Exception *APIException `json:"exception,omitempty"` StatusCode int32 `json:"statusCode,omitempty"` } // DNSRecord defines model for dnsRecords. type DNSRecord struct { ID int64 `json:"id,omitempty"` Type string `json:"recordType,omitempty"` DomainID int64 `json:"domainId,omitempty"` DomainName string `json:"domainName,omitempty"` NodeName string `json:"nodeName,omitempty"` Hostname string `json:"hostname,omitempty"` State bool `json:"state,omitempty"` Content string `json:"content,omitempty"` TextData string `json:"textData,omitempty"` TTL int `json:"ttl,omitempty"` } // DNSHostname defines model for DNS.hostname. type DNSHostname struct { *APIException ID int64 `json:"id,omitempty"` DomainName string `json:"domainName,omitempty"` Hostname string `json:"hostname,omitempty"` Node string `json:"node,omitempty"` } // RecordsResponse defines model for recordsResponse. type RecordsResponse struct { *APIException DNSRecords []DNSRecord `json:"dnsRecords,omitempty"` } // RecordResponse defines model for recordResponse. type RecordResponse struct { *APIException DNSRecord } ================================================ FILE: providers/dns/easydns/easydns.go ================================================ // Package easydns implements a DNS provider for solving the DNS-01 challenge using EasyDNS API. package easydns import ( "context" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/easydns/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "EASYDNS_" EnvEndpoint = envNamespace + "ENDPOINT" EnvToken = envNamespace + "TOKEN" EnvKey = envNamespace + "KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL Token string Key string TTL int HTTPClient *http.Client PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration } // 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), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() endpoint, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, internal.DefaultBaseURL)) if err != nil { return nil, fmt.Errorf("easydns: %w", err) } config.Endpoint = endpoint values, err := env.Get(EnvToken, EnvKey) if err != nil { return nil, fmt.Errorf("easydns: %w", err) } config.Token = values[EnvToken] config.Key = values[EnvKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for EasyDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("easydns: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("easydns: the API token is missing") } if config.Key == "" { return nil, errors.New("easydns: the API key is missing") } client := internal.NewClient(config.Token, config.Key) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) if config.Endpoint != nil { client.BaseURL = config.Endpoint } return &DNSProvider{config: config, client: client, recordIDs: map[string]string{}}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("easydns: %w", err) } if authZone == "" { return fmt.Errorf("easydns: could not find zone for domain %q", domain) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("easydns: %w", err) } record := internal.ZoneRecord{ Domain: authZone, Host: subDomain, Type: "TXT", Rdata: info.Value, TTL: strconv.Itoa(d.config.TTL), Priority: "0", } recordID, err := d.client.AddRecord(ctx, dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("easydns: error adding zone record: %w", err) } key := getMapKey(info.EffectiveFQDN, info.Value) d.recordIDsMu.Lock() d.recordIDs[key] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) key := getMapKey(info.EffectiveFQDN, info.Value) d.recordIDsMu.Lock() recordID, exists := d.recordIDs[key] d.recordIDsMu.Unlock() if !exists { return nil } authZone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("easydns: %w", err) } if authZone == "" { return fmt.Errorf("easydns: could not find zone for domain %q", domain) } err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID) if err != nil { return fmt.Errorf("easydns: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, key) d.recordIDsMu.Unlock() return 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } func getMapKey(fqdn, value string) string { return fqdn + "|" + value } func (d *DNSProvider) findZone(ctx context.Context, domain string) (string, error) { var errAll error for { i := strings.Index(domain, ".") if i == -1 { break } _, err := d.client.ListZones(ctx, domain) if err == nil { return domain, nil } errAll = errors.Join(errAll, err) domain = domain[i+1:] } return "", errAll } ================================================ FILE: providers/dns/easydns/easydns.toml ================================================ Name = "EasyDNS" Description = '''''' URL = "https://easydns.com/" Code = "easydns" Since = "v2.6.0" Example = ''' EASYDNS_TOKEN=xxx \ EASYDNS_KEY=yyy \ lego --dns easydns -d '*.example.com' -d example.com run ''' Additional = ''' To test with the sandbox environment set ```EASYDNS_ENDPOINT=https://sandbox.rest.easydns.net``` ''' [Configuration] [Configuration.Credentials] EASYDNS_TOKEN = "API Token" EASYDNS_KEY = "API Key" [Configuration.Additional] EASYDNS_ENDPOINT = "The endpoint URL of the API Server" EASYDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" EASYDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" EASYDNS_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" EASYDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" EASYDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.sandbox.rest.easydns.net" ================================================ FILE: providers/dns/easydns/easydns_test.go ================================================ package easydns import ( "fmt" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvEndpoint, EnvToken, EnvKey). WithDomain(envDomain) func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { endpoint, err := url.Parse(server.URL) if err != nil { return nil, err } config := NewDefaultConfig() config.Token = "TOKEN" config.Key = "SECRET" config.Endpoint = endpoint config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithJSONHeaders(). WithAuthorization("Basic VE9LRU46U0VDUkVU"), servermock.CheckQueryParameter().Strict(). With("format", "json")) } func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "TOKEN", EnvKey: "SECRET", }, }, { desc: "missing token", envVars: map[string]string{ EnvKey: "SECRET", }, expected: "easydns: some credentials information are missing: EASYDNS_TOKEN", }, { desc: "missing key", envVars: map[string]string{ EnvToken: "TOKEN", }, expected: "easydns: some credentials information are missing: EASYDNS_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string config *Config expected string }{ { desc: "success", config: &Config{ Token: "TOKEN", Key: "KEY", }, }, { desc: "nil config", config: nil, expected: "easydns: the configuration of the DNS provider is nil", }, { desc: "missing token", config: &Config{ Key: "KEY", }, expected: "easydns: the API token is missing", }, { desc: "missing key", config: &Config{ Token: "TOKEN", }, expected: "easydns: the API key is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p, err := NewDNSProviderConfig(test.config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /zones/records/all/example.com", servermock.RawStringResponse(`{ "msg": "string", "status": 200, "tm": 0, "data": [{ "id": "60898922", "domain": "example.com", "host": "hosta", "ttl": "300", "prio": "0", "geozone_id": "0", "type": "A", "rdata": "1.2.3.4", "last_mod": "2019-08-28 19:09:50" }], "count": 0, "total": 0, "start": 0, "max": 0 } `), servermock.CheckQueryParameter().Strict(). With("format", "json")). Route("PUT /zones/records/add/example.com/TXT", servermock.RawStringResponse(`{ "msg": "OK", "tm": 1554681934, "data": { "host": "_acme-challenge", "geozone_id": 0, "ttl": "120", "prio": "0", "rdata": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", "revoked": 0, "id": "123456789", "new_host": "_acme-challenge.example.com" }, "status": 201 }`), servermock.CheckRequestJSONBody(`{"domain":"example.com","host":"_acme-challenge","ttl":"120","prio":"0","type":"TXT","rdata":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"} `)). Build(t) err := provider.Present("example.com", "token", "keyAuth") require.NoError(t, err) require.Contains(t, provider.recordIDs, "_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM") } func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(t *testing.T) { provider := mockBuilder(). Route("GET /zones/records/all/_acme-challenge.example.com", servermock.RawStringResponse(`{ "msg": "string", "status": 200, "tm": 0, "data": [{ "id": "60898922", "domain": "example.com", "host": "hosta", "ttl": "300", "prio": "0", "geozone_id": "0", "type": "A", "rdata": "1.2.3.4", "last_mod": "2019-08-28 19:09:50" }], "count": 0, "total": 0, "start": 0, "max": 0 } `)). Build(t) err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) } func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) { provider := mockBuilder(). Route("GET /zones/records/all/_acme-challenge.example.com", servermock.RawStringResponse(`{ "msg": "string", "status": 200, "tm": 0, "data": [{ "id": "60898922", "domain": "example.com", "host": "hosta", "ttl": "300", "prio": "0", "geozone_id": "0", "type": "A", "rdata": "1.2.3.4", "last_mod": "2019-08-28 19:09:50" }], "count": 0, "total": 0, "start": 0, "max": 0 } `)). Route("DELETE /zones/records/_acme-challenge.example.com/123456", servermock.RawStringResponse(`{ "msg": "OK", "data": { "domain": "example.com", "id": "123456" }, "status": 200 }`)). Build(t) provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456" err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) } func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) { errorMessage := `{ "error": { "code": 406, "message": "Provided id is invalid or you do not have permission to access it." } }` provider := mockBuilder(). Route("GET /zones/records/all/example.com", servermock.RawStringResponse(`{ "msg": "string", "status": 200, "tm": 0, "data": [{ "id": "60898922", "domain": "example.com", "host": "hosta", "ttl": "300", "prio": "0", "geozone_id": "0", "type": "A", "rdata": "1.2.3.4", "last_mod": "2019-08-28 19:09:50" }], "count": 0, "total": 0, "start": 0, "max": 0 } `)). Route("DELETE /zones/records/example.com/123456", servermock.RawStringResponse(errorMessage). WithStatusCode(http.StatusNotAcceptable)). Build(t) provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456" err := provider.CleanUp("example.com", "token", "keyAuth") expectedError := fmt.Sprintf("easydns: unexpected status code: [status code: 406] body: %v", errorMessage) require.EqualError(t, err, expectedError) } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/easydns/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultBaseURL the default API endpoint. const DefaultBaseURL = "https://rest.easydns.net" // Client the EasyDNS API client. type Client struct { token string key string BaseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(token, key string) *Client { baseURL, _ := url.Parse(DefaultBaseURL) return &Client{ token: token, key: key, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) ListZones(ctx context.Context, domain string) ([]ZoneRecord, error) { endpoint := c.BaseURL.JoinPath("zones", "records", "all", domain) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } response := &apiResponse[[]ZoneRecord]{} err = c.do(req, response) if err != nil { return nil, err } if response.Error != nil { return nil, response.Error } return response.Data, nil } func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord) (string, error) { endpoint := c.BaseURL.JoinPath("zones", "records", "add", domain, "TXT") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record) if err != nil { return "", err } response := &apiResponse[*ZoneRecord]{} err = c.do(req, response) if err != nil { return "", err } if response.Error != nil { return "", response.Error } recordID := response.Data.ID return recordID, nil } func (c *Client) DeleteRecord(ctx context.Context, domain, recordID string) error { endpoint := c.BaseURL.JoinPath("zones", "records", domain, recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } err = c.do(req, nil) return err } func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth(c.token, c.key) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } query := endpoint.Query() query.Set("format", "json") endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/easydns/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("tok", "k") client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("tok", "k"), ) } func TestClient_ListZones(t *testing.T) { client := mockBuilder(). Route("GET /zones/records/all/example.com", servermock.ResponseFromFixture("list-zone.json")). Build(t) zones, err := client.ListZones(t.Context(), "example.com") require.NoError(t, err) expected := []ZoneRecord{{ ID: "60898922", Domain: "example.com", Host: "hosta", TTL: "300", Priority: "0", Type: "A", Rdata: "1.2.3.4", LastMod: "2019-08-28 19:09:50", }} assert.Equal(t, expected, zones) } func TestClient_ListZones_error(t *testing.T) { client := mockBuilder(). Route("GET /zones/records/all/example.com", servermock.ResponseFromFixture("error1.json")). Build(t) _, err := client.ListZones(t.Context(), "example.com") require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("PUT /zones/records/add/example.com/TXT", servermock.ResponseFromFixture("add-record.json").WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"domain":"example.com","host":"test631","ttl":"300","prio":"0","type":"TXT","rdata":"txt"}`)). Build(t) record := ZoneRecord{ Domain: "example.com", Host: "test631", Type: "TXT", Rdata: "txt", TTL: "300", Priority: "0", } recordID, err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) assert.Equal(t, "xxx", recordID) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("PUT /zones/records/add/example.com/TXT", servermock.ResponseFromFixture("error1.json").WithStatusCode(http.StatusCreated)). Build(t) record := ZoneRecord{ Domain: "example.com", Host: "test631", Type: "TXT", Rdata: "txt", TTL: "300", Priority: "0", } _, err := client.AddRecord(t.Context(), "example.com", record) require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/records/example.com/xxx", nil). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "xxx") require.NoError(t, err) } ================================================ FILE: providers/dns/easydns/internal/fixtures/add-record.json ================================================ { "msg": "message", "tm": 1, "data": { "id": "xxx", "domain": "example.com", "host": "test631", "ttl": "300", "prio": "0", "type": "TXT", "rdata": "txt" }, "status": 201 } ================================================ FILE: providers/dns/easydns/internal/fixtures/error.json ================================================ { "msg": "Enhance your calm", "status": 403 } ================================================ FILE: providers/dns/easydns/internal/fixtures/error1.json ================================================ { "error": { "code": 420, "message": "Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!" } } ================================================ FILE: providers/dns/easydns/internal/fixtures/list-zone.json ================================================ { "msg": "message", "status": 200, "tm": 0, "data": [ { "id": "60898922", "domain": "example.com", "host": "hosta", "ttl": "300", "prio": "0", "geozone_id": "0", "type": "A", "rdata": "1.2.3.4", "last_mod": "2019-08-28 19:09:50" } ], "count": 43, "total": 43, "start": 0, "max": 1000 } ================================================ FILE: providers/dns/easydns/internal/readme.md ================================================ The API doc is mainly wrong on the response schema: ex: - the doc for `/zones/records/all/{domain}` ```json { "msg": "string", "status": 200, "tm": 1709190001, "data": { "id": 60898922, "domain": "example.com", "host": "hosta", "ttl": 300, "prio": 0, "geozone_id": 0, "type": "A", "rdata": "1.2.3.4", "last_mod": "2019-08-28 19:09:50" }, "count": 0, "total": 0, "start": 0, "max": 0 } ``` - The reality: ```json { "tm": 1709190001, "data": [ { "id": "60898922", "domain": "example.com", "host": "hosta", "ttl": "300", "prio": "0", "geozone_id": "0", "type": "A", "rdata": "1.2.3.4", "last_mod": "2019-08-28 19:09:50" } ], "count": 0, "total": 0, "start": 0, "max": 0, "status": 200 } ``` `data` is an array. `id`, `ttl`, `geozone_id` are strings. ================================================ FILE: providers/dns/easydns/internal/types.go ================================================ package internal import "fmt" type apiResponse[T any] struct { Msg string `json:"msg"` Status int `json:"status"` Tm int `json:"tm"` Data T `json:"data"` Count int `json:"count"` Total int `json:"total"` Start int `json:"start"` Max int `json:"max"` Error *Error `json:"error,omitempty"` } type ZoneRecord struct { ID string `json:"id,omitempty"` Domain string `json:"domain"` Host string `json:"host"` TTL string `json:"ttl"` Priority string `json:"prio"` Type string `json:"type"` Rdata string `json:"rdata"` LastMod string `json:"last_mod,omitempty"` Revoked int `json:"revoked,omitempty"` NewHost string `json:"new_host,omitempty"` } type Error struct { Code int `json:"code"` Message string `json:"message"` } func (e *Error) Error() string { return fmt.Sprintf("code %d: %s", e.Code, e.Message) } ================================================ FILE: providers/dns/edgecenter/edgecenter.go ================================================ // Package edgecenter implements a DNS provider for solving the DNS-01 challenge using EdgeCenter. package edgecenter import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/gcore" ) // Environment variables names. const ( envNamespace = "EDGECENTER_" EnvPermanentAPIToken = envNamespace + "PERMANENT_API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://api.edgecenter.ru/dns" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config for DNSProvider. type Config = gcore.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, gcore.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, gcore.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider an implementation of challenge.Provider contract. type DNSProvider struct { prv challenge.ProviderTimeout } // NewDNSProvider returns an instance of DNSProvider configured for G-Core DNS API. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvPermanentAPIToken) if err != nil { return nil, fmt.Errorf("edgecenter: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvPermanentAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for G-Core DNS API. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("edgecenter: the configuration of the DNS provider is nil") } provider, err := gcore.NewDNSProviderConfig(config, defaultBaseURL) if err != nil { return nil, fmt.Errorf("edgecenter: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("edgecenter: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("edgecenter: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/edgecenter/edgecenter.toml ================================================ Name = "EdgeCenter" Description = '''''' URL = "https://edgecenter.ru/dns" Code = "edgecenter" Since = "v4.29.0" Example = ''' EDGECENTER_PERMANENT_API_TOKEN=xxxxx \ lego --dns edgecenter -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] EDGECENTER_PERMANENT_API_TOKEN = "Permanent API token (https://edgecenter.ru/blog/permanent-api-token-explained/)" [Configuration.Additional] EDGECENTER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" EDGECENTER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" EDGECENTER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" EDGECENTER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://apidocs.edgecenter.ru/dns" ================================================ FILE: providers/dns/edgecenter/edgecenter_test.go ================================================ package edgecenter import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvPermanentAPIToken).WithDomain(envNamespace + "DOMAIN") func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvPermanentAPIToken: "A", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvPermanentAPIToken: "", }, expected: "edgecenter: some credentials information are missing: EDGECENTER_PERMANENT_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "A", }, { desc: "missing credentials", expected: "edgecenter: incomplete credentials provided", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/edgedns/edgedns.go ================================================ // Package edgedns replaces fastdns, implementing a DNS provider for solving the DNS-01 challenge using Akamai EdgeDNS. package edgedns import ( "context" "errors" "fmt" "net/http" "slices" "strings" "time" edgegriddns "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/dns" "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/edgegrid" "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/session" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" ) // Environment variables names. const ( envNamespace = "AKAMAI_" EnvEdgeRc = envNamespace + "EDGERC" EnvEdgeRcSection = envNamespace + "EDGERC_SECTION" EnvAccountSwitchKey = envNamespace + "ACCOUNT_SWITCH_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) // Test Environment variables names (unused). // TODO(ldez): must be moved into test files. const ( EnvHost = envNamespace + "HOST" EnvClientToken = envNamespace + "CLIENT_TOKEN" EnvClientSecret = envNamespace + "CLIENT_SECRET" EnvAccessToken = envNamespace + "ACCESS_TOKEN" ) const ( defaultPropagationTimeout = 3 * time.Minute defaultPollInterval = 15 * time.Second ) const maxBody = 131072 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { *edgegrid.Config PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollInterval), Config: &edgegrid.Config{MaxBody: maxBody}, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config } // NewDNSProvider returns a DNSProvider instance configured for Akamai EdgeDNS: // Akamai's credentials are automatically detected in the following locations and prioritized in the following order: // // 1. Section-specific environment variables `AKAMAI_{SECTION}_HOST`, `AKAMAI_{SECTION}_ACCESS_TOKEN`, `AKAMAI_{SECTION}_CLIENT_TOKEN`, `AKAMAI_{SECTION}_CLIENT_SECRET` where `{SECTION}` is specified using `AKAMAI_EDGERC_SECTION` // 2. If `AKAMAI_EDGERC_SECTION` is not defined or is set to `default`: Environment variables `AKAMAI_HOST`, `AKAMAI_ACCESS_TOKEN`, `AKAMAI_CLIENT_TOKEN`, `AKAMAI_CLIENT_SECRET` // 3. .edgerc file located at `AKAMAI_EDGERC` (defaults to `~/.edgerc`, sections can be specified using `AKAMAI_EDGERC_SECTION`) // // See also: https://developer.akamai.com/api/getting-started func NewDNSProvider() (*DNSProvider, error) { conf, err := edgegrid.New( edgegrid.WithEnv(true), edgegrid.WithFile(env.GetOrDefaultString(EnvEdgeRc, "~/.edgerc")), edgegrid.WithSection(env.GetOrDefaultString(EnvEdgeRcSection, "default")), ) if err != nil { return nil, fmt.Errorf("edgedns: %w", err) } conf.MaxBody = maxBody accountSwitchKey := env.GetOrDefaultString(EnvAccountSwitchKey, "") if accountSwitchKey != "" { conf.AccountKey = accountSwitchKey } config := NewDefaultConfig() config.Config = conf return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for EdgeDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("edgedns: the configuration of the DNS provider is nil") } err := config.Validate() if err != nil { return nil, fmt.Errorf("edgedns: %w", err) } return &DNSProvider{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 { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) sess, err := session.New(session.WithSigner(d.config)) if err != nil { return fmt.Errorf("edgedns: %w", err) } client := edgegriddns.Client(sess) zone, err := getZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("edgedns: %w", err) } record, err := client.GetRecord(ctx, edgegriddns.GetRecordRequest{ Zone: zone, Name: info.EffectiveFQDN, RecordType: "TXT", }) if err != nil && !isNotFound(err) { return fmt.Errorf("edgedns: %w", err) } if err == nil && record == nil { return errors.New("edgedns: unknown error") } if record != nil { log.Infof("TXT record already exists. Updating target") if containsValue(record.Target, info.Value) { // have a record and have entry already return nil } record.Target = append(record.Target, `"`+info.Value+`"`) record.TTL = d.config.TTL err = client.UpdateRecord(ctx, edgegriddns.UpdateRecordRequest{ Record: &edgegriddns.RecordBody{ Name: record.Name, RecordType: record.RecordType, TTL: record.TTL, Active: record.Active, Target: record.Target, }, Zone: zone, }) if err != nil { return fmt.Errorf("edgedns: %w", err) } return nil } err = client.CreateRecord(ctx, edgegriddns.CreateRecordRequest{ Record: &edgegriddns.RecordBody{ Name: info.EffectiveFQDN, RecordType: "TXT", TTL: d.config.TTL, Target: []string{`"` + info.Value + `"`}, }, Zone: zone, RecLock: nil, }) if err != nil { return fmt.Errorf("edgedns: %w", err) } return nil } // CleanUp removes the record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) sess, err := session.New(session.WithSigner(d.config)) if err != nil { return fmt.Errorf("edgedns: %w", err) } client := edgegriddns.Client(sess) zone, err := getZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("edgedns: %w", err) } existingRec, err := client.GetRecord(ctx, edgegriddns.GetRecordRequest{ Zone: zone, Name: info.EffectiveFQDN, RecordType: "TXT", }) if err != nil { if isNotFound(err) { return nil } return fmt.Errorf("edgedns: %w", err) } if existingRec == nil { return errors.New("edgedns: unknown failure") } if len(existingRec.Target) == 0 { return errors.New("edgedns: TXT record is invalid") } if !containsValue(existingRec.Target, info.Value) { return nil } newRData := filterRData(existingRec, info) if len(newRData) > 0 { existingRec.Target = newRData err = client.UpdateRecord(ctx, edgegriddns.UpdateRecordRequest{ Record: &edgegriddns.RecordBody{ Name: existingRec.Name, RecordType: existingRec.RecordType, TTL: existingRec.TTL, Active: existingRec.Active, Target: existingRec.Target, }, Zone: zone, }) if err != nil { return fmt.Errorf("edgedns: %w", err) } return nil } err = client.DeleteRecord(ctx, edgegriddns.DeleteRecordRequest{ Zone: zone, Name: existingRec.Name, RecordType: "TXT", RecLock: nil, }) if err != nil { return fmt.Errorf("edgedns: %w", err) } return nil } func getZone(domain string) (string, error) { zone, err := dns01.FindZoneByFqdn(domain) if err != nil { return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) } return dns01.UnFqdn(zone), nil } func containsValue(values []string, value string) bool { return slices.ContainsFunc(values, func(val string) bool { return strings.Trim(val, `"`) == value }) } func isNotFound(err error) bool { if err == nil { return false } var e *edgegriddns.Error return errors.As(err, &e) && e.StatusCode == http.StatusNotFound } func filterRData(existingRec *edgegriddns.GetRecordResponse, info dns01.ChallengeInfo) []string { var newRData []string for _, val := range existingRec.Target { val = strings.Trim(val, `"`) if val == info.Value { continue } newRData = append(newRData, val) } return newRData } ================================================ FILE: providers/dns/edgedns/edgedns.toml ================================================ Name = "Akamai EdgeDNS" Description = ''' Akamai edgedns supersedes FastDNS; implementing a DNS provider for solving the DNS-01 challenge using Akamai EdgeDNS ''' URL = "https://www.akamai.com/us/en/products/security/edge-dns.jsp" Code = "edgedns" Aliases = ["fastdns"] # "fastdns" is for compatibility with v3, must be dropped in v5 Since = "v3.9.0" Example = ''' AKAMAI_CLIENT_SECRET=abcdefghijklmnopqrstuvwxyz1234567890ABCDEFG= \ AKAMAI_CLIENT_TOKEN=akab-mnbvcxzlkjhgfdsapoiuytrewq1234567 \ AKAMAI_HOST=akab-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.luna.akamaiapis.net \ AKAMAI_ACCESS_TOKEN=akab-1234567890qwerty-asdfghjklzxcvtnu \ lego --dns edgedns -d '*.example.com' -d example.com run ''' Additional = ''' Akamai's credentials are automatically detected in the following locations and prioritized in the following order: 1. Section-specific environment variables (where `{SECTION}` is specified using `AKAMAI_EDGERC_SECTION`): - `AKAMAI_{SECTION}_HOST` - `AKAMAI_{SECTION}_ACCESS_TOKEN` - `AKAMAI_{SECTION}_CLIENT_TOKEN` - `AKAMAI_{SECTION}_CLIENT_SECRET` 2. If `AKAMAI_EDGERC_SECTION` is not defined or is set to `default`, environment variables: - `AKAMAI_HOST` - `AKAMAI_ACCESS_TOKEN` - `AKAMAI_CLIENT_TOKEN` - `AKAMAI_CLIENT_SECRET` 3. `.edgerc` file located at `AKAMAI_EDGERC` - defaults to `~/.edgerc`, sections can be specified using `AKAMAI_EDGERC_SECTION` 4. Default environment variables: - `AKAMAI_HOST` - `AKAMAI_ACCESS_TOKEN` - `AKAMAI_CLIENT_TOKEN` - `AKAMAI_CLIENT_SECRET` See also: - [Setting up Akamai credentials](https://developer.akamai.com/api/getting-started) - [.edgerc Format](https://developer.akamai.com/legacy/introduction/Conf_Client.html#edgercformat) - [API Client Authentication](https://developer.akamai.com/legacy/introduction/Client_Auth.html) - [Config from Env](https://github.com/akamai/AkamaiOPEN-edgegrid-golang/blob/master/pkg/edgegrid/config.go#L118) - [Manage many accounts](https://techdocs.akamai.com/developer/docs/manage-many-accounts-with-one-api-client) ''' [Configuration] [Configuration.Credentials] AKAMAI_HOST = "API host, managed by the Akamai EdgeGrid client" AKAMAI_CLIENT_TOKEN = "Client token, managed by the Akamai EdgeGrid client" AKAMAI_CLIENT_SECRET = "Client secret, managed by the Akamai EdgeGrid client" AKAMAI_ACCESS_TOKEN = "Access token, managed by the Akamai EdgeGrid client" AKAMAI_EDGERC = "Path to the .edgerc file, managed by the Akamai EdgeGrid client" AKAMAI_EDGERC_SECTION = "Configuration section, managed by the Akamai EdgeGrid client" [Configuration.Additional] AKAMAI_ACCOUNT_SWITCH_KEY = "Target account ID when the DNS zone and credentials belong to different accounts" AKAMAI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)" AKAMAI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)" AKAMAI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://developer.akamai.com/api/cloud_security/edge_dns_zone_management/v2.html" GoClient = "https://github.com/akamai/AkamaiOPEN-edgegrid-golang" ================================================ FILE: providers/dns/edgedns/edgedns_integration_test.go ================================================ package edgedns import ( "context" "fmt" "testing" "time" edgegriddns "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/dns" "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/session" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) 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) // Present Twice to handle create / update err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveTTL(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) domain := envTest.GetDomain() err = provider.Present(domain, "foo", "bar") require.NoError(t, err) defer func() { e := provider.CleanUp(domain, "foo", "bar") if e != nil { t.Log(e) } }() fqdn := "_acme-challenge." + domain + "." zone, err := getZone(fqdn) require.NoError(t, err) ctx := context.Background() sess, err := session.New(session.WithSigner(provider.config)) require.NoError(t, err) client := edgegriddns.Client(sess) resourceRecordSets, err := client.GetRecordList(ctx, edgegriddns.GetRecordListRequest{ Zone: zone, RecordType: "TXT", }) require.NoError(t, err) for i, rrset := range resourceRecordSets.RecordSets { if rrset.Name != fqdn { continue } t.Run(fmt.Sprintf("testing record set %d", i), func(t *testing.T) { assert.Equal(t, fqdn, rrset.Name) assert.Equal(t, "TXT", rrset.Type) assert.Equal(t, dns01.DefaultTTL, rrset.TTL) }) } } ================================================ FILE: providers/dns/edgedns/edgedns_test.go ================================================ package edgedns import ( "testing" "time" "github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/edgegrid" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const ( envDomain = envNamespace + "TEST_DOMAIN" envTestHost = envNamespace + "TEST_HOST" envTestClientToken = envNamespace + "TEST_CLIENT_TOKEN" envTestClientSecret = envNamespace + "TEST_CLIENT_SECRET" envTestAccessToken = envNamespace + "TEST_ACCESS_TOKEN" ) var envTest = tester.NewEnvTest( EnvTTL, EnvPollingInterval, EnvPropagationTimeout, EnvHost, EnvClientToken, EnvClientSecret, EnvAccessToken, EnvAccountSwitchKey, EnvEdgeRc, EnvEdgeRcSection, envTestHost, envTestClientToken, envTestClientSecret, envTestAccessToken). WithDomain(envDomain). WithLiveTestRequirements(EnvHost, EnvClientToken, EnvClientSecret, EnvAccessToken, envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expectedConfig *edgegrid.Config expectedErr string }{ { desc: "success", envVars: map[string]string{ EnvHost: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", EnvClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", EnvClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", EnvAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", }, expectedConfig: newEdgeConfig(func(config *edgegrid.Config) { config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net" config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" config.MaxBody = maxBody }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null")), }, { desc: "with account switch key", envVars: map[string]string{ EnvHost: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", EnvClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", EnvClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", EnvAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", EnvAccountSwitchKey: "F-AC-1234", }, expectedConfig: newEdgeConfig(func(config *edgegrid.Config) { config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net" config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" config.MaxBody = maxBody config.AccountKey = "F-AC-1234" }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null")), }, { desc: "with section", envVars: map[string]string{ EnvEdgeRcSection: "test", envTestHost: "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", envTestClientToken: "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", envTestClientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", envTestAccessToken: "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx", }, expectedConfig: newEdgeConfig(func(config *edgegrid.Config) { config.Host = "akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net" config.ClientToken = "akab-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" config.ClientSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" config.AccessToken = "akac-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx" config.MaxBody = maxBody }, edgegrid.WithEnv(true), edgegrid.WithFile("/dev/null"), edgegrid.WithSection("test")), }, { desc: "missing credentials", expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, { desc: "missing host", envVars: map[string]string{ EnvHost: "", EnvClientToken: "B", EnvClientSecret: "C", EnvAccessToken: "D", }, expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, { desc: "missing client token", envVars: map[string]string{ EnvHost: "A", EnvClientToken: "", EnvClientSecret: "C", EnvAccessToken: "D", }, expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, { desc: "missing client secret", envVars: map[string]string{ EnvHost: "A", EnvClientToken: "B", EnvClientSecret: "", EnvAccessToken: "D", }, expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, { desc: "missing access token", envVars: map[string]string{ EnvHost: "A", EnvClientToken: "B", EnvClientSecret: "C", EnvAccessToken: "", }, expectedErr: `edgedns: unable to load config from environment or .edgerc file`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() if test.envVars == nil { test.envVars = map[string]string{} } test.envVars[EnvEdgeRc] = "/dev/null" envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expectedErr != "" { require.ErrorContains(t, err, test.expectedErr) return } require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) if test.expectedConfig != nil { require.Equal(t, test.expectedConfig, p.config.Config) } }) } } func TestNewDefaultConfig(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected *Config }{ { desc: "default configuration", expected: &Config{ TTL: dns01.DefaultTTL, PropagationTimeout: 3 * time.Minute, PollingInterval: 15 * time.Second, Config: &edgegrid.Config{ MaxBody: maxBody, }, }, }, { desc: "custom values", envVars: map[string]string{ EnvTTL: "99", EnvPropagationTimeout: "60", EnvPollingInterval: "60", }, expected: &Config{ TTL: 99, PropagationTimeout: 60 * time.Second, PollingInterval: 60 * time.Second, Config: &edgegrid.Config{ MaxBody: maxBody, }, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) config := NewDefaultConfig() require.Equal(t, test.expected, config) }) } } func Test_findZone(t *testing.T) { testCases := []struct { desc string domain string expected string }{ { desc: "Extract root record name", domain: "example.com.", expected: "example.com", }, { desc: "Extract sub record name", domain: "foo.example.com.", expected: "example.com", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() zone, err := getZone(test.domain) require.NoError(t, err) require.Equal(t, test.expected, zone) }) } } func newEdgeConfig(opts ...edgegrid.Option) *edgegrid.Config { config, _ := edgegrid.New(opts...) return config } ================================================ FILE: providers/dns/edgeone/edgeone.go ================================================ // Package edgeone implements a DNS provider for solving the DNS-01 challenge using Tencent EdgeOne. package edgeone import ( "context" "errors" "fmt" "math" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" teo "github.com/go-acme/tencentedgdeone/v20220901" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" "golang.org/x/net/idna" ) // Environment variables names. const ( envNamespace = "EDGEONE_" EnvSecretID = envNamespace + "SECRET_ID" EnvSecretKey = envNamespace + "SECRET_KEY" EnvRegion = envNamespace + "REGION" EnvSessionToken = envNamespace + "SESSION_TOKEN" EnvZonesMapping = envNamespace + "ZONES_MAPPING" 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 { SecretID string SecretKey string Region string SessionToken string ZonesMapping map[string]string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 20*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *teo.Client recordIDs map[string]*string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Tencent EdgeOne. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvSecretID, EnvSecretKey) if err != nil { return nil, fmt.Errorf("edgeone: %w", err) } config := NewDefaultConfig() config.SecretID = values[EnvSecretID] config.SecretKey = values[EnvSecretKey] config.Region = env.GetOrDefaultString(EnvRegion, "") config.SessionToken = env.GetOrDefaultString(EnvSessionToken, "") mapping := env.GetOrDefaultString(EnvZonesMapping, "") if mapping != "" { config.ZonesMapping, err = env.ParsePairs(mapping) if err != nil { return nil, fmt.Errorf("edgeone: zones mapping: %w", err) } } return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Tencent EdgeOne. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("edgeone: the configuration of the DNS provider is nil") } var credential *common.Credential switch { case config.SecretID != "" && config.SecretKey != "" && config.SessionToken != "": credential = common.NewTokenCredential(config.SecretID, config.SecretKey, config.SessionToken) case config.SecretID != "" && config.SecretKey != "": credential = common.NewCredential(config.SecretID, config.SecretKey) default: return nil, errors.New("edgeone: credentials missing") } cpf := profile.NewClientProfile() cpf.HttpProfile.Endpoint = "teo.intl.tencentcloudapi.com" cpf.HttpProfile.ReqTimeout = int(math.Round(config.HTTPTimeout.Seconds())) client, err := teo.NewClient(credential, config.Region, cpf) if err != nil { return nil, fmt.Errorf("edgeone: %w", err) } return &DNSProvider{ config: config, client: client, recordIDs: map[string]*string{}, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("edgeone: failed to get hosted zone: %w", err) } punnyCoded, err := idna.ToASCII(dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("edgeone: fail to convert punycode: %w", err) } request := teo.NewCreateDnsRecordRequest() request.Name = ptr.Pointer(punnyCoded) request.ZoneId = zoneID request.Type = ptr.Pointer("TXT") request.Content = ptr.Pointer(info.Value) request.TTL = ptr.Pointer(int64(d.config.TTL)) nr, err := teo.CreateDnsRecordWithContext(ctx, d.client, request) if err != nil { return fmt.Errorf("edgeone: API call failed: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = nr.Response.RecordId d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("edgeone: failed to get hosted zone: %w", err) } // get the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("edgeone: unknown record ID for '%s'", info.EffectiveFQDN) } request := teo.NewDeleteDnsRecordsRequest() request.ZoneId = zoneID request.RecordIds = []*string{recordID} _, err = teo.DeleteDnsRecordsWithContext(ctx, d.client, request) if err != nil { return fmt.Errorf("edgeone: delete record failed: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/edgeone/edgeone.toml ================================================ Name = "Tencent EdgeOne" Description = '''''' URL = "https://edgeone.ai" Code = "edgeone" Since = "v4.26.0" Example = ''' EDGEONE_SECRET_ID=abcdefghijklmnopqrstuvwx \ EDGEONE_SECRET_KEY=your-secret-key \ lego --dns edgeone -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] EDGEONE_SECRET_ID = "Access key ID" EDGEONE_SECRET_KEY = "Access Key secret" [Configuration.Additional] EDGEONE_SESSION_TOKEN = "Access Key token" EDGEONE_REGION = "Region" EDGEONE_ZONES_MAPPING = "Mapping between DNS zones and site IDs. (ex: 'example.org:id1,example.com:id2')" EDGEONE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" EDGEONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)" EDGEONE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" EDGEONE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://edgeone.ai/document/50454#dns-record-apis" GoClient = "https://github.com/tencentcloud/tencentcloud-sdk-go" ================================================ FILE: providers/dns/edgeone/edgeone_test.go ================================================ package edgeone import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvSecretID, EnvSecretKey, EnvZonesMapping, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvSecretID: "123", EnvSecretKey: "456", }, }, { desc: "success with zones mapping", envVars: map[string]string{ EnvSecretID: "123", EnvSecretKey: "456", EnvZonesMapping: "example.org:id1,example.com:id2", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvSecretID: "", EnvSecretKey: "", }, expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_ID,EDGEONE_SECRET_KEY", }, { desc: "missing access id", envVars: map[string]string{ EnvSecretID: "", EnvSecretKey: "456", }, expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_ID", }, { desc: "missing secret key", envVars: map[string]string{ EnvSecretID: "123", EnvSecretKey: "", }, expected: "edgeone: some credentials information are missing: EDGEONE_SECRET_KEY", }, { desc: "invalid mapping", envVars: map[string]string{ EnvSecretID: "123", EnvSecretKey: "456", EnvZonesMapping: "example.org:id1,example.com", }, expected: "edgeone: zones mapping: incorrect pair: example.com", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string secretID string secretKey string expected string }{ { desc: "success", secretID: "123", secretKey: "456", }, { desc: "missing credentials", expected: "edgeone: credentials missing", }, { desc: "missing secret id", secretKey: "456", expected: "edgeone: credentials missing", }, { desc: "missing secret key", secretID: "123", expected: "edgeone: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.SecretID = test.secretID config.SecretKey = test.secretKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/edgeone/wrapper.go ================================================ package edgeone import ( "context" "fmt" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" teo "github.com/go-acme/tencentedgdeone/v20220901" ) func (d *DNSProvider) getHostedZoneID(ctx context.Context, domain string) (*string, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) } if d.config.ZonesMapping != nil { zoneID, ok := d.config.ZonesMapping[authZone] if ok { return ptr.Pointer(zoneID), nil } } request := teo.NewDescribeZonesRequest() var zones []*teo.Zone for { response, err := teo.DescribeZonesWithContext(ctx, d.client, request) if err != nil { return nil, fmt.Errorf("API call failed: %w", err) } zones = append(zones, response.Response.Zones...) if int64(len(zones)) >= ptr.Deref(response.Response.TotalCount) { break } request.Offset = ptr.Pointer(int64(len(zones))) } var hostedZone *teo.Zone for _, zone := range zones { unfqdn := dns01.UnFqdn(authZone) if ptr.Deref(zone.ZoneName) == unfqdn { hostedZone = zone } } if hostedZone == nil { return nil, fmt.Errorf("zone %s not found for domain %s", authZone, domain) } return hostedZone.ZoneId, nil } ================================================ FILE: providers/dns/efficientip/efficientip.go ================================================ // Package efficientip implements a DNS provider for solving the DNS-01 challenge using Efficient IP. package efficientip import ( "context" "crypto/tls" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/efficientip/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "EFFICIENTIP_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvHostname = envNamespace + "HOSTNAME" EnvDNSName = envNamespace + "DNS_NAME" EnvViewName = envNamespace + "VIEW_NAME" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string Hostname string DNSName string ViewName string InsecureSkipVerify bool PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a new DNS provider // using environment variable EFFICIENTIP_API_KEY for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword, EnvHostname, EnvDNSName) if err != nil { return nil, fmt.Errorf("efficientip: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.Hostname = values[EnvHostname] config.DNSName = values[EnvDNSName] config.ViewName = env.GetOrDefaultString(EnvViewName, "") config.InsecureSkipVerify = env.GetOrDefaultBool(EnvInsecureSkipVerify, false) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Efficient IP. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("efficientip: the configuration of the DNS provider is nil") } if config.Username == "" { return nil, errors.New("efficientip: missing username") } if config.Password == "" { return nil, errors.New("efficientip: missing password") } if config.Hostname == "" { return nil, errors.New("efficientip: missing hostname") } if config.DNSName == "" { return nil, errors.New("efficientip: missing dnsname") } client := internal.NewClient(config.Hostname, config.Username, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } if config.InsecureSkipVerify { client.HTTPClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() r := internal.ResourceRecord{ RRName: dns01.UnFqdn(info.EffectiveFQDN), RRType: "TXT", Value1: info.Value, DNSName: d.config.DNSName, DNSViewName: d.config.ViewName, } _, err := d.client.AddRecord(ctx, r) if err != nil { return fmt.Errorf("efficientip: add record: %w", err) } return nil } func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() params := internal.DeleteInputParameters{ RRName: dns01.UnFqdn(info.EffectiveFQDN), RRType: "TXT", RRValue1: info.Value, DNSName: d.config.DNSName, DNSViewName: d.config.ViewName, } _, err := d.client.DeleteRecord(ctx, params) if err != nil { return fmt.Errorf("efficientip: delete record: %w", err) } return 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 } ================================================ FILE: providers/dns/efficientip/efficientip.toml ================================================ Name = "Efficient IP" Description = '''''' URL = "https://efficientip.com/" Code = "efficientip" Since = "v4.13.0" Example = ''' EFFICIENTIP_USERNAME="user" \ EFFICIENTIP_PASSWORD="secret" \ EFFICIENTIP_HOSTNAME="ipam.example.org" \ EFFICIENTIP_DNS_NAME="dns.smart" \ lego --dns efficientip -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] EFFICIENTIP_USERNAME = "Username" EFFICIENTIP_PASSWORD = "Password" EFFICIENTIP_HOSTNAME = "Hostname (ex: foo.example.com)" EFFICIENTIP_DNS_NAME = "DNS name (ex: dns.smart)" [Configuration.Additional] EFFICIENTIP_INSECURE_SKIP_VERIFY = "Whether or not to verify EfficientIP API certificate" EFFICIENTIP_VIEW_NAME = "View name (ex: external)" EFFICIENTIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" EFFICIENTIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" EFFICIENTIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" ================================================ FILE: providers/dns/efficientip/efficientip_test.go ================================================ package efficientip import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvPassword, EnvHostname, EnvDNSName, EnvViewName, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", EnvHostname: "example.com", EnvDNSName: "dns.smart", }, }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "secret", EnvHostname: "example.com", EnvDNSName: "dns.smart", }, expected: "efficientip: some credentials information are missing: EFFICIENTIP_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "", EnvHostname: "example.com", EnvDNSName: "dns.smart", }, expected: "efficientip: some credentials information are missing: EFFICIENTIP_PASSWORD", }, { desc: "missing hostname", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", EnvHostname: "", EnvDNSName: "dns.smart", }, expected: "efficientip: some credentials information are missing: EFFICIENTIP_HOSTNAME", }, { desc: "missing DNS name", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", EnvHostname: "example.com", EnvDNSName: "", }, expected: "efficientip: some credentials information are missing: EFFICIENTIP_DNS_NAME", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "efficientip: some credentials information are missing: EFFICIENTIP_USERNAME,EFFICIENTIP_PASSWORD,EFFICIENTIP_HOSTNAME,EFFICIENTIP_DNS_NAME", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string hostname string dnsName string expected string }{ { desc: "success", username: "user", password: "secret", hostname: "example.com", dnsName: "dns.smart", }, { desc: "missing username", password: "secret", hostname: "example.com", dnsName: "dns.smart", expected: "efficientip: missing username", }, { desc: "missing password", username: "user", hostname: "example.com", dnsName: "dns.smart", expected: "efficientip: missing password", }, { desc: "missing hostname", username: "user", password: "secret", dnsName: "dns.smart", expected: "efficientip: missing hostname", }, { desc: "missing dnsName", username: "user", password: "secret", hostname: "example.com", expected: "efficientip: missing dnsname", }, { desc: "missing all", expected: "efficientip: missing username", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password config.Hostname = test.hostname config.DNSName = test.dnsName p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/efficientip/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) type Client struct { baseURL *url.URL HTTPClient *http.Client username string password string } func NewClient(hostname, username, password string) *Client { baseURL, _ := url.Parse(fmt.Sprintf("https://%s/rest/", hostname)) return &Client{ HTTPClient: &http.Client{Timeout: 5 * time.Second}, baseURL: baseURL, username: username, password: password, } } func (c *Client) ListRecords(ctx context.Context) ([]ResourceRecord, error) { endpoint := c.baseURL.JoinPath("dns_rr_list") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []ResourceRecord err = c.do(req, &result) if err != nil { return nil, err } return result, nil } func (c *Client) GetRecord(ctx context.Context, id string) (*ResourceRecord, error) { endpoint := c.baseURL.JoinPath("dns_rr_info") query := endpoint.Query() query.Set("rr_id", id) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []ResourceRecord err = c.do(req, &result) if err != nil { return nil, err } if len(result) == 0 { return nil, nil } return &result[0], nil } func (c *Client) AddRecord(ctx context.Context, record ResourceRecord) (*BaseOutput, error) { endpoint := c.baseURL.JoinPath("dns_rr_add") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } var result []BaseOutput err = c.do(req, &result) if err != nil { return nil, err } if len(result) == 0 { return nil, nil } return &result[0], nil } func (c *Client) DeleteRecord(ctx context.Context, params DeleteInputParameters) (*BaseOutput, error) { endpoint := c.baseURL.JoinPath("dns_rr_delete") // (rr_id || (rr_name && (dns_id || dns_name || hostaddr))) v, err := querystring.Values(params) if err != nil { return nil, fmt.Errorf("query parameters: %w", err) } endpoint.RawQuery = v.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return nil, err } var result []BaseOutput err = c.do(req, &result) if err != nil { return nil, err } if len(result) == 0 { return nil, nil } return &result[0], nil } func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth(c.username, c.password) req.Header.Set("cache-control", "no-cache") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() switch req.Method { case http.MethodPost: if resp.StatusCode != http.StatusCreated { return parseError(req, resp) } default: if resp.StatusCode == http.StatusNoContent { return nil } if resp.StatusCode != http.StatusOK { return parseError(req, resp) } } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIError err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, response) } ================================================ FILE: providers/dns/efficientip/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { srvURL, _ := url.Parse(server.URL) client := NewClient(srvURL.Host, "user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("user", "secret"), ) } func TestListRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns_rr_list", servermock.ResponseFromFixture("dns_rr_list.json")). Build(t) records, err := client.ListRecords(t.Context()) require.NoError(t, err) expected := []ResourceRecord{ { ErrorCode: "0", DelayedCreateTime: "0", DelayedDeleteTime: "0", DelayedTime: "0", DNSCloud: "0", DNSID: "3", DNSName: "dns.smart", DNSType: "vdns", DNSViewID: "0", DNSViewName: "#", DNSZoneID: "9", DNSZoneIsReverse: "0", DNSZoneIsRpz: "0", DNSZoneName: "lego.example.com", DNSZoneNameUTF: "lego.example.com", DNSZoneSiteName: "#", DNSZoneSortZone: "lego.example.com", DNSZoneType: "master", RRAllValue: "test1", RRAuthGsstsig: "0", RRFullName: "test.lego.example.com", RRFullNameUTF: "test.lego.example.com", RRGlue: "test", RRGlueID: "21", RRID: "239", RRNameID: "26", RRType: "TXT", RRTypeID: "6", RRValueID: "274", TTL: "3600", Value1: "test1", VDNSParentID: "0", VDNSParentName: "#", }, { ErrorCode: "0", DelayedCreateTime: "0", DelayedDeleteTime: "0", DelayedTime: "0", DNSCloud: "0", DNSID: "3", DNSName: "dns.smart", DNSType: "vdns", DNSViewID: "0", DNSViewName: "#", DNSZoneID: "9", DNSZoneIsReverse: "0", DNSZoneIsRpz: "0", DNSZoneName: "lego.example.com", DNSZoneNameUTF: "lego.example.com", DNSZoneSiteName: "#", DNSZoneSortZone: "lego.example.com", DNSZoneType: "master", RRAllValue: "test2", RRAuthGsstsig: "0", RRFullName: "test.lego.example.com", RRFullNameUTF: "test.lego.example.com", RRGlue: "test", RRGlueID: "21", RRID: "241", RRNameID: "26", RRType: "TXT", RRTypeID: "6", RRValueID: "275", TTL: "3600", Value1: "test2", VDNSParentID: "0", VDNSParentName: "#", }, { ErrorCode: "0", DelayedCreateTime: "0", DelayedDeleteTime: "0", DelayedTime: "0", DNSCloud: "0", DNSID: "3", DNSName: "dns.smart", DNSType: "vdns", DNSViewID: "0", DNSViewName: "#", DNSZoneID: "9", DNSZoneIsReverse: "0", DNSZoneIsRpz: "0", DNSZoneName: "lego.example.com", DNSZoneNameUTF: "lego.example.com", DNSZoneSiteName: "#", DNSZoneSortZone: "lego.example.com", DNSZoneType: "master", RRAllValue: "test1", RRAuthGsstsig: "0", RRFullName: "lego.example.com", RRFullNameUTF: "lego.example.com", RRGlue: ".", RRGlueID: "3", RRID: "245", RRNameID: "21", RRType: "TXT", RRTypeID: "6", RRValueID: "274", TTL: "3600", Value1: "test1", VDNSParentID: "0", VDNSParentName: "#", }, { ErrorCode: "0", DelayedCreateTime: "0", DelayedDeleteTime: "0", DelayedTime: "0", DNSCloud: "0", DNSID: "3", DNSName: "dns.smart", DNSType: "vdns", DNSViewID: "0", DNSViewName: "#", DNSZoneID: "9", DNSZoneIsReverse: "0", DNSZoneIsRpz: "0", DNSZoneName: "lego.example.com", DNSZoneNameUTF: "lego.example.com", DNSZoneSiteName: "#", DNSZoneSortZone: "lego.example.com", DNSZoneType: "master", RRAllValue: "test2", RRAuthGsstsig: "0", RRFullName: "lego.example.com", RRFullNameUTF: "lego.example.com", RRGlue: ".", RRGlueID: "3", RRID: "247", RRNameID: "21", RRType: "TXT", RRTypeID: "6", RRValueID: "275", TTL: "3600", Value1: "test2", VDNSParentID: "0", VDNSParentName: "#", }, { ErrorCode: "0", DelayedCreateTime: "0", DelayedDeleteTime: "0", DelayedTime: "0", DNSCloud: "0", DNSID: "3", DNSName: "dns.smart", DNSType: "vdns", DNSViewID: "0", DNSViewName: "#", DNSZoneID: "9", DNSZoneIsReverse: "0", DNSZoneIsRpz: "0", DNSZoneName: "lego.example.com", DNSZoneNameUTF: "lego.example.com", DNSZoneSiteName: "#", DNSZoneSortZone: "lego.example.com", DNSZoneType: "master", RRAllValue: "dns.smart, root@lego.example.com, 2023062719, 1200, 600, 1209600, 3600", RRAuthGsstsig: "0", RRFullName: "lego.example.com", RRFullNameUTF: "lego.example.com", RRGlue: ".", RRGlueID: "3", RRID: "201", RRNameID: "21", RRType: "SOA", RRTypeID: "2", RRValueID: "282", TTL: "3600", Value1: "dns.smart", Value2: "root@lego.example.com", Value3: "2023062719", Value4: "1200", Value5: "600", Value6: "1209600", Value7: "3600", VDNSParentID: "0", VDNSParentName: "#", }, { ErrorCode: "0", DelayedCreateTime: "0", DelayedDeleteTime: "0", DelayedTime: "0", DNSCloud: "0", DNSID: "3", DNSName: "dns.smart", DNSType: "vdns", DNSViewID: "0", DNSViewName: "#", DNSZoneID: "9", DNSZoneIsReverse: "0", DNSZoneIsRpz: "0", DNSZoneName: "lego.example.com", DNSZoneNameUTF: "lego.example.com", DNSZoneSiteName: "#", DNSZoneSortZone: "lego.example.com", DNSZoneType: "master", RRAllValue: "dns.smart", RRAuthGsstsig: "0", RRFullName: "lego.example.com", RRFullNameUTF: "lego.example.com", RRGlue: ".", RRGlueID: "3", RRID: "200", RRNameID: "21", RRType: "NS", RRTypeID: "1", RRValueID: "10", TTL: "3600", Value1: "dns.smart", VDNSParentID: "0", VDNSParentName: "#", }, { ErrorCode: "0", DelayedCreateTime: "0", DelayedDeleteTime: "0", DelayedTime: "0", DNSCloud: "0", DNSID: "3", DNSName: "dns.smart", DNSType: "vdns", DNSViewID: "0", DNSViewName: "#", DNSZoneID: "9", DNSZoneIsReverse: "0", DNSZoneIsRpz: "0", DNSZoneName: "lego.example.com", DNSZoneNameUTF: "lego.example.com", DNSZoneSiteName: "#", DNSZoneSortZone: "lego.example.com", DNSZoneType: "master", RRAllValue: "127.0.0.1", RRAuthGsstsig: "0", RRFullName: "loopback.lego.example.com", RRFullNameUTF: "loopback.lego.example.com", RRGlue: "loopback", RRGlueID: "17", RRID: "208", RRNameID: "22", RRType: "A", RRTypeID: "3", RRValueID: "237", RRValueIP4Addr: "7f000001", RRValueIPAddr: "7f000001", TTL: "3600", Value1: "127.0.0.1", VDNSParentID: "0", VDNSParentName: "#", }, } assert.Equal(t, expected, records) } func TestGetRecord(t *testing.T) { client := mockBuilder(). Route("GET /dns_rr_info", servermock.ResponseFromFixture("dns_rr_info.json"), servermock.CheckQueryParameter().Strict(). With("rr_id", "239")). Build(t) record, err := client.GetRecord(t.Context(), "239") require.NoError(t, err) expected := &ResourceRecord{ ErrorCode: "0", DelayedCreateTime: "0", DelayedDeleteTime: "0", DelayedTime: "0", DNSCloud: "0", DNSID: "3", DNSName: "dns.smart", DNSType: "vdns", DNSViewID: "0", DNSViewName: "#", DNSZoneID: "9", DNSZoneIsReverse: "0", DNSZoneIsRpz: "0", DNSZoneName: "lego.example.com", DNSZoneNameUTF: "lego.example.com", DNSZoneSiteName: "#", DNSZoneSortZone: "lego.example.com", DNSZoneType: "master", RRAllValue: "test1", RRAuthGsstsig: "0", RRFullName: "test.lego.example.com", RRFullNameUTF: "test.lego.example.com", RRGlue: "test", RRGlueID: "21", RRID: "239", RRNameID: "26", RRType: "TXT", RRTypeID: "6", RRValueID: "274", TTL: "3600", Value1: "test1", VDNSParentID: "0", VDNSParentName: "#", } assert.Equal(t, expected, record) } func TestAddRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns_rr_add", servermock.ResponseFromFixture("dns_rr_add.json").WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"dns_name":"dns.smart","dnsview_name":"external","rr_name":"test.example.com","rr_type":"TXT","value1":"test"}`)). Build(t) r := ResourceRecord{ RRName: "test.example.com", RRType: "TXT", Value1: "test", DNSName: "dns.smart", DNSViewName: "external", } resp, err := client.AddRecord(t.Context(), r) require.NoError(t, err) expected := &BaseOutput{RetOID: "239"} assert.Equal(t, expected, resp) } func TestDeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns_rr_delete", servermock.ResponseFromFixture("dns_rr_delete.json"), servermock.CheckQueryParameter().Strict(). With("rr_id", "251")). Build(t) resp, err := client.DeleteRecord(t.Context(), DeleteInputParameters{RRID: "251"}) require.NoError(t, err) expected := &BaseOutput{RetOID: "251"} assert.Equal(t, expected, resp) } func TestDeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /dns_rr_delete", servermock.ResponseFromFixture("dns_rr_delete-error.json").WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.DeleteRecord(t.Context(), DeleteInputParameters{RRID: "251"}) require.ErrorAs(t, err, &APIError{}) } ================================================ FILE: providers/dns/efficientip/internal/fixtures/dns_rr_add.json ================================================ [ { "ret_oid": "239" } ] ================================================ FILE: providers/dns/efficientip/internal/fixtures/dns_rr_delete-error.json ================================================ { "errno": "20117", "errmsg": "This RR does not exist", "severity": "error", "category": "dns_rr_delete" } ================================================ FILE: providers/dns/efficientip/internal/fixtures/dns_rr_delete.json ================================================ [ { "ret_oid": "251" } ] ================================================ FILE: providers/dns/efficientip/internal/fixtures/dns_rr_info.json ================================================ [ { "errno": "0", "rr_all_value": "test1", "dnszone_sort_zone": "lego.example.com", "dnszone_is_rpz": "0", "dnszone_type": "master", "rr_full_name": "test.lego.example.com", "rr_full_name_utf": "test.lego.example.com", "rr_name_ip_addr": "", "rr_name_ip4_addr": "", "rr_value_ip_addr": "", "rr_value_ip4_addr": "", "rr_glue": "test", "rr_type": "TXT", "ttl": "3600", "delayed_time": "0", "rr_class_name": "", "value1": "test1", "value2": "", "value3": "", "value4": "", "value5": "", "value6": "", "value7": "", "dnszone_id": "9", "rr_id": "239", "dns_id": "3", "dnszone_name_utf": "lego.example.com", "dnszone_name": "lego.example.com", "dns_name": "dns.smart", "dns_type": "vdns", "dns_cloud": "0", "vdns_parent_id": "0", "dnsview_name": "#", "dnsview_class_name": "", "dnsview_id": "0", "dnszone_site_name": "#", "dnszone_is_reverse": "0", "dnszone_masters": "", "vdns_parent_name": "#", "dnszone_forwarders": "", "dns_class_name": "", "dnszone_class_name": "", "dns_version": "", "dns_comment": "", "delayed_create_time": "0", "delayed_delete_time": "0", "multistatus": "", "rr_auth_gsstsig": "0", "rr_last_update_time": "", "rr_last_update_days": "", "rr_name_id": "26", "rr_value_id": "274", "rr_type_id": "6", "rr_glue_id": "21", "dnsview_class_parameters": "", "dnsview_class_parameters_properties": "", "dnsview_class_parameters_inheritance_source": "", "rr_class_parameters": "", "rr_class_parameters_properties": "", "rr_class_parameters_inheritance_source": "" } ] ================================================ FILE: providers/dns/efficientip/internal/fixtures/dns_rr_list.json ================================================ [ { "errno": "0", "rr_all_value": "test1", "dnszone_sort_zone": "lego.example.com", "dnszone_is_rpz": "0", "dnszone_type": "master", "rr_full_name": "test.lego.example.com", "rr_full_name_utf": "test.lego.example.com", "rr_name_ip_addr": "", "rr_name_ip4_addr": "", "rr_value_ip_addr": "", "rr_value_ip4_addr": "", "rr_glue": "test", "rr_type": "TXT", "ttl": "3600", "delayed_time": "0", "rr_class_name": "", "value1": "test1", "value2": "", "value3": "", "value4": "", "value5": "", "value6": "", "value7": "", "dnszone_id": "9", "rr_id": "239", "dns_id": "3", "dnszone_name_utf": "lego.example.com", "dnszone_name": "lego.example.com", "dns_name": "dns.smart", "dns_type": "vdns", "dns_cloud": "0", "vdns_parent_id": "0", "dnsview_name": "#", "dnsview_class_name": "", "dnsview_id": "0", "dnszone_site_name": "#", "dnszone_is_reverse": "0", "dnszone_masters": "", "vdns_parent_name": "#", "dnszone_forwarders": "", "dns_class_name": "", "dnszone_class_name": "", "dns_version": "", "dns_comment": "", "delayed_create_time": "0", "delayed_delete_time": "0", "multistatus": "", "rr_auth_gsstsig": "0", "rr_last_update_time": "", "rr_last_update_days": "", "rr_name_id": "26", "rr_value_id": "274", "rr_type_id": "6", "rr_glue_id": "21", "dnsview_class_parameters": "", "dnsview_class_parameters_properties": "", "dnsview_class_parameters_inheritance_source": "", "rr_class_parameters": "", "rr_class_parameters_properties": "", "rr_class_parameters_inheritance_source": "" }, { "errno": "0", "rr_all_value": "test2", "dnszone_sort_zone": "lego.example.com", "dnszone_is_rpz": "0", "dnszone_type": "master", "rr_full_name": "test.lego.example.com", "rr_full_name_utf": "test.lego.example.com", "rr_name_ip_addr": "", "rr_name_ip4_addr": "", "rr_value_ip_addr": "", "rr_value_ip4_addr": "", "rr_glue": "test", "rr_type": "TXT", "ttl": "3600", "delayed_time": "0", "rr_class_name": "", "value1": "test2", "value2": "", "value3": "", "value4": "", "value5": "", "value6": "", "value7": "", "dnszone_id": "9", "rr_id": "241", "dns_id": "3", "dnszone_name_utf": "lego.example.com", "dnszone_name": "lego.example.com", "dns_name": "dns.smart", "dns_type": "vdns", "dns_cloud": "0", "vdns_parent_id": "0", "dnsview_name": "#", "dnsview_class_name": "", "dnsview_id": "0", "dnszone_site_name": "#", "dnszone_is_reverse": "0", "dnszone_masters": "", "vdns_parent_name": "#", "dnszone_forwarders": "", "dns_class_name": "", "dnszone_class_name": "", "dns_version": "", "dns_comment": "", "delayed_create_time": "0", "delayed_delete_time": "0", "multistatus": "", "rr_auth_gsstsig": "0", "rr_last_update_time": "", "rr_last_update_days": "", "rr_name_id": "26", "rr_value_id": "275", "rr_type_id": "6", "rr_glue_id": "21", "dnsview_class_parameters": "", "dnsview_class_parameters_properties": "", "dnsview_class_parameters_inheritance_source": "", "rr_class_parameters": "", "rr_class_parameters_properties": "", "rr_class_parameters_inheritance_source": "" }, { "errno": "0", "rr_all_value": "test1", "dnszone_sort_zone": "lego.example.com", "dnszone_is_rpz": "0", "dnszone_type": "master", "rr_full_name": "lego.example.com", "rr_full_name_utf": "lego.example.com", "rr_name_ip_addr": "", "rr_name_ip4_addr": "", "rr_value_ip_addr": "", "rr_value_ip4_addr": "", "rr_glue": ".", "rr_type": "TXT", "ttl": "3600", "delayed_time": "0", "rr_class_name": "", "value1": "test1", "value2": "", "value3": "", "value4": "", "value5": "", "value6": "", "value7": "", "dnszone_id": "9", "rr_id": "245", "dns_id": "3", "dnszone_name_utf": "lego.example.com", "dnszone_name": "lego.example.com", "dns_name": "dns.smart", "dns_type": "vdns", "dns_cloud": "0", "vdns_parent_id": "0", "dnsview_name": "#", "dnsview_class_name": "", "dnsview_id": "0", "dnszone_site_name": "#", "dnszone_is_reverse": "0", "dnszone_masters": "", "vdns_parent_name": "#", "dnszone_forwarders": "", "dns_class_name": "", "dnszone_class_name": "", "dns_version": "", "dns_comment": "", "delayed_create_time": "0", "delayed_delete_time": "0", "multistatus": "", "rr_auth_gsstsig": "0", "rr_last_update_time": "", "rr_last_update_days": "", "rr_name_id": "21", "rr_value_id": "274", "rr_type_id": "6", "rr_glue_id": "3", "dnsview_class_parameters": "", "dnsview_class_parameters_properties": "", "dnsview_class_parameters_inheritance_source": "", "rr_class_parameters": "", "rr_class_parameters_properties": "", "rr_class_parameters_inheritance_source": "" }, { "errno": "0", "rr_all_value": "test2", "dnszone_sort_zone": "lego.example.com", "dnszone_is_rpz": "0", "dnszone_type": "master", "rr_full_name": "lego.example.com", "rr_full_name_utf": "lego.example.com", "rr_name_ip_addr": "", "rr_name_ip4_addr": "", "rr_value_ip_addr": "", "rr_value_ip4_addr": "", "rr_glue": ".", "rr_type": "TXT", "ttl": "3600", "delayed_time": "0", "rr_class_name": "", "value1": "test2", "value2": "", "value3": "", "value4": "", "value5": "", "value6": "", "value7": "", "dnszone_id": "9", "rr_id": "247", "dns_id": "3", "dnszone_name_utf": "lego.example.com", "dnszone_name": "lego.example.com", "dns_name": "dns.smart", "dns_type": "vdns", "dns_cloud": "0", "vdns_parent_id": "0", "dnsview_name": "#", "dnsview_class_name": "", "dnsview_id": "0", "dnszone_site_name": "#", "dnszone_is_reverse": "0", "dnszone_masters": "", "vdns_parent_name": "#", "dnszone_forwarders": "", "dns_class_name": "", "dnszone_class_name": "", "dns_version": "", "dns_comment": "", "delayed_create_time": "0", "delayed_delete_time": "0", "multistatus": "", "rr_auth_gsstsig": "0", "rr_last_update_time": "", "rr_last_update_days": "", "rr_name_id": "21", "rr_value_id": "275", "rr_type_id": "6", "rr_glue_id": "3", "dnsview_class_parameters": "", "dnsview_class_parameters_properties": "", "dnsview_class_parameters_inheritance_source": "", "rr_class_parameters": "", "rr_class_parameters_properties": "", "rr_class_parameters_inheritance_source": "" }, { "errno": "0", "rr_all_value": "dns.smart, root@lego.example.com, 2023062719, 1200, 600, 1209600, 3600", "dnszone_sort_zone": "lego.example.com", "dnszone_is_rpz": "0", "dnszone_type": "master", "rr_full_name": "lego.example.com", "rr_full_name_utf": "lego.example.com", "rr_name_ip_addr": "", "rr_name_ip4_addr": "", "rr_value_ip_addr": "", "rr_value_ip4_addr": "", "rr_glue": ".", "rr_type": "SOA", "ttl": "3600", "delayed_time": "0", "rr_class_name": "", "value1": "dns.smart", "value2": "root@lego.example.com", "value3": "2023062719", "value4": "1200", "value5": "600", "value6": "1209600", "value7": "3600", "dnszone_id": "9", "rr_id": "201", "dns_id": "3", "dnszone_name_utf": "lego.example.com", "dnszone_name": "lego.example.com", "dns_name": "dns.smart", "dns_type": "vdns", "dns_cloud": "0", "vdns_parent_id": "0", "dnsview_name": "#", "dnsview_class_name": "", "dnsview_id": "0", "dnszone_site_name": "#", "dnszone_is_reverse": "0", "dnszone_masters": "", "vdns_parent_name": "#", "dnszone_forwarders": "", "dns_class_name": "", "dnszone_class_name": "", "dns_version": "", "dns_comment": "", "delayed_create_time": "0", "delayed_delete_time": "0", "multistatus": "", "rr_auth_gsstsig": "0", "rr_last_update_time": "", "rr_last_update_days": "", "rr_name_id": "21", "rr_value_id": "282", "rr_type_id": "2", "rr_glue_id": "3", "dnsview_class_parameters": "", "dnsview_class_parameters_properties": "", "dnsview_class_parameters_inheritance_source": "", "rr_class_parameters": "", "rr_class_parameters_properties": "", "rr_class_parameters_inheritance_source": "" }, { "errno": "0", "rr_all_value": "dns.smart", "dnszone_sort_zone": "lego.example.com", "dnszone_is_rpz": "0", "dnszone_type": "master", "rr_full_name": "lego.example.com", "rr_full_name_utf": "lego.example.com", "rr_name_ip_addr": "", "rr_name_ip4_addr": "", "rr_value_ip_addr": "", "rr_value_ip4_addr": "", "rr_glue": ".", "rr_type": "NS", "ttl": "3600", "delayed_time": "0", "rr_class_name": "", "value1": "dns.smart", "value2": "", "value3": "", "value4": "", "value5": "", "value6": "", "value7": "", "dnszone_id": "9", "rr_id": "200", "dns_id": "3", "dnszone_name_utf": "lego.example.com", "dnszone_name": "lego.example.com", "dns_name": "dns.smart", "dns_type": "vdns", "dns_cloud": "0", "vdns_parent_id": "0", "dnsview_name": "#", "dnsview_class_name": "", "dnsview_id": "0", "dnszone_site_name": "#", "dnszone_is_reverse": "0", "dnszone_masters": "", "vdns_parent_name": "#", "dnszone_forwarders": "", "dns_class_name": "", "dnszone_class_name": "", "dns_version": "", "dns_comment": "", "delayed_create_time": "0", "delayed_delete_time": "0", "multistatus": "", "rr_auth_gsstsig": "0", "rr_last_update_time": "", "rr_last_update_days": "", "rr_name_id": "21", "rr_value_id": "10", "rr_type_id": "1", "rr_glue_id": "3", "dnsview_class_parameters": "", "dnsview_class_parameters_properties": "", "dnsview_class_parameters_inheritance_source": "", "rr_class_parameters": "", "rr_class_parameters_properties": "", "rr_class_parameters_inheritance_source": "" }, { "errno": "0", "rr_all_value": "127.0.0.1", "dnszone_sort_zone": "lego.example.com", "dnszone_is_rpz": "0", "dnszone_type": "master", "rr_full_name": "loopback.lego.example.com", "rr_full_name_utf": "loopback.lego.example.com", "rr_name_ip_addr": "", "rr_name_ip4_addr": "", "rr_value_ip_addr": "7f000001", "rr_value_ip4_addr": "7f000001", "rr_glue": "loopback", "rr_type": "A", "ttl": "3600", "delayed_time": "0", "rr_class_name": "", "value1": "127.0.0.1", "value2": "", "value3": "", "value4": "", "value5": "", "value6": "", "value7": "", "dnszone_id": "9", "rr_id": "208", "dns_id": "3", "dnszone_name_utf": "lego.example.com", "dnszone_name": "lego.example.com", "dns_name": "dns.smart", "dns_type": "vdns", "dns_cloud": "0", "vdns_parent_id": "0", "dnsview_name": "#", "dnsview_class_name": "", "dnsview_id": "0", "dnszone_site_name": "#", "dnszone_is_reverse": "0", "dnszone_masters": "", "vdns_parent_name": "#", "dnszone_forwarders": "", "dns_class_name": "", "dnszone_class_name": "", "dns_version": "", "dns_comment": "", "delayed_create_time": "0", "delayed_delete_time": "0", "multistatus": "", "rr_auth_gsstsig": "0", "rr_last_update_time": "", "rr_last_update_days": "", "rr_name_id": "22", "rr_value_id": "237", "rr_type_id": "3", "rr_glue_id": "17", "dnsview_class_parameters": "", "dnsview_class_parameters_properties": "", "dnsview_class_parameters_inheritance_source": "", "rr_class_parameters": "", "rr_class_parameters_properties": "", "rr_class_parameters_inheritance_source": "" } ] ================================================ FILE: providers/dns/efficientip/internal/types.go ================================================ package internal import "fmt" type ResourceRecord struct { ErrorCode string `json:"errno,omitempty"` DelayedCreateTime string `json:"delayed_create_time,omitempty"` DelayedDeleteTime string `json:"delayed_delete_time,omitempty"` DelayedTime string `json:"delayed_time,omitempty"` DNSClassName string `json:"dns_class_name,omitempty"` DNSCloud string `json:"dns_cloud,omitempty"` DNSComment string `json:"dns_comment,omitempty"` DNSID string `json:"dns_id,omitempty"` DNSName string `json:"dns_name,omitempty"` DNSType string `json:"dns_type,omitempty"` DNSVersion string `json:"dns_version,omitempty"` DNSViewClassName string `json:"dnsview_class_name,omitempty"` DNSViewClassParameters string `json:"dnsview_class_parameters,omitempty"` DNSViewClassParametersInheritanceSource string `json:"dnsview_class_parameters_inheritance_source,omitempty"` DNSViewClassParametersProperties string `json:"dnsview_class_parameters_properties,omitempty"` DNSViewID string `json:"dnsview_id,omitempty"` DNSViewName string `json:"dnsview_name,omitempty"` DNSZoneClassName string `json:"dnszone_class_name,omitempty"` DNSZoneForwarders string `json:"dnszone_forwarders,omitempty"` DNSZoneID string `json:"dnszone_id,omitempty"` DNSZoneIsReverse string `json:"dnszone_is_reverse,omitempty"` DNSZoneIsRpz string `json:"dnszone_is_rpz,omitempty"` DNSZoneMasters string `json:"dnszone_masters,omitempty"` DNSZoneName string `json:"dnszone_name,omitempty"` DNSZoneNameUTF string `json:"dnszone_name_utf,omitempty"` DNSZoneSiteName string `json:"dnszone_site_name,omitempty"` DNSZoneSortZone string `json:"dnszone_sort_zone,omitempty"` DNSZoneType string `json:"dnszone_type,omitempty"` MultiStatus string `json:"multistatus,omitempty"` RRAllValue string `json:"rr_all_value,omitempty"` RRAuthGsstsig string `json:"rr_auth_gsstsig,omitempty"` RRClassName string `json:"rr_class_name,omitempty"` RRClassParameters string `json:"rr_class_parameters,omitempty"` RRClassParametersInheritanceSource string `json:"rr_class_parameters_inheritance_source,omitempty"` RRClassParametersProperties string `json:"rr_class_parameters_properties,omitempty"` RRFullName string `json:"rr_full_name,omitempty"` RRFullNameUTF string `json:"rr_full_name_utf,omitempty"` RRGlue string `json:"rr_glue,omitempty"` RRGlueID string `json:"rr_glue_id,omitempty"` RRID string `json:"rr_id,omitempty"` RRLastUpdateDays string `json:"rr_last_update_days,omitempty"` RRLastUpdateTime string `json:"rr_last_update_time,omitempty"` RRName string `json:"rr_name,omitempty"` RRNameID string `json:"rr_name_id,omitempty"` RRNameIP4Addr string `json:"rr_name_ip4_addr,omitempty"` RRNameIPAddr string `json:"rr_name_ip_addr,omitempty"` RRType string `json:"rr_type,omitempty"` RRTypeID string `json:"rr_type_id,omitempty"` RRValueID string `json:"rr_value_id,omitempty"` RRValueIP4Addr string `json:"rr_value_ip4_addr,omitempty"` RRValueIPAddr string `json:"rr_value_ip_addr,omitempty"` TTL string `json:"ttl,omitempty"` Value1 string `json:"value1,omitempty"` Value2 string `json:"value2,omitempty"` Value3 string `json:"value3,omitempty"` Value4 string `json:"value4,omitempty"` Value5 string `json:"value5,omitempty"` Value6 string `json:"value6,omitempty"` Value7 string `json:"value7,omitempty"` VDNSParentID string `json:"vdns_parent_id,omitempty"` VDNSParentName string `json:"vdns_parent_name,omitempty"` } type DeleteInputParameters struct { RRID string `url:"rr_id,omitempty"` DNSName string `url:"dns_name,omitempty"` DNSViewName string `url:"dnsview_name,omitempty"` RRName string `url:"rr_name,omitempty"` RRType string `url:"rr_type,omitempty"` RRValue1 string `url:"rr_value1,omitempty"` } type BaseOutput struct { RetOID string `json:"ret_oid,omitempty"` } type APIError struct { ErrorCode string `json:"errno,omitempty"` ErrMsg string `json:"errmsg,omitempty"` Severity string `json:"severity,omitempty"` Category string `json:"category,omitempty"` Parameters string `json:"parameters,omitempty"` ParamFormat string `json:"param_format,omitempty"` ParamValue string `json:"param_value,omitempty"` } func (a APIError) Error() string { msg := fmt.Sprintf("%s: %s %s %s", a.Category, a.Severity, a.ErrorCode, a.ErrMsg) if a.Parameters != "" { msg += fmt.Sprintf(" parameters: %s", a.Parameters) } if a.ParamFormat != "" { msg += fmt.Sprintf(" param_format: %s", a.ParamFormat) } if a.ParamValue != "" { msg += fmt.Sprintf(" param_value: %s", a.ParamValue) } return msg } ================================================ FILE: providers/dns/epik/epik.go ================================================ // Package epik implements a DNS provider for solving the DNS-01 challenge using Epik. package epik import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/epik/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "EPIK_" EnvSignature = envNamespace + "SIGNATURE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Signature string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Epik. // Credentials must be passed in the environment variable: EPIK_SIGNATURE. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvSignature) if err != nil { return nil, fmt.Errorf("epik: %w", err) } config := NewDefaultConfig() config.Signature = values[EnvSignature] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Epik. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("epik: the configuration of the DNS provider is nil") } if config.Signature == "" { return nil, errors.New("epik: missing credentials") } client := internal.NewClient(config.Signature) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // find authZone authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("epik: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("epik: %w", err) } record := internal.RecordRequest{ Host: subDomain, Type: "TXT", Data: info.Value, TTL: d.config.TTL, } _, err = d.client.CreateHostRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("epik: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // find authZone authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("epik: could not find zone for domain %q: %w", domain, err) } dom := dns01.UnFqdn(authZone) ctx := context.Background() records, err := d.client.GetDNSRecords(ctx, dom) if err != nil { return fmt.Errorf("epik: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("epik: %w", err) } for _, record := range records { if strings.EqualFold(record.Type, "TXT") && record.Data == info.Value && record.Name == subDomain { _, err = d.client.RemoveHostRecord(ctx, dom, record.ID) if err != nil { return fmt.Errorf("epik: %w", err) } } } return nil } ================================================ FILE: providers/dns/epik/epik.toml ================================================ Name = "Epik" Description = '''''' URL = "https://www.epik.com/" Code = "epik" Since = "v4.5.0" Example = ''' EPIK_SIGNATURE=xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns epik -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] EPIK_SIGNATURE = "Epik API signature (https://registrar.epik.com/account/api-settings/)" [Configuration.Additional] EPIK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" EPIK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" EPIK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" EPIK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs-userapi.epik.com/v2/" ================================================ FILE: providers/dns/epik/epik_test.go ================================================ package epik import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvSignature).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvSignature: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "epik: some credentials information are missing: EPIK_SIGNATURE", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string signature string expected string }{ { desc: "success", signature: "A", }, { desc: "missing credentials", expected: "epik: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Signature = test.signature p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/epik/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://usersapiv2.epik.com/v2" // Client the Epik API client. type Client struct { signature string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(signature string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ signature: signature, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetDNSRecords gets DNS records for a domain. // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/getDnsRecord func (c *Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, error) { endpoint := c.createEndpoint(domain, url.Values{}) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var data GetDNSRecordResponse err = c.do(req, &data) if err != nil { return nil, err } return data.Data.Records, nil } // CreateHostRecord creates a record for a domain. // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/createHostRecord func (c *Client) CreateHostRecord(ctx context.Context, domain string, record RecordRequest) (*Data, error) { endpoint := c.createEndpoint(domain, url.Values{}) payload := CreateHostRecords{Payload: record} req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return nil, err } var data Data err = c.do(req, &data) if err != nil { return nil, err } return &data, nil } // RemoveHostRecord removes a record for a domain. // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/removeHostRecord func (c *Client) RemoveHostRecord(ctx context.Context, domain, recordID string) (*Data, error) { params := url.Values{} params.Set("ID", recordID) endpoint := c.createEndpoint(domain, params) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return nil, err } var data Data err = c.do(req, &data) if err != nil { return nil, err } return &data, nil } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) createEndpoint(domain string, params url.Values) *url.URL { endpoint := c.baseURL.JoinPath("domains", domain, "records") params.Set("SIGNATURE", c.signature) endpoint.RawQuery = params.Encode() return endpoint } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var apiErr APIError err := json.Unmarshal(raw, &apiErr) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &apiErr } ================================================ FILE: providers/dns/epik/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestClient_GetDNSRecords(t *testing.T) { client := mockBuilder(). Route("GET /domains/example.com/records", servermock.ResponseFromFixture("getDnsRecord.json"), servermock.CheckQueryParameter().Strict(). With("SIGNATURE", "secret")). Build(t) records, err := client.GetDNSRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ { ID: "abc123", Name: "www", Type: "CAA", Data: "1 issue letsencrypt.org", AUX: 0, TTL: 300, }, { ID: "abc123", Name: "www", Type: "A", Data: "192.64.147.249", AUX: 0, TTL: 300, }, { ID: "abc123", Name: "*", Type: "A", Data: "192.64.147.249", AUX: 0, TTL: 300, }, { ID: "abc123", Type: "CAA", Data: "0 issue trust-provider.com", AUX: 0, TTL: 300, }, { ID: "abc123", Type: "CAA", Data: "1 issue letsencrypt.org", AUX: 0, TTL: 300, }, { ID: "abc123", Type: "A", Data: "192.64.147.249", AUX: 0, TTL: 300, }, } assert.Equal(t, expected, records) } func TestClient_GetDNSRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /domains/example.com/records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized), servermock.CheckQueryParameter().Strict(). With("SIGNATURE", "secret")). Build(t) _, err := client.GetDNSRecords(t.Context(), "example.com") require.Error(t, err) } func TestClient_CreateHostRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/records", servermock.ResponseFromFixture("createHostRecord.json"), servermock.CheckQueryParameter().Strict(). With("SIGNATURE", "secret")). Build(t) record := RecordRequest{ Host: "www2", Type: "A", Data: "192.64.147.249", Aux: 0, TTL: 300, } data, err := client.CreateHostRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Data{ Code: 1000, Message: "Command completed successfully.", } assert.Equal(t, expected, data) } func TestClient_CreateHostRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized), servermock.CheckQueryParameter().Strict(). With("SIGNATURE", "secret")). Build(t) record := RecordRequest{ Host: "www2", Type: "A", Data: "192.64.147.249", Aux: 0, TTL: 300, } _, err := client.CreateHostRecord(t.Context(), "example.com", record) require.Error(t, err) } func TestClient_RemoveHostRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/records", servermock.ResponseFromFixture("removeHostRecord.json"), servermock.CheckQueryParameter().Strict(). With("ID", "abc123"). With("SIGNATURE", "secret")). Build(t) data, err := client.RemoveHostRecord(t.Context(), "example.com", "abc123") require.NoError(t, err) expected := &Data{ Code: 1000, Message: "Command completed successfully.", } assert.Equal(t, expected, data) } func TestClient_RemoveHostRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.RemoveHostRecord(t.Context(), "example.com", "abc123") require.Error(t, err) } ================================================ FILE: providers/dns/epik/internal/fixtures/createHostRecord.json ================================================ { "code": 1000, "message": "Command completed successfully.", "description": null } ================================================ FILE: providers/dns/epik/internal/fixtures/error.json ================================================ { "errors": [ { "code": 1, "message": "Unauthorized", "description": "Unauthorized: Signature was not provided or was invalid" } ] } ================================================ FILE: providers/dns/epik/internal/fixtures/getDnsRecord.json ================================================ { "data": { "name": "MYDOMAIN.ORG", "code": 1000, "records": [ { "id": "abc123", "name": "www", "type": "CAA", "data": "1 issue letsencrypt.org", "aux": 0, "ttl": 300 }, { "id": "abc123", "name": "www", "type": "A", "data": "192.64.147.249", "aux": 0, "ttl": 300 }, { "id": "abc123", "name": "*", "type": "A", "data": "192.64.147.249", "aux": 0, "ttl": 300 }, { "id": "abc123", "name": "", "type": "CAA", "data": "0 issue trust-provider.com", "aux": 0, "ttl": 300 }, { "id": "abc123", "name": "", "type": "CAA", "data": "1 issue letsencrypt.org", "aux": 0, "ttl": 300 }, { "id": "abc123", "name": "", "type": "A", "data": "192.64.147.249", "aux": 0, "ttl": 300 } ] } } ================================================ FILE: providers/dns/epik/internal/fixtures/removeHostRecord.json ================================================ { "code": 1000, "message": "Command completed successfully.", "description": null } ================================================ FILE: providers/dns/epik/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type RecordRequest struct { Host string `json:"HOST,omitempty"` Type string `json:"TYPE,omitempty"` Data string `json:"DATA,omitempty"` Aux int `json:"AUX,omitempty"` TTL int `json:"TTL,omitempty"` } type SetHostRecords struct { Payload []RecordRequest `json:"set_host_records_payload"` } type CreateHostRecords struct { Payload RecordRequest `json:"create_host_records_payload"` } type Data struct { Code int `json:"code,omitempty"` Message string `json:"message,omitempty"` Description string `json:"description,omitempty"` } type APIError struct { Errors []Data `json:"errors"` } func (a APIError) Error() string { var parts []string for _, data := range a.Errors { parts = append(parts, fmt.Sprintf("code: %d, message: %s, description: %s", data.Code, data.Message, data.Description)) } return strings.Join(parts, ", ") } type Record struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Data string `json:"data"` AUX int `json:"aux"` TTL int `json:"ttl"` } type GetDNSRecordResponse struct { Data struct { Name string `json:"name"` Code int `json:"code"` Records []Record `json:"records"` } `json:"data"` } ================================================ FILE: providers/dns/eurodns/eurodns.go ================================================ // Package eurodns implements a DNS provider for solving the DNS-01 challenge using EuroDNS. package eurodns import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/eurodns/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "EURODNS_" EnvApplicationID = envNamespace + "APP_ID" EnvAPIKey = envNamespace + "API_KEY" 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 { ApplicationID string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, internal.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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for EuroDNS. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvApplicationID, EnvAPIKey) if err != nil { return nil, fmt.Errorf("eurodns: %w", err) } config := NewDefaultConfig() config.ApplicationID = values[EnvApplicationID] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for EuroDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("eurodns: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.ApplicationID, config.APIKey) if err != nil { return nil, fmt.Errorf("eurodns: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("eurodns: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("eurodns: %w", err) } authZone = dns01.UnFqdn(authZone) zone, err := d.client.GetZone(ctx, authZone) if err != nil { return fmt.Errorf("eurodns: get zone: %w", err) } zone.Records = append(zone.Records, internal.Record{ Type: "TXT", Host: subDomain, TTL: internal.TTLRounder(d.config.TTL), RData: info.Value, }) validation, err := d.client.ValidateZone(ctx, authZone, zone) if err != nil { return fmt.Errorf("eurodns: validate zone: %w", err) } if validation.Report != nil && !validation.Report.IsValid { return fmt.Errorf("eurodns: validation report: %w", validation.Report) } err = d.client.SaveZone(ctx, authZone, zone) if err != nil { return fmt.Errorf("eurodns: save zone: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("eurodns: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("eurodns: %w", err) } authZone = dns01.UnFqdn(authZone) zone, err := d.client.GetZone(ctx, authZone) if err != nil { return fmt.Errorf("eurodns: get zone: %w", err) } var recordsToKeep []internal.Record for _, record := range zone.Records { if record.Type == "TXT" && record.Host == subDomain && record.RData == info.Value { continue } recordsToKeep = append(recordsToKeep, record) } zone.Records = recordsToKeep validation, err := d.client.ValidateZone(ctx, authZone, zone) if err != nil { return fmt.Errorf("eurodns: validate zone: %w", err) } if validation.Report != nil && !validation.Report.IsValid { return fmt.Errorf("eurodns: validation report: %w", validation.Report) } err = d.client.SaveZone(ctx, authZone, zone) if err != nil { return fmt.Errorf("eurodns: save zone: %w", err) } return 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 } ================================================ FILE: providers/dns/eurodns/eurodns.toml ================================================ Name = "EuroDNS" Description = '''''' URL = "https://www.eurodns.com/" Code = "eurodns" Since = "v4.33.0" Example = ''' EURODNS_APP_ID="xxx" \ EURODNS_API_KEY="yyy" \ lego --dns eurodns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] EURODNS_APP_ID = "Application ID" EURODNS_API_KEY = "API key" [Configuration.Additional] EURODNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" EURODNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" EURODNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" EURODNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docapi.eurodns.com/" ================================================ FILE: providers/dns/eurodns/eurodns_test.go ================================================ package eurodns import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/eurodns/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvApplicationID, EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvApplicationID: "abc", EnvAPIKey: "secret", }, }, { desc: "missing application ID", envVars: map[string]string{ EnvApplicationID: "", EnvAPIKey: "secret", }, expected: "eurodns: some credentials information are missing: EURODNS_APP_ID", }, { desc: "missing API secret", envVars: map[string]string{ EnvApplicationID: "", EnvAPIKey: "secret", }, expected: "eurodns: some credentials information are missing: EURODNS_APP_ID", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "eurodns: some credentials information are missing: EURODNS_APP_ID,EURODNS_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string appID string apiKey string expected string }{ { desc: "success", appID: "abc", apiKey: "secret", }, { desc: "missing application ID", expected: "eurodns: credentials missing", apiKey: "secret", }, { desc: "missing API secret", expected: "eurodns: credentials missing", appID: "abc", }, { desc: "missing credentials", expected: "eurodns: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.ApplicationID = test.appID config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = "secret" config.ApplicationID = "abc" config.HTTPClient = server.Client() provider, err := NewDNSProviderConfig(config) if err != nil { return nil, err } provider.client.BaseURL, _ = url.Parse(server.URL) return provider, nil }, servermock.CheckHeader(). WithJSONHeaders(). With(internal.HeaderAppID, "abc"). With(internal.HeaderAPIKey, "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /example.com", servermock.ResponseFromInternal("zone_get.json"), ). Route("POST /example.com/check", servermock.ResponseFromInternal("zone_add_validate_ok.json"), servermock.CheckRequestJSONBodyFromInternal("zone_add.json"), ). Route("PUT /example.com", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckRequestJSONBodyFromInternal("zone_add.json"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /example.com", servermock.ResponseFromInternal("zone_add.json"), ). Route("POST /example.com/check", servermock.ResponseFromInternal("zone_remove.json"), servermock.CheckRequestJSONBodyFromInternal("zone_remove.json"), ). Route("PUT /example.com", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckRequestJSONBodyFromInternal("zone_remove.json"), ). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/eurodns/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://rest-api.eurodns.com/dns-zones/" const ( HeaderAppID = "X-APP-ID" HeaderAPIKey = "X-API-KEY" ) // Client the EuroDNS API client. type Client struct { appID string apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(appID, apiKey string) (*Client, error) { if appID == "" || apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ appID: appID, apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // GetZone gets a DNS Zone. // https://docapi.eurodns.com/#/dnsprovider/getdnszone func (c *Client) GetZone(ctx context.Context, domain string) (*Zone, error) { endpoint := c.BaseURL.JoinPath(domain) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := &Zone{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } // SaveZone saves a DNS Zone. // https://docapi.eurodns.com/#/dnsprovider/savednszone func (c *Client) SaveZone(ctx context.Context, domain string, zone *Zone) error { endpoint := c.BaseURL.JoinPath(domain) if len(zone.URLForwards) == 0 { zone.URLForwards = make([]URLForward, 0) } if len(zone.MailForwards) == 0 { zone.MailForwards = make([]MailForward, 0) } req, err := newJSONRequest(ctx, http.MethodPut, endpoint, zone) if err != nil { return err } return c.do(req, nil) } // ValidateZone validates DNS Zone. // https://docapi.eurodns.com/#/dnsprovider/checkdnszone func (c *Client) ValidateZone(ctx context.Context, domain string, zone *Zone) (*Zone, error) { endpoint := c.BaseURL.JoinPath(domain, "check") if len(zone.URLForwards) == 0 { zone.URLForwards = make([]URLForward, 0) } if len(zone.MailForwards) == 0 { zone.MailForwards = make([]MailForward, 0) } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zone) if err != nil { return nil, err } result := &Zone{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(HeaderAppID, c.appID) req.Header.Set(HeaderAPIKey, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("%d: %w", resp.StatusCode, &errAPI) } const DefaultTTL = 600 // TTLRounder rounds the given TTL in seconds to the next accepted value. // Accepted TTL values are: 600, 900, 1800,3600, 7200, 14400, 21600, 43200, 86400, 172800, 432000, 604800. func TTLRounder(ttl int) int { for _, validTTL := range []int{DefaultTTL, 900, 1800, 3600, 7200, 14400, 21600, 43200, 86400, 172800, 432000, 604800} { if ttl <= validTTL { return validTTL } } return DefaultTTL } ================================================ FILE: providers/dns/eurodns/internal/client_test.go ================================================ package internal import ( "context" "net/http" "net/http/httptest" "net/url" "slices" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("abc", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). With(HeaderAppID, "abc"). With(HeaderAPIKey, "secret"), ) } func TestClient_GetZone(t *testing.T) { client := mockBuilder(). Route("GET /example.com", servermock.ResponseFromFixture("zone_get.json"), ). Build(t) zone, err := client.GetZone(context.Background(), "example.com") require.NoError(t, err) expected := &Zone{ Name: "example.com", DomainConnect: true, Records: slices.Concat([]Record{fakeARecord()}), URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, } assert.Equal(t, expected, zone) } func TestClient_GetZone_error(t *testing.T) { client := mockBuilder(). Route("GET /example.com", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized), ). Build(t) _, err := client.GetZone(context.Background(), "example.com") require.Error(t, err) require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key") } func TestClient_SaveZone(t *testing.T) { client := mockBuilder(). Route("PUT /example.com", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckRequestJSONBodyFromFixture("zone_add.json"), ). Build(t) record := Record{ Type: "TXT", Host: "_acme-challenge", RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 600, } zone := &Zone{ Name: "example.com", DomainConnect: true, Records: []Record{fakeARecord(), record}, URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, } err := client.SaveZone(context.Background(), "example.com", zone) require.NoError(t, err) } func TestClient_SaveZone_emptyForwards(t *testing.T) { client := mockBuilder(). Route("PUT /example.com", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckRequestJSONBodyFromFixture("zone_add_empty_forwards.json"), ). Build(t) record := Record{ Type: "TXT", Host: "_acme-challenge", RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 600, } zone := &Zone{ Name: "example.com", DomainConnect: true, Records: slices.Concat([]Record{fakeARecord(), record}), } err := client.SaveZone(context.Background(), "example.com", zone) require.NoError(t, err) } func TestClient_SaveZone_error(t *testing.T) { client := mockBuilder(). Route("PUT /example.com", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized), ). Build(t) zone := &Zone{ Name: "example.com", DomainConnect: true, Records: []Record{fakeARecord()}, URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, } err := client.SaveZone(context.Background(), "example.com", zone) require.Error(t, err) require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key") } func TestClient_ValidateZone(t *testing.T) { client := mockBuilder(). Route("POST /example.com/check", servermock.ResponseFromFixture("zone_add_validate_ok.json"), servermock.CheckRequestJSONBodyFromFixture("zone_add.json"), ). Build(t) record := Record{ Type: "TXT", Host: "_acme-challenge", RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 600, } zone := &Zone{ Name: "example.com", DomainConnect: true, Records: []Record{fakeARecord(), record}, URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, } zone, err := client.ValidateZone(context.Background(), "example.com", zone) require.NoError(t, err) expected := &Zone{ Name: "example.com", DomainConnect: true, Records: []Record{fakeARecord(), record}, URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, Report: &Report{IsValid: true}, } assert.Equal(t, expected, zone) } func TestClient_ValidateZone_report(t *testing.T) { client := mockBuilder(). Route("POST /example.com/check", servermock.ResponseFromFixture("zone_add_validate_ko.json"), servermock.CheckRequestJSONBodyFromFixture("zone_add.json"), ). Build(t) record := Record{ Type: "TXT", Host: "_acme-challenge", RData: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 600, } zone := &Zone{ Name: "example.com", DomainConnect: true, Records: []Record{fakeARecord(), record}, URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, } zone, err := client.ValidateZone(context.Background(), "example.com", zone) require.NoError(t, err) expected := &Zone{ Name: "example.com", DomainConnect: true, Records: []Record{fakeARecord(), record}, URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, Report: fakeReport(), } assert.EqualError(t, zone.Report, `record error (ERROR): "120" is not a valid TTL, URL forward error (ERROR): string, mail forward error (ERROR): string, zone error (ERROR): string`) assert.Equal(t, expected, zone) } func TestClient_ValidateZone_error(t *testing.T) { client := mockBuilder(). Route("POST /example.com/check", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized), ). Build(t) zone := &Zone{ Name: "example.com", DomainConnect: true, Records: []Record{fakeARecord()}, URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, } _, err := client.ValidateZone(context.Background(), "example.com", zone) require.Error(t, err) require.EqualError(t, err, "401: INVALID_API_KEY: Invalid API Key") } func fakeARecord() Record { return Record{ ID: 1000, Type: "A", Host: "@", TTL: 600, RData: "string", Updated: ptr.Pointer(true), Locked: ptr.Pointer(true), IsDynDNS: ptr.Pointer(true), Proxy: "ON", } } func fakeURLForward() URLForward { return URLForward{ ID: 2000, ForwardType: "FRAME", Host: "string", URL: "string", Title: "string", Keywords: "string", Description: "string", Updated: ptr.Pointer(true), } } func fakeMailForward() MailForward { return MailForward{ ID: 3000, Source: "string", Destination: "string", Updated: ptr.Pointer(true), } } func fakeReport() *Report { return &Report{ IsValid: false, RecordErrors: []RecordError{{ Messages: []string{`"120" is not a valid TTL`}, Severity: "ERROR", Record: fakeARecord(), }}, URLForwardErrors: []URLForwardError{{ Messages: []string{"string"}, Severity: "ERROR", URLForward: fakeURLForward(), }}, MailForwardErrors: []MailForwardError{{ Messages: []string{"string"}, MailForward: fakeMailForward(), Severity: "ERROR", }}, ZoneErrors: []ZoneError{{ Message: "string", Severity: "ERROR", Records: []Record{fakeARecord()}, URLForwards: []URLForward{fakeURLForward()}, MailForwards: []MailForward{fakeMailForward()}, }}, } } ================================================ FILE: providers/dns/eurodns/internal/fixtures/error.json ================================================ { "errors": [ { "code": "INVALID_API_KEY", "title": "Invalid API Key" } ] } ================================================ FILE: providers/dns/eurodns/internal/fixtures/zone_add.json ================================================ { "name": "example.com", "domainConnect": true, "records": [ { "id": 1000, "type": "A", "host": "@", "ttl": 600, "rdata": "string", "updated": true, "locked": true, "isDynDns": true, "proxy": "ON" }, { "type": "TXT", "host": "_acme-challenge", "ttl": 600, "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "updated": null, "locked": null, "isDynDns": null } ], "urlForwards": [ { "id": 2000, "forwardType": "FRAME", "host": "string", "url": "string", "title": "string", "keywords": "string", "description": "string", "updated": true } ], "mailForwards": [ { "id": 3000, "source": "string", "destination": "string", "updated": true } ] } ================================================ FILE: providers/dns/eurodns/internal/fixtures/zone_add_empty_forwards.json ================================================ { "name": "example.com", "domainConnect": true, "records": [ { "id": 1000, "type": "A", "host": "@", "ttl": 600, "rdata": "string", "updated": true, "locked": true, "isDynDns": true, "proxy": "ON" }, { "type": "TXT", "host": "_acme-challenge", "ttl": 600, "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "updated": null, "locked": null, "isDynDns": null } ], "urlForwards": [], "mailForwards": [] } ================================================ FILE: providers/dns/eurodns/internal/fixtures/zone_add_validate_ko.json ================================================ { "name": "example.com", "domainConnect": true, "records": [ { "id": 1000, "type": "A", "host": "@", "ttl": 600, "rdata": "string", "updated": true, "locked": true, "isDynDns": true, "proxy": "ON" }, { "type": "TXT", "host": "_acme-challenge", "ttl": 600, "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "updated": null, "locked": null, "isDynDns": null } ], "urlForwards": [ { "id": 2000, "forwardType": "FRAME", "host": "string", "url": "string", "title": "string", "keywords": "string", "description": "string", "updated": true } ], "mailForwards": [ { "id": 3000, "source": "string", "destination": "string", "updated": true } ], "report": { "isValid": false, "recordErrors": [ { "messages": [ "\"120\" is not a valid TTL" ], "record": { "id": 1000, "type": "A", "host": "@", "ttl": 600, "rdata": "string", "updated": true, "locked": true, "isDynDns": true, "proxy": "ON" }, "severity": "ERROR" } ], "urlForwardErrors": [ { "messages": [ "string" ], "urlForward": { "id": 2000, "forwardType": "FRAME", "host": "string", "url": "string", "title": "string", "keywords": "string", "description": "string", "updated": true }, "severity": "ERROR" } ], "mailForwardErrors": [ { "messages": [ "string" ], "mailForward": { "id": 3000, "source": "string", "destination": "string", "updated": true }, "severity": "ERROR" } ], "zoneErrors": [ { "message": "string", "records": [ { "id": 1000, "type": "A", "host": "@", "ttl": 600, "rdata": "string", "updated": true, "locked": true, "isDynDns": true, "proxy": "ON" } ], "urlForwards": [ { "id": 2000, "forwardType": "FRAME", "host": "string", "url": "string", "title": "string", "keywords": "string", "description": "string", "updated": true } ], "mailForwards": [ { "id": 3000, "source": "string", "destination": "string", "updated": true } ], "severity": "ERROR" } ] } } ================================================ FILE: providers/dns/eurodns/internal/fixtures/zone_add_validate_ok.json ================================================ { "name": "example.com", "domainConnect": true, "records": [ { "id": 1000, "type": "A", "host": "@", "ttl": 600, "rdata": "string", "updated": true, "locked": true, "isDynDns": true, "proxy": "ON" }, { "type": "TXT", "host": "_acme-challenge", "ttl": 600, "rdata": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "updated": null, "locked": null, "isDynDns": null } ], "urlForwards": [ { "id": 2000, "forwardType": "FRAME", "host": "string", "url": "string", "title": "string", "keywords": "string", "description": "string", "updated": true } ], "mailForwards": [ { "id": 3000, "source": "string", "destination": "string", "updated": true } ], "report": { "isValid": true } } ================================================ FILE: providers/dns/eurodns/internal/fixtures/zone_get.json ================================================ { "name": "example.com", "domainConnect": true, "records": [ { "id": 1000, "type": "A", "host": "@", "ttl": 600, "rdata": "string", "updated": true, "locked": true, "isDynDns": true, "proxy": "ON" } ], "urlForwards": [ { "id": 2000, "forwardType": "FRAME", "host": "string", "url": "string", "title": "string", "keywords": "string", "description": "string", "updated": true } ], "mailForwards": [ { "id": 3000, "source": "string", "destination": "string", "updated": true } ] } ================================================ FILE: providers/dns/eurodns/internal/fixtures/zone_remove.json ================================================ { "name": "example.com", "domainConnect": true, "records": [ { "id": 1000, "type": "A", "host": "@", "ttl": 600, "rdata": "string", "updated": true, "locked": true, "isDynDns": true, "proxy": "ON" } ], "urlForwards": [ { "id": 2000, "forwardType": "FRAME", "host": "string", "url": "string", "title": "string", "keywords": "string", "description": "string", "updated": true } ], "mailForwards": [ { "id": 3000, "source": "string", "destination": "string", "updated": true } ] } ================================================ FILE: providers/dns/eurodns/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type APIError struct { Errors []Error `json:"errors"` } func (a *APIError) Error() string { var msg []string for _, e := range a.Errors { msg = append(msg, fmt.Sprintf("%s: %s", e.Code, e.Title)) } return strings.Join(msg, ", ") } type Error struct { Code string `json:"code"` Title string `json:"title"` } type Zone struct { Name string `json:"name,omitempty"` DomainConnect bool `json:"domainConnect,omitempty"` Records []Record `json:"records"` URLForwards []URLForward `json:"urlForwards"` MailForwards []MailForward `json:"mailForwards"` Report *Report `json:"report,omitempty"` } type Record struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` Host string `json:"host,omitempty"` TTL int `json:"ttl,omitempty"` RData string `json:"rdata,omitempty"` Updated *bool `json:"updated"` Locked *bool `json:"locked"` IsDynDNS *bool `json:"isDynDns"` Proxy string `json:"proxy,omitempty"` } type URLForward struct { ID int `json:"id,omitempty"` ForwardType string `json:"forwardType,omitempty"` Host string `json:"host,omitempty"` URL string `json:"url,omitempty"` Title string `json:"title,omitempty"` Keywords string `json:"keywords,omitempty"` Description string `json:"description,omitempty"` Updated *bool `json:"updated,omitempty"` } type MailForward struct { ID int `json:"id,omitempty"` Source string `json:"source,omitempty"` Destination string `json:"destination,omitempty"` Updated *bool `json:"updated,omitempty"` } type Report struct { IsValid bool `json:"isValid,omitempty"` RecordErrors []RecordError `json:"recordErrors,omitempty"` URLForwardErrors []URLForwardError `json:"urlForwardErrors,omitempty"` MailForwardErrors []MailForwardError `json:"mailForwardErrors,omitempty"` ZoneErrors []ZoneError `json:"zoneErrors,omitempty"` } func (r *Report) Error() string { var msg []string for _, e := range r.RecordErrors { msg = append(msg, e.Error()) } for _, e := range r.URLForwardErrors { msg = append(msg, e.Error()) } for _, e := range r.MailForwardErrors { msg = append(msg, e.Error()) } for _, e := range r.ZoneErrors { msg = append(msg, e.Error()) } return strings.Join(msg, ", ") } type RecordError struct { Messages []string `json:"messages,omitempty"` Record Record `json:"record"` Severity string `json:"severity,omitempty"` } func (e *RecordError) Error() string { return fmt.Sprintf("record error (%s): %s", e.Severity, strings.Join(e.Messages, ", ")) } type URLForwardError struct { Messages []string `json:"messages,omitempty"` URLForward URLForward `json:"urlForward"` Severity string `json:"severity,omitempty"` } func (e *URLForwardError) Error() string { return fmt.Sprintf("URL forward error (%s): %s", e.Severity, strings.Join(e.Messages, ", ")) } type MailForwardError struct { Messages []string `json:"messages,omitempty"` MailForward MailForward `json:"mailForward"` Severity string `json:"severity,omitempty"` } func (e *MailForwardError) Error() string { return fmt.Sprintf("mail forward error (%s): %s", e.Severity, strings.Join(e.Messages, ", ")) } type ZoneError struct { Message string `json:"message,omitempty"` Records []Record `json:"records,omitempty"` URLForwards []URLForward `json:"urlForwards,omitempty"` MailForwards []MailForward `json:"mailForwards,omitempty"` Severity string `json:"severity,omitempty"` } func (e *ZoneError) Error() string { return fmt.Sprintf("zone error (%s): %s", e.Severity, e.Message) } ================================================ FILE: providers/dns/excedo/excedo.go ================================================ // Package excedo implements a DNS provider for solving the DNS-01 challenge using Excedo. package excedo import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/excedo/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "EXCEDO_" EnvAPIURL = envNamespace + "API_URL" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIURL string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordsMu sync.Mutex records map[string]int64 } // NewDNSProvider returns a DNSProvider instance configured for Excedo. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIURL, EnvAPIKey) if err != nil { return nil, fmt.Errorf("excedo: %w", err) } config := NewDefaultConfig() config.APIURL = values[EnvAPIURL] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Excedo. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("excedo: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIURL, config.APIKey) if err != nil { return nil, fmt.Errorf("excedo: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, records: make(map[string]int64), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("excedo: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("excedo: %w", err) } record := internal.Record{ DomainName: dns01.UnFqdn(authZone), Name: subDomain, Type: "TXT", Content: info.Value, TTL: strconv.Itoa(d.config.TTL), } recordID, err := d.client.AddRecord(ctx, record) if err != nil { return fmt.Errorf("excedo: add record: %w", err) } d.recordsMu.Lock() d.records[token] = recordID d.recordsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("excedo: could not find zone for domain %q: %w", domain, err) } d.recordsMu.Lock() recordID, ok := d.records[token] d.recordsMu.Unlock() if !ok { return fmt.Errorf("excedo: unknown record ID for '%s'", info.EffectiveFQDN) } err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), strconv.FormatInt(recordID, 10)) if err != nil { return fmt.Errorf("excedo: delete record: %w", err) } d.recordsMu.Lock() delete(d.records, token) d.recordsMu.Unlock() return 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 } ================================================ FILE: providers/dns/excedo/excedo.toml ================================================ Name = "Excedo" Description = '''''' URL = "https://excedo.se/" Code = "excedo" Since = "v4.33.0" Example = ''' EXCEDO_API_KEY=your-api-key \ EXCEDO_API_URL=your-base-url \ lego --dns excedo -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] EXCEDO_API_KEY = "API key" EXCEDO_API_URL = "API base URL" [Configuration.Additional] EXCEDO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" EXCEDO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" EXCEDO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" EXCEDO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "none" ================================================ FILE: providers/dns/excedo/excedo_test.go ================================================ package excedo import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIURL, EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIURL: "https://example.com", EnvAPIKey: "secret", }, }, { desc: "missing the API key", envVars: map[string]string{ EnvAPIURL: "https://example.com", EnvAPIKey: "", }, expected: "excedo: some credentials information are missing: EXCEDO_API_KEY", }, { desc: "missing the API URL", envVars: map[string]string{ EnvAPIURL: "", EnvAPIKey: "secret", }, expected: "excedo: some credentials information are missing: EXCEDO_API_URL", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "excedo: some credentials information are missing: EXCEDO_API_URL,EXCEDO_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiURL string apiKey string expected string }{ { desc: "success", apiURL: "https://example.com", apiKey: "secret", }, { desc: "missing the API key", apiURL: "https://example.com", expected: "excedo: credentials missing", }, { desc: "missing the API URL", apiKey: "secret", expected: "excedo: credentials missing", }, { desc: "missing credentials", expected: "excedo: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIURL = test.apiURL config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIURL = server.URL config.APIKey = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } return p, nil }, ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /authenticate/login/", servermock.ResponseFromInternal("login.json"), servermock.CheckHeader(). WithAuthorization("Bearer secret"), ). Route("POST /dns/addrecord/", servermock.ResponseFromInternal("addrecord.json"), servermock.CheckHeader(). WithAuthorization("Bearer session-token"), servermock.CheckForm().Strict(). With("content", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). With("domainName", "example.com"). With("name", "_acme-challenge"). With("ttl", "60"). With("type", "TXT"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /authenticate/login/", servermock.ResponseFromInternal("login.json"), servermock.CheckHeader(). WithAuthorization("Bearer secret"), ). Route("POST /dns/deleterecord/", servermock.ResponseFromInternal("deleterecord.json"), servermock.CheckHeader(). WithAuthorization("Bearer session-token"), ). Build(t) provider.records["abc"] = 19695822 err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/excedo/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" querystring "github.com/google/go-querystring/query" ) type responseChecker interface { Check() error } // Client the Excedo API client. type Client struct { apiKey string baseURL *url.URL HTTPClient *http.Client token *ExpirableToken muToken sync.Mutex } // NewClient creates a new Client. func NewClient(apiURL, apiKey string) (*Client, error) { if apiURL == "" || apiKey == "" { return nil, errors.New("credentials missing") } baseURL, err := url.Parse(apiURL) if err != nil { return nil, err } return &Client{ apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddRecord(ctx context.Context, record Record) (int64, error) { payload, err := querystring.Values(record) if err != nil { return 0, err } endpoint := c.baseURL.JoinPath("/dns/addrecord/") req, err := newFormRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return 0, err } result := new(AddRecordResponse) err = c.doAuthenticated(ctx, req, result) if err != nil { return 0, err } return result.RecordID, nil } func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error { endpoint := c.baseURL.JoinPath("/dns/deleterecord/") data := map[string]string{ "domainname": dns01.UnFqdn(zone), "recordid": recordID, } req, err := newMultipartRequest(ctx, http.MethodPost, endpoint, data) if err != nil { return err } result := new(BaseResponse) err = c.doAuthenticated(ctx, req, result) if err != nil { return err } return nil } func (c *Client) GetRecords(ctx context.Context, zone string) (map[string]Zone, error) { endpoint := c.baseURL.JoinPath("/dns/getrecords/") query := endpoint.Query() query.Set("domainname", zone) endpoint.RawQuery = query.Encode() req, err := newFormRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := new(GetRecordsResponse) err = c.doAuthenticated(ctx, req, result) if err != nil { return nil, err } return result.DNS, nil } func (c *Client) do(req *http.Request, result responseChecker) error { useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return result.Check() } func newMultipartRequest(ctx context.Context, method string, endpoint *url.URL, data map[string]string) (*http.Request, error) { buf := new(bytes.Buffer) writer := multipart.NewWriter(buf) for k, v := range data { err := writer.WriteField(k, v) if err != nil { return nil, err } } err := writer.Close() if err != nil { return nil, err } body := bytes.NewReader(buf.Bytes()) req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) return req, nil } func newFormRequest(ctx context.Context, method string, endpoint *url.URL, form url.Values) (*http.Request, error) { var body io.Reader if len(form) > 0 { body = bytes.NewReader([]byte(form.Encode())) } else { body = http.NoBody } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } if method == http.MethodPost { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } return req, nil } ================================================ FILE: providers/dns/excedo/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }, ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/addrecord/", servermock.ResponseFromFixture("addrecord.json"), servermock.CheckHeader(). WithAuthorization("Bearer session-token"), servermock.CheckForm().Strict(). With("content", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). With("domainName", "example.com"). With("name", "_acme-challenge"). With("ttl", "60"). With("type", "TXT"), ). Build(t) client.token = &ExpirableToken{ Token: "session-token", Expires: time.Now().Add(6 * time.Hour), } record := Record{ DomainName: "example.com", Name: "_acme-challenge", Type: "TXT", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: "60", } recordID, err := client.AddRecord(t.Context(), record) require.NoError(t, err) assert.EqualValues(t, 19695822, recordID) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /dns/addrecord/", servermock.ResponseFromFixture("error.json"), ). Build(t) client.token = &ExpirableToken{ Token: "session-token", Expires: time.Now().Add(6 * time.Hour), } record := Record{ DomainName: "example.com", Name: "_acme-challenge", Type: "TXT", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: "60", } _, err := client.AddRecord(t.Context(), record) require.EqualError(t, err, "2003: Required parameter missing") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/deleterecord/", servermock.ResponseFromFixture("deleterecord.json"), servermock.CheckHeader(). WithAuthorization("Bearer session-token"), ). Build(t) client.token = &ExpirableToken{ Token: "session-token", Expires: time.Now().Add(6 * time.Hour), } err := client.DeleteRecord(t.Context(), "example.com", "19695822") require.NoError(t, err) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/getrecords/", servermock.ResponseFromFixture("getrecords.json"), servermock.CheckHeader(). WithAuthorization("Bearer session-token"), servermock.CheckQueryParameter().Strict(). With("domainname", "example.com"), ). Build(t) client.token = &ExpirableToken{ Token: "session-token", Expires: time.Now().Add(6 * time.Hour), } zones, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) expected := map[string]Zone{ "example.com": { DNSType: "type", Records: []Record{{ RecordID: "1234", Name: "_acme-challenge.example.com", Type: "TXT", Content: "txt-value", TTL: "60", }}, }, } assert.Equal(t, expected, zones) } ================================================ FILE: providers/dns/excedo/internal/fixtures/addrecord.json ================================================ { "code": 1000, "desc": "Command completed successfully", "recordid": 19695822, "session": { "accID": "1234", "usrID": "1234", "status": "active", "expire": { "date": "2026-03-10 19:03:18", "seconds": 5678 } }, "runtime": 0.2852 } ================================================ FILE: providers/dns/excedo/internal/fixtures/deleterecord.json ================================================ { "code": 1000, "desc": "Command completed successfully", "session": { "accID": "1234", "usrID": "1234", "status": "active", "expire": { "date": "2026-03-10 19:03:18", "seconds": 5678 } }, "runtime": 0.2852 } ================================================ FILE: providers/dns/excedo/internal/fixtures/error.json ================================================ { "code": 2003, "desc": "Required parameter missing", "missing": [ "domainname", "recordid" ], "session": { "accID": "1234", "usrID": "1234", "status": "active", "expire": { "date": "2026-03-10 19:03:18", "seconds": 5485 } }, "runtime": 0.0534 } ================================================ FILE: providers/dns/excedo/internal/fixtures/getrecords.json ================================================ { "code": 1000, "desc": "Command completed successfully", "dns": { "example.com": { "dnstype": "type", "recordusage": { "used": 74 }, "records": [ { "recordid": "1234", "name": "_acme-challenge.example.com", "type": "TXT", "content": "txt-value", "ttl": "60", "prio": null, "change_date": null } ] } } } ================================================ FILE: providers/dns/excedo/internal/fixtures/login.json ================================================ { "code": 1000, "desc": "Command completed successfully", "parameters": { "token": "session-token" } } ================================================ FILE: providers/dns/excedo/internal/identity.go ================================================ package internal import ( "context" "fmt" "net/http" "time" ) type ExpirableToken struct { Token string Expires time.Time } func (t *ExpirableToken) IsExpired() bool { return time.Now().After(t.Expires) } func (c *Client) Login(ctx context.Context) (string, error) { endpoint := c.baseURL.JoinPath("/authenticate/login/") req, err := newFormRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+c.apiKey) result := new(LoginResponse) err = c.do(req, result) if err != nil { return "", err } if result.Code != 1000 && result.Code != 1300 { return "", fmt.Errorf("%d: %s", result.Code, result.Description) } return result.Parameters.Token, nil } func (c *Client) authenticate(ctx context.Context) (string, error) { c.muToken.Lock() defer c.muToken.Unlock() if c.token == nil || c.token.IsExpired() { token, err := c.Login(ctx) if err != nil { return "", err } c.token = &ExpirableToken{ Token: token, Expires: time.Now().Add(2*time.Hour - time.Minute), } return token, nil } return c.token.Token, nil } func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result responseChecker) error { token, err := c.authenticate(ctx) if err != nil { return err } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } return c.do(req, result) } ================================================ FILE: providers/dns/excedo/internal/identity_test.go ================================================ package internal import ( "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClient_Login(t *testing.T) { client := mockBuilder(). Route("GET /authenticate/login/", servermock.ResponseFromFixture("login.json"), servermock.CheckHeader(). WithAuthorization("Bearer secret"), ). Build(t) token, err := client.Login(t.Context()) require.NoError(t, err) assert.Equal(t, "session-token", token) } func TestClient_Login_error(t *testing.T) { client := mockBuilder(). Route("GET /authenticate/login/", servermock.ResponseFromFixture("error.json"), ). Build(t) _, err := client.Login(t.Context()) require.EqualError(t, err, "2003: Required parameter missing") } ================================================ FILE: providers/dns/excedo/internal/types.go ================================================ package internal import "fmt" type BaseResponse struct { Code int `json:"code"` Description string `json:"desc"` } func (r BaseResponse) Check() error { // Response codes: // - 1000: Command completed successfully // - 1300: Command completed successfully; no messages // - 2001: Command syntax error // - 2002: Command use error // - 2003: Required parameter missing // - 2004: Parameter value range error // - 2104: Billing failure // - 2200: Authentication error // - 2201: Authorization error // - 2303: Object does not exist // - 2304: Object status prohibits operation // - 2309: Object duplicate found // - 2400: Command failed // - 2500: Command failed; server closing connection if r.Code != 1000 && r.Code != 1300 { return fmt.Errorf("%d: %s", r.Code, r.Description) } return nil } type GetRecordsResponse struct { BaseResponse DNS map[string]Zone `json:"dns"` } type Zone struct { DNSType string `json:"dnstype"` Records []Record `json:"records"` } type Record struct { DomainName string `json:"domainName,omitempty" url:"domainName,omitempty"` RecordID string `json:"recordid,omitempty" url:"recordid,omitempty"` Name string `json:"name,omitempty" url:"name,omitempty"` Type string `json:"type,omitempty" url:"type,omitempty"` Content string `json:"content,omitempty" url:"content,omitempty"` TTL string `json:"ttl,omitempty" url:"ttl,omitempty"` } type AddRecordResponse struct { BaseResponse RecordID int64 `json:"recordid"` } type LoginResponse struct { BaseResponse Parameters struct { Token string `json:"token"` } `json:"parameters"` } ================================================ FILE: providers/dns/exec/exec.go ================================================ // Package exec implements a DNS provider which runs a program for adding/removing the DNS record. package exec import ( "bufio" "context" "errors" "fmt" "os" "os/exec" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" ) // Environment variables names. const ( envNamespace = "EXEC_" EnvPath = envNamespace + "PATH" EnvMode = envNamespace + "MODE" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config Provider configuration. type Config struct { Program string Mode string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config } // NewDNSProvider returns a new DNS provider which runs the program in the // environment variable EXEC_PATH for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvPath) if err != nil { return nil, fmt.Errorf("exec: %w", err) } config := NewDefaultConfig() config.Program = values[EnvPath] config.Mode = os.Getenv(EnvMode) return NewDNSProviderConfig(config) } // NewDNSProviderConfig returns a new DNS provider which runs the given configuration // for adding and removing the DNS record. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("exec: the configuration is nil") } return &DNSProvider{config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.run(context.Background(), "present", domain, token, keyAuth) if err != nil { return fmt.Errorf("exec: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.run(context.Background(), "cleanup", domain, token, keyAuth) if err != nil { return fmt.Errorf("exec: %w", err) } return 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } func (d *DNSProvider) run(ctx context.Context, command, domain, token, keyAuth string) error { var args []string if d.config.Mode == "RAW" { args = []string{command, "--", domain, token, keyAuth} } else { info := dns01.GetChallengeInfo(domain, keyAuth) args = []string{command, info.EffectiveFQDN, info.Value} } cmd := exec.CommandContext(ctx, d.config.Program, args...) stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("create pipe: %w", err) } cmd.Stderr = cmd.Stdout err = cmd.Start() if err != nil { return fmt.Errorf("start command: %w", err) } scanner := bufio.NewScanner(stdout) for scanner.Scan() { log.Println(scanner.Text()) } err = cmd.Wait() if err != nil { return fmt.Errorf("wait command: %w", err) } return nil } ================================================ FILE: providers/dns/exec/exec.toml ================================================ Name = "External program" Description = "Solving the DNS-01 challenge using an external program." URL = "/dns/exec" Code = "exec" Since = "v0.5.0" Example = ''' EXEC_PATH=/the/path/to/myscript.sh \ lego --dns exec -d '*.example.com' -d example.com run ''' Additional = ''' ## Base Configuration | Environment Variable Name | Description | |---------------------------|---------------------------------------| | `EXEC_MODE` | `RAW`, none | | `EXEC_PATH` | The path of the the external program. | ## Additional Configuration | Environment Variable Name | Description | |----------------------------|--------------------------------------------------------------------| | `EXEC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 3). | | `EXEC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60). | | `EXEC_SEQUENCE_INTERVAL` | Time between sequential requests in seconds (Default: 60). | ## Description The file name of the external program is specified in the environment variable `EXEC_PATH`. When it is run by lego, three command-line parameters are passed to it: The action ("present" or "cleanup"), the fully-qualified domain name and the value for the record. For example, requesting a certificate for the domain 'my.example.org' can be achieved by calling lego as follows: ```bash EXEC_PATH=./update-dns.sh \ lego --dns exec --d my.example.org run ``` It will then call the program './update-dns.sh' with like this: ```bash ./update-dns.sh "present" "_acme-challenge.my.example.org." "MsijOYZxqyjGnFGwhjrhfg-Xgbl5r68WPda0J9EgqqI" ``` The program then needs to make sure the record is inserted. When it returns an error via a non-zero exit code, lego aborts. When the record is to be removed again, the program is called with the first command-line parameter set to `cleanup` instead of `present`. If you want to use the raw domain, token, and keyAuth values with your program, you can set `EXEC_MODE=RAW`: ```bash EXEC_MODE=RAW \ EXEC_PATH=./update-dns.sh \ lego --dns exec -d my.example.org run ``` It will then call the program `./update-dns.sh` like this: ```bash ./update-dns.sh "present" "--" "my.example.org." "some-token" "KxAy-J3NwUmg9ZQuM-gP_Mq1nStaYSaP9tYQs5_-YsE.ksT-qywTd8058G-SHHWA3RAN72Pr0yWtPYmmY5UBpQ8" ``` ## Commands {{% notice note %}} The `--` is because the token MAY start with a `-`, and the called program may try and interpret a `-` as indicating a flag. In the case of urfave, which is commonly used, you can use the `--` delimiter to specify the start of positional arguments, and handle such a string safely. {{% /notice %}} ### Present | Mode | Command | |---------|----------------------------------------------------| | default | `myprogram present ` | | `RAW` | `myprogram present -- ` | ### Cleanup | Mode | Command | |---------|----------------------------------------------------| | default | `myprogram cleanup ` | | `RAW` | `myprogram cleanup -- ` | ''' ================================================ FILE: providers/dns/exec/exec_test.go ================================================ package exec import ( "fmt" "os" "strings" "testing" "github.com/go-acme/lego/v4/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) func TestDNSProvider_Present(t *testing.T) { backupLogger := log.Logger defer func() { log.Logger = backupLogger }() logRecorder := &LogRecorder{} log.Logger = logRecorder type expected struct { args string error bool } testCases := []struct { desc string config *Config expected expected }{ { desc: "Standard mode", config: &Config{ Program: "echo", Mode: "", }, expected: expected{ args: "present _acme-challenge.domain. pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", }, }, { desc: "program error", config: &Config{ Program: "ogellego", Mode: "", }, expected: expected{error: true}, }, { desc: "Raw mode", config: &Config{ Program: "echo", Mode: "RAW", }, expected: expected{ args: "present -- domain token keyAuth", }, }, } var message string logRecorder.On("Println", mock.Anything).Run(func(args mock.Arguments) { message = args.String(0) fmt.Fprintln(os.Stdout, "XXX", message) }) for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { message = "" provider, err := NewDNSProviderConfig(test.config) require.NoError(t, err) err = provider.Present("domain", "token", "keyAuth") if test.expected.error { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, test.expected.args, strings.TrimSpace(message)) } }) } } func TestDNSProvider_CleanUp(t *testing.T) { backupLogger := log.Logger defer func() { log.Logger = backupLogger }() logRecorder := &LogRecorder{} log.Logger = logRecorder type expected struct { args string error bool } testCases := []struct { desc string config *Config expected expected }{ { desc: "Standard mode", config: &Config{ Program: "echo", Mode: "", }, expected: expected{ args: "cleanup _acme-challenge.domain. pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", }, }, { desc: "program error", config: &Config{ Program: "ogellego", Mode: "", }, expected: expected{error: true}, }, { desc: "Raw mode", config: &Config{ Program: "echo", Mode: "RAW", }, expected: expected{ args: "cleanup -- domain token keyAuth", }, }, } var message string logRecorder.On("Println", mock.Anything).Run(func(args mock.Arguments) { message = args.String(0) fmt.Fprintln(os.Stdout, "XXX", message) }) for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { message = "" provider, err := NewDNSProviderConfig(test.config) require.NoError(t, err) err = provider.CleanUp("domain", "token", "keyAuth") if test.expected.error { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, test.expected.args, strings.TrimSpace(message)) } }) } } ================================================ FILE: providers/dns/exec/log_mock_test.go ================================================ package exec import "github.com/stretchr/testify/mock" type LogRecorder struct { mock.Mock } func (*LogRecorder) Fatal(args ...any) { panic("implement me") } func (*LogRecorder) Fatalln(args ...any) { panic("implement me") } func (*LogRecorder) Fatalf(format string, args ...any) { panic("implement me") } func (*LogRecorder) Print(args ...any) { panic("implement me") } func (l *LogRecorder) Println(args ...any) { l.Called(args...) } func (*LogRecorder) Printf(format string, args ...any) { panic("implement me") } ================================================ FILE: providers/dns/exoscale/exoscale.go ================================================ // Package exoscale implements a DNS provider for solving the DNS-01 challenge using Exoscale DNS. package exoscale import ( "context" "errors" "fmt" "net/http" "strconv" "time" egoscale "github.com/exoscale/egoscale/v3" "github.com/exoscale/egoscale/v3/credentials" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) // Environment variables names. const ( envNamespace = "EXOSCALE_" EnvAPISecret = envNamespace + "API_SECRET" EnvAPIKey = envNamespace + "API_KEY" EnvEndpoint = envNamespace + "ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string APISecret string Endpoint string HTTPTimeout time.Duration PropagationTimeout time.Duration PollingInterval time.Duration TTL int64 } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: int64(env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL)), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *egoscale.Client } // NewDNSProvider Credentials must be passed in the environment variables: // EXOSCALE_API_KEY, EXOSCALE_API_SECRET, EXOSCALE_ENDPOINT. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvAPISecret) if err != nil { return nil, fmt.Errorf("exoscale: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.APISecret = values[EnvAPISecret] config.Endpoint = env.GetOrDefaultString(EnvEndpoint, string(egoscale.CHGva2)) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Exoscale. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("exoscale: the configuration of the DNS provider is nil") } if config.APIKey == "" || config.APISecret == "" { return nil, errors.New("exoscale: credentials missing") } client, err := egoscale.NewClient( credentials.NewStaticCredentials(config.APIKey, config.APISecret), egoscale.ClientOptWithEndpoint(egoscale.Endpoint(config.Endpoint)), egoscale.ClientOptWithHTTPClient(clientdebug.Wrap(&http.Client{Timeout: config.HTTPTimeout})), egoscale.ClientOptWithUserAgent(useragent.Get()), ) if err != nil { return nil, fmt.Errorf("exoscale: initializing client: %w", err) } return &DNSProvider{ client: client, config: config, }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("exoscale: %w", err) } zone, err := d.findExistingZone(ctx, zoneName) if err != nil { return fmt.Errorf("exoscale: %w", err) } if zone == nil { return fmt.Errorf("exoscale: zone %q not found", zoneName) } recordRequest := egoscale.CreateDNSDomainRecordRequest{ Name: recordName, Ttl: d.config.TTL, Content: info.Value, Type: egoscale.CreateDNSDomainRecordRequestTypeTXT, } op, err := d.client.CreateDNSDomainRecord(ctx, zone.ID, recordRequest) if err != nil { return fmt.Errorf("exoscale: error while creating DNS record: %w", err) } _, err = d.client.Wait(ctx, op, egoscale.OperationStateSuccess) if err != nil { return fmt.Errorf("exoscale: error while creating DNS record: %w", err) } return nil } // CleanUp removes the record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, recordName, err := d.findZoneAndRecordName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("exoscale: %w", err) } zone, err := d.findExistingZone(ctx, zoneName) if err != nil { return fmt.Errorf("exoscale: %w", err) } if zone == nil { return fmt.Errorf("exoscale: zone %q not found", zoneName) } recordID, err := d.findExistingRecordID(ctx, zone.ID, recordName, info.Value) if err != nil { return err } if recordID == "" { return nil } op, err := d.client.DeleteDNSDomainRecord(ctx, zone.ID, recordID) if err != nil { return fmt.Errorf("exoscale: error while deleting DNS record: %w", err) } _, err = d.client.Wait(ctx, op, egoscale.OperationStateSuccess) if err != nil { return fmt.Errorf("exoscale: error while creating DNS record: %w", err) } return 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 } // findExistingZone Query Exoscale to find an existing zone for this name. // Returns nil result if no zone could be found. func (d *DNSProvider) findExistingZone(ctx context.Context, zoneName string) (*egoscale.DNSDomain, error) { zones, err := d.client.ListDNSDomains(ctx) if err != nil { return nil, fmt.Errorf("error while retrieving DNS zones: %w", err) } for _, zone := range zones.DNSDomains { if zone.UnicodeName == zoneName { return &zone, nil } } return nil, nil } // findExistingRecordID Query Exoscale to find an existing record for this name. // Returns empty result if no record could be found. func (d *DNSProvider) findExistingRecordID(ctx context.Context, zoneID egoscale.UUID, recordName, value string) (egoscale.UUID, error) { records, err := d.client.ListDNSDomainRecords(ctx, zoneID) if err != nil { return "", fmt.Errorf("error while retrieving DNS records: %w", err) } for _, record := range records.DNSDomainRecords { if record.Name == recordName && record.Type == egoscale.DNSDomainRecordTypeTXT && (record.Content == value || record.Content == strconv.Quote(value)) { return record.ID, nil } } return "", nil } // findZoneAndRecordName Extract DNS zone and DNS entry name. func (d *DNSProvider) findZoneAndRecordName(fqdn string) (string, string, error) { zone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", "", fmt.Errorf("could not find zone: %w", err) } zone = dns01.UnFqdn(zone) subDomain, err := dns01.ExtractSubDomain(fqdn, zone) if err != nil { return "", "", err } return zone, subDomain, nil } ================================================ FILE: providers/dns/exoscale/exoscale.toml ================================================ Name = "Exoscale" Description = '''''' URL = "https://www.exoscale.com/" Code = "exoscale" Since = "v0.4.0" Example = ''' EXOSCALE_API_KEY=abcdefghijklmnopqrstuvwx \ EXOSCALE_API_SECRET=xxxxxxx \ lego --dns exoscale -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] EXOSCALE_API_KEY = "API key" EXOSCALE_API_SECRET = "API secret" [Configuration.Additional] EXOSCALE_ENDPOINT = "API endpoint URL" EXOSCALE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" EXOSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" EXOSCALE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" EXOSCALE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://openapi-v2.exoscale.com/#endpoint-dns" GoClient = "https://github.com/exoscale/egoscale" ================================================ FILE: providers/dns/exoscale/exoscale_test.go ================================================ package exoscale import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPISecret, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvAPISecret: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvAPISecret: "", }, expected: "exoscale: some credentials information are missing: EXOSCALE_API_KEY,EXOSCALE_API_SECRET", }, { desc: "missing access key", envVars: map[string]string{ EnvAPIKey: "", EnvAPISecret: "456", }, expected: "exoscale: some credentials information are missing: EXOSCALE_API_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAPIKey: "123", EnvAPISecret: "", }, expected: "exoscale: some credentials information are missing: EXOSCALE_API_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string apiSecret string expected string }{ { desc: "success", apiKey: "123", apiSecret: "456", }, { desc: "missing credentials", expected: "exoscale: credentials missing", }, { desc: "missing api key", apiSecret: "456", expected: "exoscale: credentials missing", }, { desc: "missing secret key", apiKey: "123", expected: "exoscale: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.APISecret = test.apiSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_FindZoneAndRecordName(t *testing.T) { config := NewDefaultConfig() config.APIKey = "example@example.com" config.APISecret = "123" provider, err := NewDNSProviderConfig(config) require.NoError(t, err) type expected struct { zone string recordName string } testCases := []struct { desc string fqdn string expected expected }{ { desc: "Extract root record name", fqdn: "_acme-challenge.example.com.", expected: expected{ zone: "example.com", recordName: "_acme-challenge", }, }, { desc: "Extract sub record name", fqdn: "_acme-challenge.foo.example.com.", expected: expected{ zone: "example.com", recordName: "_acme-challenge.foo", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() zone, recordName, err := provider.findZoneAndRecordName(test.fqdn) require.NoError(t, err) assert.Equal(t, test.expected.zone, zone) assert.Equal(t, test.expected.recordName, recordName) }) } } 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) // Present Twice to handle create / update err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/f5xc/f5xc.go ================================================ // Package f5xc implements a DNS provider for solving the DNS-01 challenge using F5 XC. package f5xc import ( "context" "errors" "fmt" "net/http" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/f5xc/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "F5XC_" EnvToken = envNamespace + "API_TOKEN" EnvTenantName = envNamespace + "TENANT_NAME" EnvServer = envNamespace + "SERVER" EnvGroupName = envNamespace + "GROUP_NAME" 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 { APIToken string TenantName string Server string GroupName string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for F5 XC. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken, EnvTenantName, EnvGroupName) if err != nil { return nil, fmt.Errorf("f5xc: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvToken] config.TenantName = values[EnvTenantName] config.GroupName = values[EnvGroupName] config.Server = env.GetOrFile(EnvServer) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for F5 XC. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("f5xc: the configuration of the DNS provider is nil") } if config.GroupName == "" { return nil, errors.New("f5xc: missing group name") } client, err := internal.NewClient(config.APIToken, config.TenantName, config.Server) if err != nil { return nil, fmt.Errorf("f5xc: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("f5xc: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("f5xc: %w", err) } existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT") if err != nil { return fmt.Errorf("f5xc: get RR Set: %w", err) } // New RRSet. if existingRRSet == nil || existingRRSet.RRSet.TXTRecord == nil { rrSet := internal.RRSet{ Description: "lego", TTL: d.config.TTL, TXTRecord: &internal.TXTRecord{ Name: subDomain, Values: []string{info.Value}, }, } return d.waitFor(ctx, func() error { _, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, rrSet) if err != nil { return fmt.Errorf("create RR set: %w", err) } return nil }) } // Update RRSet. existingRRSet.RRSet.TXTRecord.Values = append(existingRRSet.RRSet.TXTRecord.Values, info.Value) return d.waitFor(ctx, func() error { _, err = d.client.ReplaceRRSet(ctx, dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT", existingRRSet.RRSet) if err != nil { return fmt.Errorf("replace RR set: %w", err) } return nil }) } func (d *DNSProvider) waitFor(ctx context.Context, operation func() error) error { err := wait.Retry(ctx, operation, backoff.WithBackOff(backoff.NewConstantBackOff(2*time.Second)), backoff.WithMaxElapsedTime(60*time.Second), ) if err != nil { return fmt.Errorf("f5xc: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("f5xc: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("f5xc: %w", err) } _, err = d.client.DeleteRRSet(context.Background(), dns01.UnFqdn(authZone), d.config.GroupName, subDomain, "TXT") if err != nil { return fmt.Errorf("f5xc: delete RR set: %w", err) } return 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 } ================================================ FILE: providers/dns/f5xc/f5xc.toml ================================================ Name = "F5 XC" Description = '''''' URL = "https://www.f5.com/products/distributed-cloud-services" Code = "f5xc" Since = "v4.23.0" Example = ''' F5XC_API_TOKEN="xxx" \ F5XC_TENANT_NAME="yyy" \ F5XC_GROUP_NAME="zzz" \ lego --dns f5xc -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] F5XC_API_TOKEN = "API token" F5XC_TENANT_NAME = "XC Tenant shortname" F5XC_GROUP_NAME = "Group name" [Configuration.Additional] F5XC_SERVER = "Server domain (Default: console.ves.volterra.io)" F5XC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" F5XC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" F5XC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" F5XC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset" Documentation = "https://my.f5.com/manage/s/article/K000147937" ================================================ FILE: providers/dns/f5xc/f5xc_test.go ================================================ package f5xc import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvToken, EnvTenantName, EnvServer, EnvGroupName, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "secret", EnvTenantName: "shortname", EnvGroupName: "group", }, }, { desc: "missing API token", envVars: map[string]string{ EnvToken: "", EnvTenantName: "shortname", EnvGroupName: "group", }, expected: "f5xc: some credentials information are missing: F5XC_API_TOKEN", }, { desc: "missing tenant name", envVars: map[string]string{ EnvToken: "secret", EnvTenantName: "", EnvGroupName: "group", }, expected: "f5xc: some credentials information are missing: F5XC_TENANT_NAME", }, { desc: "missing group name", envVars: map[string]string{ EnvToken: "secret", EnvTenantName: "shortname", EnvGroupName: "", }, expected: "f5xc: some credentials information are missing: F5XC_GROUP_NAME", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "f5xc: some credentials information are missing: F5XC_API_TOKEN,F5XC_TENANT_NAME,F5XC_GROUP_NAME", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string tenantName string groupName string expected string }{ { desc: "success", apiToken: "secret", tenantName: "shortname", groupName: "group", }, { desc: "missing API token", tenantName: "shortname", groupName: "group", expected: "f5xc: credentials missing", }, { desc: "missing tenant name", apiToken: "secret", groupName: "group", expected: "f5xc: missing tenant name", }, { desc: "missing group name", apiToken: "secret", tenantName: "shortname", expected: "f5xc: missing group name", }, { desc: "missing credentials", expected: "f5xc: missing group name", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken config.TenantName = test.tenantName config.GroupName = test.groupName p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/f5xc/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultServer = "console.ves.volterra.io" const authorizationHeader = "Authorization" // Client the F5 XC API client. type Client struct { apiToken string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiToken, tenantName, server string) (*Client, error) { if apiToken == "" { return nil, errors.New("credentials missing") } baseURL, err := createBaseURL(tenantName, server) if err != nil { return nil, err } return &Client{ apiToken: apiToken, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // CreateRRSet creates RRSet. // https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Create func (c *Client) CreateRRSet(ctx context.Context, dnsZoneName, groupName string, rrSet RRSet) (*APIRRSet, error) { endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName) req, err := newJSONRequest(ctx, http.MethodPost, endpoint, APIRRSet{ DNSZoneName: dnsZoneName, GroupName: groupName, RRSet: rrSet, }) if err != nil { return nil, err } result := &APIRRSet{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } // GetRRSet gets RRSets. // https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Get func (c *Client) GetRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string) (*APIRRSet, error) { endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := &APIRRSet{} err = c.do(req, result) if err != nil { usce := &APIError{} if errors.As(err, &usce) && usce.StatusCode == http.StatusNotFound { return nil, nil } return nil, err } return result, nil } // DeleteRRSet deletes RRSet. // https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Delete func (c *Client) DeleteRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string) (*APIRRSet, error) { endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return nil, err } result := &APIRRSet{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } // ReplaceRRSet replaces RRSet. // https://docs.cloud.f5.com/docs-v2/api/dns-zone-rrset#operation/ves.io.schema.dns_zone.rrset.CustomAPI.Replace func (c *Client) ReplaceRRSet(ctx context.Context, dnsZoneName, groupName, recordName, recordType string, rrSet RRSet) (*APIRRSet, error) { endpoint := c.baseURL.JoinPath("api", "config", "dns", "namespaces", "system", "dns_zones", dnsZoneName, "rrsets", groupName, recordName, recordType) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, APIRRSet{ DNSZoneName: dnsZoneName, GroupName: groupName, RRSet: rrSet, Type: recordType, }) if err != nil { return nil, err } result := &APIRRSet{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, "APIToken "+c.apiToken) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) apiErr := APIError{StatusCode: resp.StatusCode} err := json.Unmarshal(raw, &apiErr) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &apiErr } func createBaseURL(tenant, server string) (*url.URL, error) { if tenant == "" { return nil, errors.New("missing tenant name") } if server == "" { server = defaultServer } baseURL, err := url.Parse(fmt.Sprintf("https://%s.%s", tenant, server)) if err != nil { return nil, fmt.Errorf("parse base URL: %w", err) } return baseURL, nil } ================================================ FILE: providers/dns/f5xc/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret", "shortname", "") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("APIToken secret")) } func TestClient_CreateRRSet(t *testing.T) { client := mockBuilder(). Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", servermock.ResponseFromFixture("create.json"), servermock.CheckRequestJSONBody(`{"dns_zone_name":"example.com","group_name":"groupA","rrset":{"description":"lego","ttl":60,"txt_record":{"name":"wwww","values":["txt"]}}}`)). Build(t) rrSet := RRSet{ Description: "lego", TTL: 60, TXTRecord: &TXTRecord{ Name: "wwww", Values: []string{"txt"}, }, } result, err := client.CreateRRSet(t.Context(), "example.com", "groupA", rrSet) require.NoError(t, err) expected := &APIRRSet{ DNSZoneName: "string", GroupName: "string", RRSet: RRSet{ Description: "string", TXTRecord: &TXTRecord{ Name: "string", Values: []string{"string"}, }, }, } assert.Equal(t, expected, result) } func TestClient_CreateRRSet_error(t *testing.T) { client := mockBuilder(). Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) rrSet := RRSet{ Description: "lego", TTL: 60, TXTRecord: &TXTRecord{ Name: "wwww", Values: []string{"txt"}, }, } _, err := client.CreateRRSet(t.Context(), "example.com", "groupA", rrSet) require.Error(t, err) } func TestClient_GetRRSet(t *testing.T) { client := mockBuilder(). Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.ResponseFromFixture("get.json")). Build(t) result, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.NoError(t, err) expected := &APIRRSet{ DNSZoneName: "string", GroupName: "string", Namespace: "string", RecordName: "string", Type: "string", RRSet: RRSet{ Description: "string", TXTRecord: &TXTRecord{ Name: "string", Values: []string{"string"}, }, }, } assert.Equal(t, expected, result) } func TestClient_GetRRSet_not_found(t *testing.T) { client := mockBuilder(). Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.ResponseFromFixture("error_404.json").WithStatusCode(http.StatusNotFound)). Build(t) result, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.NoError(t, err) assert.Nil(t, result) } func TestClient_GetRRSet_error(t *testing.T) { client := mockBuilder(). Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.Error(t, err) } func TestClient_DeleteRRSet(t *testing.T) { client := mockBuilder(). Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.ResponseFromFixture("get.json")). Build(t) result, err := client.DeleteRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.NoError(t, err) expected := &APIRRSet{ DNSZoneName: "string", GroupName: "string", Namespace: "string", RecordName: "string", Type: "string", RRSet: RRSet{ Description: "string", TXTRecord: &TXTRecord{ Name: "string", Values: []string{"string"}, }, }, } assert.Equal(t, expected, result) } func TestClient_DeleteRRSet_error(t *testing.T) { client := mockBuilder(). Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.DeleteRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.Error(t, err) } func TestClient_ReplaceRRSet(t *testing.T) { client := mockBuilder(). Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.ResponseFromFixture("get.json"), servermock.CheckRequestJSONBody(`{"dns_zone_name":"example.com","group_name":"groupA","type":"TXT","rrset":{"description":"lego","ttl":60,"txt_record":{"name":"wwww","values":["txt"]}}}`)). Build(t) rrSet := RRSet{ Description: "lego", TTL: 60, TXTRecord: &TXTRecord{ Name: "wwww", Values: []string{"txt"}, }, } result, err := client.ReplaceRRSet(t.Context(), "example.com", "groupA", "www", "TXT", rrSet) require.NoError(t, err) expected := &APIRRSet{ DNSZoneName: "string", GroupName: "string", Namespace: "string", RecordName: "string", Type: "string", RRSet: RRSet{ Description: "string", TXTRecord: &TXTRecord{ Name: "string", Values: []string{"string"}, }, }, } assert.Equal(t, expected, result) } func TestClient_ReplaceRRSet_error(t *testing.T) { client := mockBuilder(). Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) rrSet := RRSet{ Description: "lego", TTL: 60, TXTRecord: &TXTRecord{ Name: "wwww", Values: []string{"txt"}, }, } _, err := client.ReplaceRRSet(t.Context(), "example.com", "groupA", "www", "TXT", rrSet) require.Error(t, err) } func Test_createBaseURL(t *testing.T) { testCases := []struct { desc string tenant string server string expected string }{ { desc: "only tenant", tenant: "foo", expected: "https://foo.console.ves.volterra.io", }, { desc: "custom server", tenant: "foo", server: "example.com", expected: "https://foo.example.com", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() baseURL, err := createBaseURL(test.tenant, test.server) require.NoError(t, err) assert.Equal(t, test.expected, baseURL.String()) }) } } func Test_createBaseURL_error(t *testing.T) { testCases := []struct { desc string tenant string server string expected string }{ { desc: "no tenant", tenant: "", expected: "missing tenant name", }, { desc: "invalid tenant", tenant: "%31", expected: `parse base URL: parse "https://%31.console.ves.volterra.io": invalid URL escape "%31"`, }, { desc: "invalid host", tenant: "foo", server: "192.168.0.%31", expected: `parse base URL: parse "https://foo.192.168.0.%31": invalid URL escape "%31"`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() _, err := createBaseURL(test.tenant, test.server) require.EqualError(t, err, test.expected) }) } } ================================================ FILE: providers/dns/f5xc/internal/fixtures/create.json ================================================ { "dns_zone_name": "string", "group_name": "string", "rrset": { "a_record": { "name": "string", "values": [ "string" ] }, "aaaa_record": { "name": "string", "values": [ "string" ] }, "afsdb_record": { "name": "string", "values": [ { "hostname": "string", "subtype": "NONE" } ] }, "alias_record": { "value": "string" }, "caa_record": { "name": "string", "values": [ { "flags": 0, "tag": "string", "value": "string" } ] }, "cds_record": { "name": "string", "values": [ { "ds_key_algorithm": "UNSPECIFIED", "key_tag": 0, "sha1_digest": { "digest": "stringstringstringstringstringstringstri" }, "sha256_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstri" }, "sha384_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" } } ] }, "cert_record": { "name": "string", "values": [ { "algorithm": "RESERVEDALGORITHM", "cert_key_tag": 0, "cert_type": "INVALIDCERTTYPE", "certificate": "string" } ] }, "cname_record": { "name": "string", "value": "string" }, "description": "string", "ds_record": { "name": "string", "values": [ { "ds_key_algorithm": "UNSPECIFIED", "key_tag": 0, "sha1_digest": { "digest": "stringstringstringstringstringstringstri" }, "sha256_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstri" }, "sha384_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" } } ] }, "eui48_record": { "name": "string", "value": "stringstringstrin" }, "eui64_record": { "name": "string", "value": "stringstringstringstrin" }, "lb_record": { "name": "string", "value": { "name": "string", "namespace": "string", "tenant": "string" } }, "loc_record": { "name": "string", "values": [ { "altitude": 0.1, "horizontal_precision": 0.1, "latitude_degree": 0, "latitude_hemisphere": "N", "latitude_minute": 0, "latitude_second": 0.1, "location_diameter": 0.1, "longitude_degree": 0, "longitude_hemisphere": "E", "longitude_minute": 0, "longitude_second": 0.1, "vertical_precision": 0.1 } ] }, "mx_record": { "name": "string", "values": [ { "domain": "string", "priority": 0 } ] }, "naptr_record": { "name": "string", "values": [ { "flags": "string", "order": 0, "preference": 0, "regexp": "string", "replacement": "string", "service": "string" } ] }, "ns_record": { "name": "string", "values": [ "string" ] }, "ptr_record": { "name": "string", "values": [ "string" ] }, "srv_record": { "name": "string", "values": [ { "port": 0, "priority": 0, "target": "string", "weight": 0 } ] }, "sshfp_record": { "name": "string", "values": [ { "algorithm": "UNSPECIFIEDALGORITHM", "sha1_fingerprint": { "fingerprint": "stringstringstringstringstringstringstri" }, "sha256_fingerprint": { "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri" } } ] }, "tlsa_record": { "name": "string", "values": [ { "certificate_association_data": "string", "certificate_usage": "CertificateAuthorityConstraint", "matching_type": "NoHash", "selector": "FullCertificate" } ] }, "ttl": 0, "txt_record": { "name": "string", "values": [ "string" ] } } } ================================================ FILE: providers/dns/f5xc/internal/fixtures/delete.json ================================================ { "dns_zone_name": "string", "group_name": "string", "namespace": "string", "record_name": "string", "rrset": { "a_record": { "name": "string", "values": [ "string" ] }, "aaaa_record": { "name": "string", "values": [ "string" ] }, "afsdb_record": { "name": "string", "values": [ { "hostname": "string", "subtype": "NONE" } ] }, "alias_record": { "value": "string" }, "caa_record": { "name": "string", "values": [ { "flags": 0, "tag": "string", "value": "string" } ] }, "cds_record": { "name": "string", "values": [ { "ds_key_algorithm": "UNSPECIFIED", "key_tag": 0, "sha1_digest": { "digest": "stringstringstringstringstringstringstri" }, "sha256_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstri" }, "sha384_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" } } ] }, "cert_record": { "name": "string", "values": [ { "algorithm": "RESERVEDALGORITHM", "cert_key_tag": 0, "cert_type": "INVALIDCERTTYPE", "certificate": "string" } ] }, "cname_record": { "name": "string", "value": "string" }, "description": "string", "ds_record": { "name": "string", "values": [ { "ds_key_algorithm": "UNSPECIFIED", "key_tag": 0, "sha1_digest": { "digest": "stringstringstringstringstringstringstri" }, "sha256_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstri" }, "sha384_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" } } ] }, "eui48_record": { "name": "string", "value": "stringstringstrin" }, "eui64_record": { "name": "string", "value": "stringstringstringstrin" }, "lb_record": { "name": "string", "value": { "name": "string", "namespace": "string", "tenant": "string" } }, "loc_record": { "name": "string", "values": [ { "altitude": 0.1, "horizontal_precision": 0.1, "latitude_degree": 0, "latitude_hemisphere": "N", "latitude_minute": 0, "latitude_second": 0.1, "location_diameter": 0.1, "longitude_degree": 0, "longitude_hemisphere": "E", "longitude_minute": 0, "longitude_second": 0.1, "vertical_precision": 0.1 } ] }, "mx_record": { "name": "string", "values": [ { "domain": "string", "priority": 0 } ] }, "naptr_record": { "name": "string", "values": [ { "flags": "string", "order": 0, "preference": 0, "regexp": "string", "replacement": "string", "service": "string" } ] }, "ns_record": { "name": "string", "values": [ "string" ] }, "ptr_record": { "name": "string", "values": [ "string" ] }, "srv_record": { "name": "string", "values": [ { "port": 0, "priority": 0, "target": "string", "weight": 0 } ] }, "sshfp_record": { "name": "string", "values": [ { "algorithm": "UNSPECIFIEDALGORITHM", "sha1_fingerprint": { "fingerprint": "stringstringstringstringstringstringstri" }, "sha256_fingerprint": { "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri" } } ] }, "tlsa_record": { "name": "string", "values": [ { "certificate_association_data": "string", "certificate_usage": "CertificateAuthorityConstraint", "matching_type": "NoHash", "selector": "FullCertificate" } ] }, "ttl": 0, "txt_record": { "name": "string", "values": [ "string" ] } }, "type": "string" } ================================================ FILE: providers/dns/f5xc/internal/fixtures/error_404.json ================================================ { "code": 5, "details": [], "message": "the requested resource record was not found: (group,name,type) (acme-records,_acme-challenge,TXT)" } ================================================ FILE: providers/dns/f5xc/internal/fixtures/error_503.json ================================================ { "code": 14, "details": [], "message": "Previous DNS zone change is pending. Try again later" } ================================================ FILE: providers/dns/f5xc/internal/fixtures/get.json ================================================ { "dns_zone_name": "string", "group_name": "string", "namespace": "string", "record_name": "string", "rrset": { "a_record": { "name": "string", "values": [ "string" ] }, "aaaa_record": { "name": "string", "values": [ "string" ] }, "afsdb_record": { "name": "string", "values": [ { "hostname": "string", "subtype": "NONE" } ] }, "alias_record": { "value": "string" }, "caa_record": { "name": "string", "values": [ { "flags": 0, "tag": "string", "value": "string" } ] }, "cds_record": { "name": "string", "values": [ { "ds_key_algorithm": "UNSPECIFIED", "key_tag": 0, "sha1_digest": { "digest": "stringstringstringstringstringstringstri" }, "sha256_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstri" }, "sha384_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" } } ] }, "cert_record": { "name": "string", "values": [ { "algorithm": "RESERVEDALGORITHM", "cert_key_tag": 0, "cert_type": "INVALIDCERTTYPE", "certificate": "string" } ] }, "cname_record": { "name": "string", "value": "string" }, "description": "string", "ds_record": { "name": "string", "values": [ { "ds_key_algorithm": "UNSPECIFIED", "key_tag": 0, "sha1_digest": { "digest": "stringstringstringstringstringstringstri" }, "sha256_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstri" }, "sha384_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" } } ] }, "eui48_record": { "name": "string", "value": "stringstringstrin" }, "eui64_record": { "name": "string", "value": "stringstringstringstrin" }, "lb_record": { "name": "string", "value": { "name": "string", "namespace": "string", "tenant": "string" } }, "loc_record": { "name": "string", "values": [ { "altitude": 0.1, "horizontal_precision": 0.1, "latitude_degree": 0, "latitude_hemisphere": "N", "latitude_minute": 0, "latitude_second": 0.1, "location_diameter": 0.1, "longitude_degree": 0, "longitude_hemisphere": "E", "longitude_minute": 0, "longitude_second": 0.1, "vertical_precision": 0.1 } ] }, "mx_record": { "name": "string", "values": [ { "domain": "string", "priority": 0 } ] }, "naptr_record": { "name": "string", "values": [ { "flags": "string", "order": 0, "preference": 0, "regexp": "string", "replacement": "string", "service": "string" } ] }, "ns_record": { "name": "string", "values": [ "string" ] }, "ptr_record": { "name": "string", "values": [ "string" ] }, "srv_record": { "name": "string", "values": [ { "port": 0, "priority": 0, "target": "string", "weight": 0 } ] }, "sshfp_record": { "name": "string", "values": [ { "algorithm": "UNSPECIFIEDALGORITHM", "sha1_fingerprint": { "fingerprint": "stringstringstringstringstringstringstri" }, "sha256_fingerprint": { "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri" } } ] }, "tlsa_record": { "name": "string", "values": [ { "certificate_association_data": "string", "certificate_usage": "CertificateAuthorityConstraint", "matching_type": "NoHash", "selector": "FullCertificate" } ] }, "ttl": 0, "txt_record": { "name": "string", "values": [ "string" ] } }, "type": "string" } ================================================ FILE: providers/dns/f5xc/internal/fixtures/replace.json ================================================ { "dns_zone_name": "string", "group_name": "string", "record_name": "string", "rrset": { "a_record": { "name": "string", "values": [ "string" ] }, "aaaa_record": { "name": "string", "values": [ "string" ] }, "afsdb_record": { "name": "string", "values": [ { "hostname": "string", "subtype": "NONE" } ] }, "alias_record": { "value": "string" }, "caa_record": { "name": "string", "values": [ { "flags": 0, "tag": "string", "value": "string" } ] }, "cds_record": { "name": "string", "values": [ { "ds_key_algorithm": "UNSPECIFIED", "key_tag": 0, "sha1_digest": { "digest": "stringstringstringstringstringstringstri" }, "sha256_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstri" }, "sha384_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" } } ] }, "cert_record": { "name": "string", "values": [ { "algorithm": "RESERVEDALGORITHM", "cert_key_tag": 0, "cert_type": "INVALIDCERTTYPE", "certificate": "string" } ] }, "cname_record": { "name": "string", "value": "string" }, "description": "string", "ds_record": { "name": "string", "values": [ { "ds_key_algorithm": "UNSPECIFIED", "key_tag": 0, "sha1_digest": { "digest": "stringstringstringstringstringstringstri" }, "sha256_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstri" }, "sha384_digest": { "digest": "stringstringstringstringstringstringstringstringstringstringstringstringstringstringstringstring" } } ] }, "eui48_record": { "name": "string", "value": "stringstringstrin" }, "eui64_record": { "name": "string", "value": "stringstringstringstrin" }, "lb_record": { "name": "string", "value": { "name": "string", "namespace": "string", "tenant": "string" } }, "loc_record": { "name": "string", "values": [ { "altitude": 0.1, "horizontal_precision": 0.1, "latitude_degree": 0, "latitude_hemisphere": "N", "latitude_minute": 0, "latitude_second": 0.1, "location_diameter": 0.1, "longitude_degree": 0, "longitude_hemisphere": "E", "longitude_minute": 0, "longitude_second": 0.1, "vertical_precision": 0.1 } ] }, "mx_record": { "name": "string", "values": [ { "domain": "string", "priority": 0 } ] }, "naptr_record": { "name": "string", "values": [ { "flags": "string", "order": 0, "preference": 0, "regexp": "string", "replacement": "string", "service": "string" } ] }, "ns_record": { "name": "string", "values": [ "string" ] }, "ptr_record": { "name": "string", "values": [ "string" ] }, "srv_record": { "name": "string", "values": [ { "port": 0, "priority": 0, "target": "string", "weight": 0 } ] }, "sshfp_record": { "name": "string", "values": [ { "algorithm": "UNSPECIFIEDALGORITHM", "sha1_fingerprint": { "fingerprint": "stringstringstringstringstringstringstri" }, "sha256_fingerprint": { "fingerprint": "stringstringstringstringstringstringstringstringstringstringstri" } } ] }, "tlsa_record": { "name": "string", "values": [ { "certificate_association_data": "string", "certificate_usage": "CertificateAuthorityConstraint", "matching_type": "NoHash", "selector": "FullCertificate" } ] }, "ttl": 0, "txt_record": { "name": "string", "values": [ "string" ] } }, "type": "string" } ================================================ FILE: providers/dns/f5xc/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type APIError struct { StatusCode int `json:"-"` Code int `json:"code"` Details []string `json:"details"` Message string `json:"message"` } func (a *APIError) Error() string { var details string if len(a.Details) > 0 { details = " " + strings.Join(a.Details, ", ") } return fmt.Sprintf("code: %d, message: %s%s", a.Code, a.Message, details) } type APIRRSet struct { DNSZoneName string `json:"dns_zone_name,omitempty"` GroupName string `json:"group_name,omitempty"` Namespace string `json:"namespace,omitempty"` RecordName string `json:"record_name,omitempty"` Type string `json:"type,omitempty"` RRSet RRSet `json:"rrset"` } type RRSetRequest struct { DNSZoneName string `json:"dns_zone_name,omitempty"` GroupName string `json:"group_name,omitempty"` RRSet RRSet `json:"rrset"` } type RRSet struct { Description string `json:"description,omitempty"` TTL int `json:"ttl,omitempty"` TXTRecord *TXTRecord `json:"txt_record,omitempty"` } type TXTRecord struct { Name string `json:"name,omitempty"` Values []string `json:"values,omitempty"` } ================================================ FILE: providers/dns/freemyip/freemyip.go ================================================ // Package freemyip implements a DNS provider for solving the DNS-01 challenge using freemyip.com. package freemyip import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/freemyip" ) // Environment variables names. const ( envNamespace = "FREEMYIP_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *freemyip.Client } // NewDNSProvider returns a DNSProvider instance configured for freemyip.com. // Credentials must be passed in the environment variable: FREEMYIP_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("freemyip: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for freemyip.com. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("freemyip: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("freemyip: missing credentials") } client := freemyip.New(config.Token, true) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, freemyip.RootDomain) if err != nil { return fmt.Errorf("freemyip: %w", err) } _, err = d.client.EditTXTRecord(context.Background(), subDomain, info.Value) if err != nil { return fmt.Errorf("freemyip: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, freemyip.RootDomain) if err != nil { return fmt.Errorf("freemyip: %w", err) } _, err = d.client.DeleteTXTRecord(context.Background(), subDomain) if err != nil { return fmt.Errorf("freemyip: %w", err) } return nil } ================================================ FILE: providers/dns/freemyip/freemyip.toml ================================================ Name = "freemyip.com" Description = '''''' URL = "https://freemyip.com/" Code = "freemyip" Since = "v4.5.0" Example = ''' FREEMYIP_TOKEN=xxxxxx \ lego --dns freemyip -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] FREEMYIP_TOKEN = "Account token" [Configuration.Additional] FREEMYIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" FREEMYIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" FREEMYIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" FREEMYIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" FREEMYIP_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" [Links] API = "https://freemyip.com/help" ================================================ FILE: providers/dns/freemyip/freemyip_test.go ================================================ package freemyip import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvToken: "", }, expected: "freemyip: some credentials information are missing: FREEMYIP_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "123", }, { desc: "missing credentials", expected: "freemyip: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/gandi/gandi.go ================================================ // Package gandi implements a DNS provider for solving the DNS-01 challenge using Gandi DNS. package gandi import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/gandi/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "GANDI_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 40*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 60*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second), }, } } // inProgressInfo contains information about an in-progress challenge. type inProgressInfo struct { zoneID int // zoneID of gandi zone to restore in CleanUp newZoneID int // zoneID of temporary gandi zone containing TXT record authZone string // the domain name registered at gandi with trailing "." } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client inProgressFQDNs map[string]inProgressInfo inProgressAuthZones map[string]struct{} inProgressMu sync.Mutex // findZoneByFqdn determines the DNS zone of a FQDN. // It is overridden during tests. // only for testing purpose. findZoneByFqdn func(fqdn string) (string, error) } // NewDNSProvider returns a DNSProvider instance configured for Gandi. // Credentials must be passed in the environment variable: GANDI_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("gandi: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Gandi. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("gandi: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("gandi: no API Key given") } client := internal.NewClient(config.APIKey) if config.BaseURL != "" { client.BaseURL = config.BaseURL } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, inProgressFQDNs: make(map[string]inProgressInfo), inProgressAuthZones: make(map[string]struct{}), findZoneByFqdn: dns01.FindZoneByFqdn, }, nil } // Present creates a TXT record using the specified parameters. It // does this by creating and activating a new temporary Gandi DNS // zone. This new zone contains the TXT record. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) if d.config.TTL < minTTL { d.config.TTL = minTTL // 300 is gandi minimum value for ttl } // find authZone and Gandi zone_id for fqdn authZone, err := d.findZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("gandi: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() zoneID, err := d.client.GetZoneID(ctx, authZone) if err != nil { return fmt.Errorf("gandi: %w", err) } // determine name of TXT record subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("gandi: %w", err) } // acquire lock and check there is not a challenge already in // progress for this value of authZone d.inProgressMu.Lock() defer d.inProgressMu.Unlock() if _, ok := d.inProgressAuthZones[authZone]; ok { return fmt.Errorf("gandi: challenge already in progress for authZone %s", authZone) } // perform API actions to create and activate new gandi zone // containing the required TXT record newZoneName := fmt.Sprintf("%s [ACME Challenge %s]", dns01.UnFqdn(authZone), time.Now().Format(time.RFC822Z)) newZoneID, err := d.client.CloneZone(ctx, zoneID, newZoneName) if err != nil { return err } newZoneVersion, err := d.client.NewZoneVersion(ctx, newZoneID) if err != nil { return fmt.Errorf("gandi: %w", err) } err = d.client.AddTXTRecord(ctx, newZoneID, newZoneVersion, subDomain, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("gandi: %w", err) } err = d.client.SetZoneVersion(ctx, newZoneID, newZoneVersion) if err != nil { return fmt.Errorf("gandi: %w", err) } err = d.client.SetZone(ctx, authZone, newZoneID) if err != nil { return fmt.Errorf("gandi: %w", err) } // save data necessary for CleanUp d.inProgressFQDNs[info.EffectiveFQDN] = inProgressInfo{ zoneID: zoneID, newZoneID: newZoneID, authZone: authZone, } d.inProgressAuthZones[authZone] = struct{}{} return nil } // CleanUp removes the TXT record matching the specified // parameters. It does this by restoring the old Gandi DNS zone and // removing the temporary one created by Present. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // acquire lock and retrieve zoneID, newZoneID and authZone d.inProgressMu.Lock() defer d.inProgressMu.Unlock() if _, ok := d.inProgressFQDNs[info.EffectiveFQDN]; !ok { // if there is no cleanup information then just return return nil } zoneID := d.inProgressFQDNs[info.EffectiveFQDN].zoneID newZoneID := d.inProgressFQDNs[info.EffectiveFQDN].newZoneID authZone := d.inProgressFQDNs[info.EffectiveFQDN].authZone delete(d.inProgressFQDNs, info.EffectiveFQDN) delete(d.inProgressAuthZones, authZone) ctx := context.Background() // perform API actions to restore old gandi zone for authZone err := d.client.SetZone(ctx, authZone, zoneID) if err != nil { return fmt.Errorf("gandi: %w", err) } return d.client.DeleteZone(ctx, newZoneID) } // Timeout returns the values (40*time.Minute, 60*time.Second) which // are used by the acme package as timeout and check interval values // when checking for DNS record propagation with Gandi. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } ================================================ FILE: providers/dns/gandi/gandi.toml ================================================ Name = "Gandi" Description = """""" URL = "https://www.gandi.net" Code = "gandi" Since = "v0.3.0" Example = ''' GANDI_API_KEY=abcdefghijklmnopqrstuvwx \ lego --dns gandi -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GANDI_API_KEY = "API key" [Configuration.Additional] GANDI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" GANDI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2400)" GANDI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" GANDI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://doc.rpc.gandi.net/index.html" ================================================ FILE: providers/dns/gandi/gandi_mock_test.go ================================================ package gandi // CleanUp Request->Response 1 (setZone). const cleanupSetZoneRequestMock = ` domain.zone.set 123412341234123412341234 example.com. 1234567 ` // CleanUp Request->Response 1 (setZone). const cleanupSetZoneResponseMock = ` date_updated 20160216T16:24:38 date_delete 20170331T16:04:06 is_premium 0 date_hold_begin 20170215T02:04:06 date_registry_end 20170215T02:04:06 authinfo_expiration_date 20161211T21:31:20 contacts owner handle LEGO-GANDI id 111111 admin handle LEGO-GANDI id 111111 bill handle LEGO-GANDI id 111111 tech handle LEGO-GANDI id 111111 reseller nameservers a.dns.gandi.net b.dns.gandi.net c.dns.gandi.net date_restore_end 20170501T02:04:06 id 2222222 authinfo ABCDABCDAB status clientTransferProhibited serverTransferProhibited tags date_hold_end 20170401T02:04:06 services gandidns gandimail date_pending_delete_end 20170506T02:04:06 zone_id 1234567 date_renew_begin 20120101T00:00:00 fqdn example.com autorenew date_registry_creation 20150215T02:04:06 tld org date_created 20150215T03:04:06 ` // CleanUp Request->Response 2 (deleteZone). const cleanupDeleteZoneRequestMock = ` domain.zone.delete 123412341234123412341234 7654321 ` // CleanUp Request->Response 2 (deleteZone). const cleanupDeleteZoneResponseMock = ` 1 ` // Present Request->Response 1 (getZoneID). const presentGetZoneIDRequestMock = ` domain.info 123412341234123412341234 example.com. ` // Present Request->Response 1 (getZoneID). const presentGetZoneIDResponseMock = ` date_updated 20160216T16:14:23 date_delete 20170331T16:04:06 is_premium 0 date_hold_begin 20170215T02:04:06 date_registry_end 20170215T02:04:06 authinfo_expiration_date 20161211T21:31:20 contacts owner handle LEGO-GANDI id 111111 admin handle LEGO-GANDI id 111111 bill handle LEGO-GANDI id 111111 tech handle LEGO-GANDI id 111111 reseller nameservers a.dns.gandi.net b.dns.gandi.net c.dns.gandi.net date_restore_end 20170501T02:04:06 id 2222222 authinfo ABCDABCDAB status clientTransferProhibited serverTransferProhibited tags date_hold_end 20170401T02:04:06 services gandidns gandimail date_pending_delete_end 20170506T02:04:06 zone_id 1234567 date_renew_begin 20120101T00:00:00 fqdn example.com autorenew date_registry_creation 20150215T02:04:06 tld org date_created 20150215T03:04:06 ` // Present Request->Response 2 (cloneZone). const presentCloneZoneRequestMock = ` domain.zone.clone 123412341234123412341234 1234567 0 name example.com [ACME Challenge 01 Jan 16 00:00 +0000] ` // Present Request->Response 2 (cloneZone). const presentCloneZoneResponseMock = ` name example.com [ACME Challenge 01 Jan 16 00:00 +0000] versions 1 date_updated 20160216T16:24:29 id 7654321 owner LEGO-GANDI version 1 domains 0 public 0 ` // Present Request->Response 3 (newZoneVersion). const presentNewZoneVersionRequestMock = ` domain.zone.version.new 123412341234123412341234 7654321 ` // Present Request->Response 3 (newZoneVersion). const presentNewZoneVersionResponseMock = ` 2 ` // Present Request->Response 4 (addTXTRecord). const presentAddTXTRecordRequestMock = ` domain.zone.record.add 123412341234123412341234 7654321 2 type TXT name _acme-challenge.abc.def value ezRpBPY8wH8djMLYjX2uCKPwiKDkFZ1SFMJ6ZXGlHrQ ttl 300 ` // Present Request->Response 4 (addTXTRecord). const presentAddTXTRecordResponseMock = ` name _acme-challenge.abc.def type TXT id 333333333 value "ezRpBPY8wH8djMLYjX2uCKPwiKDkFZ1SFMJ6ZXGlHrQ" ttl 300 ` // Present Request->Response 5 (setZoneVersion). const presentSetZoneVersionRequestMock = ` domain.zone.version.set 123412341234123412341234 7654321 2 ` // Present Request->Response 5 (setZoneVersion). const presentSetZoneVersionResponseMock = ` 1 ` // Present Request->Response 6 (setZone). const presentSetZoneRequestMock = ` domain.zone.set 123412341234123412341234 example.com. 7654321 ` // Present Request->Response 6 (setZone). const presentSetZoneResponseMock = ` date_updated 20160216T16:14:23 date_delete 20170331T16:04:06 is_premium 0 date_hold_begin 20170215T02:04:06 date_registry_end 20170215T02:04:06 authinfo_expiration_date 20161211T21:31:20 contacts owner handle LEGO-GANDI id 111111 admin handle LEGO-GANDI id 111111 bill handle LEGO-GANDI id 111111 tech handle LEGO-GANDI id 111111 reseller nameservers a.dns.gandi.net b.dns.gandi.net c.dns.gandi.net date_restore_end 20170501T02:04:06 id 2222222 authinfo ABCDABCDAB status clientTransferProhibited serverTransferProhibited tags date_hold_end 20170401T02:04:06 services gandidns gandimail date_pending_delete_end 20170506T02:04:06 zone_id 7654321 date_renew_begin 20120101T00:00:00 fqdn example.com autorenew date_registry_creation 20150215T02:04:06 tld org date_created 20150215T03:04:06 ` ================================================ FILE: providers/dns/gandi/gandi_test.go ================================================ package gandi import ( "io" "net/http" "net/http/httptest" "regexp" "strings" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAPIKey) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "gandi: some credentials information are missing: GANDI_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.inProgressFQDNs) require.NotNil(t, p.inProgressAuthZones) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "123", }, { desc: "missing credentials", expected: "gandi: no API Key given", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.inProgressFQDNs) require.NotNil(t, p.inProgressAuthZones) } else { require.EqualError(t, err, test.expected) } }) } } // TestDNSProvider runs Present and CleanUp against a fake Gandi RPC // Server, whose responses are predetermined for particular requests. func TestDNSProvider(t *testing.T) { // serverResponses is the XML-RPC Request->Response map used by the // fake RPC server. It was generated by recording a real RPC session // which resulted in the successful issue of a cert, and then // anonymizing the RPC data. serverResponses := map[string]string{ // Present Request->Response 1 (getZoneID) presentGetZoneIDRequestMock: presentGetZoneIDResponseMock, // Present Request->Response 2 (cloneZone) presentCloneZoneRequestMock: presentCloneZoneResponseMock, // Present Request->Response 3 (newZoneVersion) presentNewZoneVersionRequestMock: presentNewZoneVersionResponseMock, // Present Request->Response 4 (addTXTRecord) presentAddTXTRecordRequestMock: presentAddTXTRecordResponseMock, // Present Request->Response 5 (setZoneVersion) presentSetZoneVersionRequestMock: presentSetZoneVersionResponseMock, // Present Request->Response 6 (setZone) presentSetZoneRequestMock: presentSetZoneResponseMock, // CleanUp Request->Response 1 (setZone) cleanupSetZoneRequestMock: cleanupSetZoneResponseMock, // CleanUp Request->Response 2 (deleteZone) cleanupDeleteZoneRequestMock: cleanupDeleteZoneResponseMock, } regexpDate := regexp.MustCompile(`\[ACME Challenge [^\]:]*:[^\]]*\]`) provider := servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.BaseURL = server.URL + "/" config.HTTPClient = server.Client() config.APIKey = "123412341234123412341234" return NewDNSProviderConfig(config) }, servermock.CheckHeader().WithContentType("text/xml"), ). Route("POST /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { require.Equal(t, "text/xml", req.Header.Get("Content-Type"), "invalid content type") body, errS := io.ReadAll(req.Body) require.NoError(t, errS) body = regexpDate.ReplaceAllLiteral(body, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`)) resp, ok := serverResponses[string(body)] require.Truef(t, ok, "Server response for request not found: %s", string(body)) _, errS = io.Copy(rw, strings.NewReader(resp)) require.NoError(t, errS) })). Build(t) fakeKeyAuth := "XXXX" // define function to override findZoneByFqdn with fakeFindZoneByFqdn := func(fqdn string) (string, error) { return "example.com.", nil } // override findZoneByFqdn function savedFindZoneByFqdn := provider.findZoneByFqdn t.Cleanup(func() { provider.findZoneByFqdn = savedFindZoneByFqdn }) provider.findZoneByFqdn = fakeFindZoneByFqdn // run Present err := provider.Present("abc.def.example.com", "", fakeKeyAuth) require.NoError(t, err) // run CleanUp err = provider.CleanUp("abc.def.example.com", "", fakeKeyAuth) require.NoError(t, err) } ================================================ FILE: providers/dns/gandi/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/xml" "errors" "fmt" "io" "net/http" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // defaultBaseURL Gandi XML-RPC endpoint used by Present and CleanUp. const defaultBaseURL = "https://rpc.gandi.net/xmlrpc/" // Client the Gandi API client. type Client struct { apiKey string BaseURL string HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(apiKey string) *Client { return &Client{ apiKey: apiKey, BaseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) GetZoneID(ctx context.Context, domain string) (int, error) { call := &methodCall{ MethodName: "domain.info", Params: []param{ paramString{Value: c.apiKey}, paramString{Value: domain}, }, } resp := &responseStruct{} err := c.rpcCall(ctx, call, resp) if err != nil { return 0, err } var zoneID int for _, member := range resp.StructMembers { if member.Name == "zone_id" { zoneID = member.ValueInt } } if zoneID == 0 { return 0, fmt.Errorf("could not find zone_id for %s", domain) } return zoneID, nil } func (c *Client) CloneZone(ctx context.Context, zoneID int, name string) (int, error) { call := &methodCall{ MethodName: "domain.zone.clone", Params: []param{ paramString{Value: c.apiKey}, paramInt{Value: zoneID}, paramInt{Value: 0}, paramStruct{ StructMembers: []structMember{ structMemberString{ Name: "name", Value: name, }, }, }, }, } resp := &responseStruct{} err := c.rpcCall(ctx, call, resp) if err != nil { return 0, err } var newZoneID int for _, member := range resp.StructMembers { if member.Name == "id" { newZoneID = member.ValueInt } } if newZoneID == 0 { return 0, errors.New("could not determine cloned zone_id") } return newZoneID, nil } func (c *Client) NewZoneVersion(ctx context.Context, zoneID int) (int, error) { call := &methodCall{ MethodName: "domain.zone.version.new", Params: []param{ paramString{Value: c.apiKey}, paramInt{Value: zoneID}, }, } resp := &responseInt{} err := c.rpcCall(ctx, call, resp) if err != nil { return 0, err } if resp.Value == 0 { return 0, errors.New("could not create new zone version") } return resp.Value, nil } func (c *Client) AddTXTRecord(ctx context.Context, zoneID, version int, name, value string, ttl int) error { call := &methodCall{ MethodName: "domain.zone.record.add", Params: []param{ paramString{Value: c.apiKey}, paramInt{Value: zoneID}, paramInt{Value: version}, paramStruct{ StructMembers: []structMember{ structMemberString{ Name: "type", Value: "TXT", }, structMemberString{ Name: "name", Value: name, }, structMemberString{ Name: "value", Value: value, }, structMemberInt{ Name: "ttl", Value: ttl, }, }, }, }, } resp := &responseStruct{} return c.rpcCall(ctx, call, resp) } func (c *Client) SetZoneVersion(ctx context.Context, zoneID, version int) error { call := &methodCall{ MethodName: "domain.zone.version.set", Params: []param{ paramString{Value: c.apiKey}, paramInt{Value: zoneID}, paramInt{Value: version}, }, } resp := &responseBool{} err := c.rpcCall(ctx, call, resp) if err != nil { return err } if !resp.Value { return errors.New("could not set zone version") } return nil } func (c *Client) SetZone(ctx context.Context, domain string, zoneID int) error { call := &methodCall{ MethodName: "domain.zone.set", Params: []param{ paramString{Value: c.apiKey}, paramString{Value: domain}, paramInt{Value: zoneID}, }, } resp := &responseStruct{} err := c.rpcCall(ctx, call, resp) if err != nil { return err } var respZoneID int for _, member := range resp.StructMembers { if member.Name == "zone_id" { respZoneID = member.ValueInt } } if respZoneID != zoneID { return fmt.Errorf("could not set new zone_id for %s", domain) } return nil } func (c *Client) DeleteZone(ctx context.Context, zoneID int) error { call := &methodCall{ MethodName: "domain.zone.delete", Params: []param{ paramString{Value: c.apiKey}, paramInt{Value: zoneID}, }, } resp := &responseBool{} err := c.rpcCall(ctx, call, resp) if err != nil { return err } if !resp.Value { return errors.New("could not delete zone_id") } return nil } // rpcCall makes an XML-RPC call to Gandi's RPC endpoint by marshaling the data given in the call argument to XML // and sending that via HTTP Post to Gandi. // The response is then unmarshalled into the resp argument. func (c *Client) rpcCall(ctx context.Context, call *methodCall, result response) error { req, err := newXMLRequest(ctx, c.BaseURL, call) if err != nil { return err } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = xml.Unmarshal(raw, result) if err != nil { return fmt.Errorf("unmarshal error: %w", err) } if result.faultCode() != 0 { return RPCError{ FaultCode: result.faultCode(), FaultString: result.faultString(), } } return nil } func newXMLRequest(ctx context.Context, endpoint string, payload *methodCall) (*http.Request, error) { body := new(bytes.Buffer) body.WriteString(xml.Header) encoder := xml.NewEncoder(body) encoder.Indent("", " ") err := encoder.Encode(payload) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "text/xml") return req, nil } ================================================ FILE: providers/dns/gandi/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.BaseURL = server.URL client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithContentType("text/xml"), ) } func TestClient_GetZoneID(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("get_zone_id.xml"), servermock.CheckRequestBodyFromFixture("get_zone_id-request.xml").IgnoreWhitespace()). Build(t) zoneID, err := client.GetZoneID(t.Context(), "example.com") require.NoError(t, err) assert.Equal(t, 1, zoneID) } func TestClient_CloneZone(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("clone_zone.xml"), servermock.CheckRequestBodyFromFixture("clone_zone-request.xml").IgnoreWhitespace()). Build(t) zoneID, err := client.CloneZone(t.Context(), 6, "foo") require.NoError(t, err) assert.Equal(t, 1, zoneID) } func TestClient_NewZoneVersion(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("new_zone_version.xml"), servermock.CheckRequestBodyFromFixture("new_zone_version-request.xml").IgnoreWhitespace()). Build(t) zoneID, err := client.NewZoneVersion(t.Context(), 6) require.NoError(t, err) assert.Equal(t, 1, zoneID) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("empty.xml"), servermock.CheckRequestBodyFromFixture("add_txt_record-request.xml").IgnoreWhitespace()). Build(t) err := client.AddTXTRecord(t.Context(), 1, 123, "foo", "content", 120) require.NoError(t, err) } func TestClient_SetZoneVersion(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("set_zone_version.xml"), servermock.CheckRequestBodyFromFixture("set_zone_version-request.xml").IgnoreWhitespace()). Build(t) err := client.SetZoneVersion(t.Context(), 1, 123) require.NoError(t, err) } func TestClient_SetZone(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("set_zone.xml"), servermock.CheckRequestBodyFromFixture("set_zone-request.xml").IgnoreWhitespace()). Build(t) err := client.SetZone(t.Context(), "example.com", 1) require.NoError(t, err) } func TestClient_DeleteZone(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("delete_zone.xml"), servermock.CheckRequestBodyFromFixture("delete_zone-request.xml").IgnoreWhitespace()). Build(t) err := client.DeleteZone(t.Context(), 1) require.NoError(t, err) } ================================================ FILE: providers/dns/gandi/internal/fixtures/add_txt_record-request.xml ================================================ domain.zone.record.add secret 1 123 type TXT name foo value content ttl 120 ================================================ FILE: providers/dns/gandi/internal/fixtures/clone_zone-request.xml ================================================ domain.zone.clone secret 6 0 name foo ================================================ FILE: providers/dns/gandi/internal/fixtures/clone_zone.xml ================================================ id 1 foo 2 ================================================ FILE: providers/dns/gandi/internal/fixtures/delete_zone-request.xml ================================================ domain.zone.delete secret 1 ================================================ FILE: providers/dns/gandi/internal/fixtures/delete_zone.xml ================================================ true ================================================ FILE: providers/dns/gandi/internal/fixtures/empty.xml ================================================ ================================================ FILE: providers/dns/gandi/internal/fixtures/get_zone_id-request.xml ================================================ domain.info secret example.com ================================================ FILE: providers/dns/gandi/internal/fixtures/get_zone_id.xml ================================================ zone_id 1 foo 2 ================================================ FILE: providers/dns/gandi/internal/fixtures/new_zone_version-request.xml ================================================ domain.zone.version.new secret 6 ================================================ FILE: providers/dns/gandi/internal/fixtures/new_zone_version.xml ================================================ 1 ================================================ FILE: providers/dns/gandi/internal/fixtures/set_zone-request.xml ================================================ domain.zone.set secret example.com 1 ================================================ FILE: providers/dns/gandi/internal/fixtures/set_zone.xml ================================================ zone_id 1 foo 2 ================================================ FILE: providers/dns/gandi/internal/fixtures/set_zone_version-request.xml ================================================ domain.zone.version.set secret 1 123 ================================================ FILE: providers/dns/gandi/internal/fixtures/set_zone_version.xml ================================================ true ================================================ FILE: providers/dns/gandi/internal/types.go ================================================ package internal import ( "encoding/xml" "fmt" ) // types for XML-RPC method calls and parameters type param interface { param() } type paramString struct { XMLName xml.Name `xml:"param"` Value string `xml:"value>string"` } type paramInt struct { XMLName xml.Name `xml:"param"` Value int `xml:"value>int"` } type structMember interface { structMember() } type structMemberString struct { Name string `xml:"name"` Value string `xml:"value>string"` } type structMemberInt struct { Name string `xml:"name"` Value int `xml:"value>int"` } type paramStruct struct { XMLName xml.Name `xml:"param"` StructMembers []structMember `xml:"value>struct>member"` } func (p paramString) param() {} func (p paramInt) param() {} func (m structMemberString) structMember() {} func (m structMemberInt) structMember() {} func (p paramStruct) param() {} type methodCall struct { XMLName xml.Name `xml:"methodCall"` MethodName string `xml:"methodName"` Params []param `xml:"params"` } // types for XML-RPC responses type response interface { faultCode() int faultString() string } type responseFault struct { FaultCode int `xml:"fault>value>struct>member>value>int"` FaultString string `xml:"fault>value>struct>member>value>string"` } func (r responseFault) faultCode() int { return r.FaultCode } func (r responseFault) faultString() string { return r.FaultString } type responseStruct struct { responseFault StructMembers []struct { Name string `xml:"name"` ValueInt int `xml:"value>int"` } `xml:"params>param>value>struct>member"` } type responseInt struct { responseFault Value int `xml:"params>param>value>int"` } type responseBool struct { responseFault Value bool `xml:"params>param>value>boolean"` } type RPCError struct { FaultCode int FaultString string } func (e RPCError) Error() string { return fmt.Sprintf("Gandi DNS: RPC Error: (%d) %s", e.FaultCode, e.FaultString) } ================================================ FILE: providers/dns/gandiv5/gandiv5.go ================================================ // Package gandiv5 implements a DNS provider for solving the DNS-01 challenge using Gandi LiveDNS api. package gandiv5 import ( "context" "errors" "fmt" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/gandiv5/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "GANDIV5_" EnvAPIKey = envNamespace + "API_KEY" EnvPersonalAccessToken = envNamespace + "PERSONAL_ACCESS_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // inProgressInfo contains information about an in-progress challenge. type inProgressInfo struct { fieldName string authZone string } // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIKey string // Deprecated use PersonalAccessToken PersonalAccessToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 20*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 20*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client inProgressFQDNs map[string]inProgressInfo inProgressMu sync.Mutex // findZoneByFqdn determines the DNS zone of a FQDN. // It is overridden during tests. // only for testing purpose. findZoneByFqdn func(fqdn string) (string, error) } // NewDNSProvider returns a DNSProvider instance configured for Gandi. // Credentials must be passed in the environment variable: GANDIV5_API_KEY. func NewDNSProvider() (*DNSProvider, error) { // TODO(ldez): rewrite this when APIKey will be removed. config := NewDefaultConfig() config.APIKey = env.GetOrFile(EnvAPIKey) config.PersonalAccessToken = env.GetOrFile(EnvPersonalAccessToken) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Gandi. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("gandiv5: the configuration of the DNS provider is nil") } if config.APIKey != "" { log.Print("gandiv5: API Key is deprecated, use Personal Access Token instead") } if config.APIKey == "" && config.PersonalAccessToken == "" { return nil, errors.New("gandiv5: credentials information are missing") } if config.TTL < minTTL { return nil, fmt.Errorf("gandiv5: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.APIKey, config.PersonalAccessToken) if config.BaseURL != "" { baseURL, err := url.Parse(config.BaseURL) if err != nil { return nil, fmt.Errorf("gandiv5: %w", err) } client.BaseURL = baseURL } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, inProgressFQDNs: make(map[string]inProgressInfo), findZoneByFqdn: dns01.FindZoneByFqdn, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // find authZone authZone, err := d.findZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("gandiv5: could not find zone for domain %q: %w", domain, err) } // determine name of TXT record subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("gandiv5: %w", err) } // acquire lock and check there is not a challenge already in // progress for this value of authZone d.inProgressMu.Lock() defer d.inProgressMu.Unlock() // add TXT record into authZone err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value, d.config.TTL) if err != nil { return err } // save data necessary for CleanUp d.inProgressFQDNs[info.EffectiveFQDN] = inProgressInfo{ authZone: authZone, fieldName: subDomain, } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // acquire lock and retrieve authZone d.inProgressMu.Lock() defer d.inProgressMu.Unlock() if _, ok := d.inProgressFQDNs[info.EffectiveFQDN]; !ok { // if there is no cleanup information then just return return nil } fieldName := d.inProgressFQDNs[info.EffectiveFQDN].fieldName authZone := d.inProgressFQDNs[info.EffectiveFQDN].authZone delete(d.inProgressFQDNs, info.EffectiveFQDN) // delete TXT record from authZone err := d.client.DeleteTXTRecord(context.Background(), dns01.UnFqdn(authZone), fieldName) if err != nil { return fmt.Errorf("gandiv5: %w", err) } return 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 } ================================================ FILE: providers/dns/gandiv5/gandiv5.toml ================================================ Name = "Gandi Live DNS (v5)" Description = '''''' URL = "https://www.gandi.net" Code = "gandiv5" Since = "v0.5.0" Example = ''' GANDIV5_PERSONAL_ACCESS_TOKEN=abcdefghijklmnopqrstuvwx \ lego --dns gandiv5 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GANDIV5_PERSONAL_ACCESS_TOKEN = "Personal Access Token" GANDIV5_API_KEY = "API key (Deprecated)" [Configuration.Additional] GANDIV5_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" GANDIV5_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)" GANDIV5_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" GANDIV5_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api.gandi.net/docs/livedns/" ================================================ FILE: providers/dns/gandiv5/gandiv5_test.go ================================================ package gandiv5 import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAPIKey, EnvPersonalAccessToken) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "gandiv5: credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.inProgressFQDNs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "123", }, { desc: "missing credentials", expected: "gandiv5: credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.inProgressFQDNs) } else { require.EqualError(t, err, test.expected) } }) } } // TestDNSProvider runs Present and CleanUp against a fake Gandi RPC // Server, whose responses are predetermined for particular requests. func TestDNSProvider(t *testing.T) { provider := servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.PersonalAccessToken = "123412341234123412341234" config.BaseURL = server.URL config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer 123412341234123412341234"), ). Route("GET /domains/example.com/records/_acme-challenge.abc.def/TXT", servermock.RawStringResponse(`{"rrset_ttl":300,"rrset_values":[],"rrset_name":"_acme-challenge.abc.def","rrset_type":"TXT"}`)). Route("PUT /domains/example.com/records/_acme-challenge.abc.def/TXT", servermock.RawStringResponse(`{"message": "Zone Record Created"}`), servermock.CheckRequestJSONBody(`{"rrset_ttl":300,"rrset_values":["ezRpBPY8wH8djMLYjX2uCKPwiKDkFZ1SFMJ6ZXGlHrQ"]}`)). Route("DELETE /domains/example.com/records/_acme-challenge.abc.def/TXT", nil). Build(t) fakeKeyAuth := "XXXX" // define function to override findZoneByFqdn with fakeFindZoneByFqdn := func(fqdn string) (string, error) { return "example.com.", nil } // override findZoneByFqdn function savedFindZoneByFqdn := provider.findZoneByFqdn defer func() { provider.findZoneByFqdn = savedFindZoneByFqdn }() provider.findZoneByFqdn = fakeFindZoneByFqdn // run Present err := provider.Present("abc.def.example.com", "", fakeKeyAuth) require.NoError(t, err) // run CleanUp err = provider.CleanUp("abc.def.example.com", "", fakeKeyAuth) require.NoError(t, err) } ================================================ FILE: providers/dns/gandiv5/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // defaultBaseURL endpoint is the Gandi API endpoint used by Present and CleanUp. const defaultBaseURL = "https://api.gandi.net/v5/livedns" // Related to Personal Access Token. const authorizationHeader = "Authorization" // Client the Gandi API v5 client. type Client struct { apiKey string pat string BaseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(apiKey, pat string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, pat: pat, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) AddTXTRecord(ctx context.Context, domain, name, value string, ttl int) error { // Get exiting values for the TXT records // Needed to create challenges for both wildcard and base name domains txtRecord, err := c.getTXTRecord(ctx, domain, name) if err != nil { return err } values := []string{value} if len(txtRecord.RRSetValues) > 0 { values = append(values, txtRecord.RRSetValues...) } newRecord := &Record{RRSetTTL: ttl, RRSetValues: values} err = c.addTXTRecord(ctx, domain, name, newRecord) if err != nil { return err } return nil } func (c *Client) getTXTRecord(ctx context.Context, domain, name string) (*Record, error) { endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT") // Get exiting values for the TXT records // Needed to create challenges for both wildcard and base name domains req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } txtRecord := &Record{} err = c.do(req, txtRecord) if err != nil { return nil, fmt.Errorf("unable to get TXT records for domain %s and name %s: %w", domain, name, err) } return txtRecord, nil } func (c *Client) addTXTRecord(ctx context.Context, domain, name string, newRecord *Record) error { endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, newRecord) if err != nil { return err } message := apiResponse{} err = c.do(req, &message) if err != nil { return fmt.Errorf("unable to create TXT record for domain %s and name %s: %w", domain, name, err) } if message.Message != "" { log.Infof("API response: %s", message.Message) } return nil } func (c *Client) DeleteTXTRecord(ctx context.Context, domain, name string) error { endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT") req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } message := apiResponse{} err = c.do(req, &message) if err != nil { return fmt.Errorf("unable to delete TXT record for domain %s and name %s: %w", domain, name, err) } if message.Message != "" { log.Infof("API response: %s", message.Message) } return nil } func (c *Client) do(req *http.Request, result any) error { if c.apiKey != "" { req.Header.Set(authorizationHeader, "Apikey "+c.apiKey) } if c.pat != "" { req.Header.Set(authorizationHeader, "Bearer "+c.pat) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() err = checkResponse(req, resp) if err != nil { return err } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if len(raw) > 0 { err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func checkResponse(req *http.Request, resp *http.Response) error { if resp.StatusCode == http.StatusNotFound && resp.Request.Method == http.MethodGet { return nil } if resp.StatusCode < http.StatusBadRequest { return nil } return parseError(req, resp) } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) response := apiResponse{} err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("%d: request failed: %s", resp.StatusCode, response.Message) } ================================================ FILE: providers/dns/gandiv5/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder(apiKey, pat string) *servermock.Builder[*Client] { checkHeaders := servermock.CheckHeader().WithJSONHeaders() if apiKey != "" { checkHeaders = checkHeaders.WithAuthorization("Apikey secret-apikey") } else { checkHeaders = checkHeaders.WithAuthorization("Bearer secret-pat") } return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(apiKey, pat) client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, checkHeaders, ) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder("secret-apikey", ""). Route("GET /domains/example.com/records/foo/TXT", servermock.ResponseFromFixture("add_txt_record_get.json")). Route("PUT /domains/example.com/records/foo/TXT", servermock.ResponseFromFixture("api_response.json"), servermock.CheckRequestJSONBody(`{"rrset_ttl":120,"rrset_values":["content","value1"]}`)). Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "foo", "content", 120) require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { client := mockBuilder("", "secret-pat"). Route("DELETE /domains/example.com/records/foo/TXT", servermock.ResponseFromFixture("api_response.json")). Build(t) err := client.DeleteTXTRecord(t.Context(), "example.com", "foo") require.NoError(t, err) } ================================================ FILE: providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json ================================================ { "rrset_ttl": 120, "rrset_values": [ "value1" ], "rrset_name": "foo", "rrset_type": "TXT" } ================================================ FILE: providers/dns/gandiv5/internal/fixtures/api_response.json ================================================ { "message": "test", "uuid": "123456789" } ================================================ FILE: providers/dns/gandiv5/internal/types.go ================================================ package internal // types for JSON responses with only a message. type apiResponse struct { Message string `json:"message"` UUID string `json:"uuid,omitempty"` } // Record TXT record representation. type Record struct { RRSetTTL int `json:"rrset_ttl"` RRSetValues []string `json:"rrset_values"` RRSetName string `json:"rrset_name,omitempty"` RRSetType string `json:"rrset_type,omitempty"` } ================================================ FILE: providers/dns/gcloud/fixtures/gce_account_service_file.json ================================================ { "project_id": "A", "type": "service_account", "client_email": "foo@bar.com", "private_key_id": "pki", "private_key": "pk", "token_uri": "/token", "client_secret": "secret", "client_id": "C", "refresh_token": "D" } ================================================ FILE: providers/dns/gcloud/gcloud.toml ================================================ Name = "Google Cloud" Description = '''''' URL = "https://cloud.google.com" Code = "gcloud" Since = "v0.3.0" Example = ''' # Using a service account file GCE_PROJECT="gc-project-id" \ GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \ lego --dns gcloud -d '*.example.com' -d example.com run # Using default credentials with impersonation GCE_PROJECT="gc-project-id" \ GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \ lego --dns gcloud -d '*.example.com' -d example.com run # Using service account key with impersonation GCE_PROJECT="gc-project-id" \ GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file.json" \ GCE_IMPERSONATE_SERVICE_ACCOUNT="target-sa@gc-project-id.iam.gserviceaccount.com" \ lego --dns gcloud -d '*.example.com' -d example.com run ''' Additional = ''' Supports service account impersonation to access Google Cloud DNS resources across different projects or with restricted permissions. When using impersonation, the source service account must have: 1. The "Service Account Token Creator" role on the source service account 2. The "https://www.googleapis.com/auth/cloud-platform" scope ''' [Configuration] [Configuration.Credentials] GCE_PROJECT = "Project name (by default, the project name is auto-detected by using the metadata service)" 'Application Default Credentials' = "[Documentation](https://cloud.google.com/docs/authentication/production#providing_credentials_to_your_application)" GCE_SERVICE_ACCOUNT_FILE = "Account file path" GCE_SERVICE_ACCOUNT = "Account" [Configuration.Additional] GCE_ALLOW_PRIVATE_ZONE = "Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false)" GCE_ZONE_ID = "Allows to skip the automatic detection of the zone" GCE_IMPERSONATE_SERVICE_ACCOUNT = "Service account email to impersonate" GCE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" GCE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 180)" GCE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://cloud.google.com/dns/api/v1/" GoClient = "https://github.com/googleapis/google-api-go-client" ================================================ FILE: providers/dns/gcloud/googlecloud.go ================================================ // Package gcloud implements a DNS provider for solving the DNS-01 challenge using Google Cloud DNS. package gcloud import ( "context" "encoding/json" "errors" "fmt" "net/http" "os" "strconv" "time" "cloud.google.com/go/compute/metadata" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/miekg/dns" "golang.org/x/oauth2" "golang.org/x/oauth2/google" gdns "google.golang.org/api/dns/v1" "google.golang.org/api/googleapi" "google.golang.org/api/impersonate" "google.golang.org/api/option" ) // Environment variables names. const ( envNamespace = "GCE_" EnvServiceAccount = envNamespace + "SERVICE_ACCOUNT" EnvProject = envNamespace + "PROJECT" EnvZoneID = envNamespace + "ZONE_ID" EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE" EnvDebug = envNamespace + "DEBUG" EnvImpersonateServiceAccount = envNamespace + "IMPERSONATE_SERVICE_ACCOUNT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) const changeStatusDone = "done" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Debug bool Project string ZoneID string AllowPrivateZone bool ImpersonateServiceAccount string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ Debug: env.GetOrDefaultBool(EnvDebug, false), ZoneID: env.GetOrDefaultString(EnvZoneID, ""), AllowPrivateZone: env.GetOrDefaultBool(EnvAllowPrivateZone, false), ImpersonateServiceAccount: env.GetOrDefaultString(EnvImpersonateServiceAccount, ""), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *gdns.Service } // NewDNSProvider returns a DNSProvider instance configured for Google Cloud DNS. // By default, the project name is auto-detected by using the metadata service, // it can be overridden using the GCE_PROJECT environment variable. // A Service Account can be passed in the environment variable: GCE_SERVICE_ACCOUNT // or by specifying the keyfile location: GCE_SERVICE_ACCOUNT_FILE. func NewDNSProvider() (*DNSProvider, error) { // Use a service account file if specified via environment variable. if saKey := env.GetOrFile(EnvServiceAccount); saKey != "" { return NewDNSProviderServiceAccountKey([]byte(saKey)) } // Use default credentials. project := env.GetOrDefaultString(EnvProject, autodetectProjectID(context.Background())) return NewDNSProviderCredentials(project) } // NewDNSProviderCredentials uses the supplied credentials // to return a DNSProvider instance configured for Google Cloud DNS. func NewDNSProviderCredentials(project string) (*DNSProvider, error) { if project == "" { return nil, errors.New("googlecloud: project name missing") } config := NewDefaultConfig() config.Project = project var err error config.HTTPClient, err = newClientFromCredentials(context.Background(), config) if err != nil { return nil, fmt.Errorf("googlecloud: %w", err) } return NewDNSProviderConfig(config) } // NewDNSProviderServiceAccountKey uses the supplied service account JSON // to return a DNSProvider instance configured for Google Cloud DNS. func NewDNSProviderServiceAccountKey(saKey []byte) (*DNSProvider, error) { if len(saKey) == 0 { return nil, errors.New("googlecloud: Service Account is missing") } // If GCE_PROJECT is non-empty it overrides the project in the service // account file. project := env.GetOrDefaultString(EnvProject, "") if project == "" { // read project id from service account file var datJSON struct { ProjectID string `json:"project_id"` } err := json.Unmarshal(saKey, &datJSON) if err != nil || datJSON.ProjectID == "" { return nil, errors.New("googlecloud: project ID not found in Google Cloud Service Account file") } project = datJSON.ProjectID } config := NewDefaultConfig() config.Project = project var err error config.HTTPClient, err = newClientFromServiceAccountKey(context.Background(), config, saKey) if err != nil { return nil, fmt.Errorf("googlecloud: %w", err) } return NewDNSProviderConfig(config) } // NewDNSProviderServiceAccount uses the supplied service account JSON file // to return a DNSProvider instance configured for Google Cloud DNS. func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) { if saFile == "" { return nil, errors.New("googlecloud: Service Account file missing") } saKey, err := os.ReadFile(saFile) if err != nil { return nil, fmt.Errorf("googlecloud: unable to read Service Account file: %w", err) } return NewDNSProviderServiceAccountKey(saKey) } // NewDNSProviderConfig return a DNSProvider instance configured for Google Cloud DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("googlecloud: the configuration of the DNS provider is nil") } if config.HTTPClient == nil { return nil, errors.New("googlecloud: unable to create Google Cloud DNS service: client is nil") } svc, err := gdns.NewService(context.Background(), option.WithHTTPClient(clientdebug.Wrap(config.HTTPClient))) if err != nil { return nil, fmt.Errorf("googlecloud: unable to create Google Cloud DNS service: %w", err) } return &DNSProvider{config: config, client: svc}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("googlecloud: %w", err) } // Look for existing records. existingRrSet, err := d.findTxtRecords(zone, info.EffectiveFQDN) if err != nil { return fmt.Errorf("googlecloud: %w", err) } for _, rrSet := range existingRrSet { var rrd []string for _, rr := range rrSet.Rrdatas { data := mustUnquote(rr) rrd = append(rrd, data) if data == info.Value { log.Printf("skip: the record already exists: %s", info.Value) return nil } } rrSet.Rrdatas = rrd } // Attempt to delete the existing records before adding the new one. if len(existingRrSet) > 0 { if err = d.applyChanges(ctx, zone, &gdns.Change{Deletions: existingRrSet}); err != nil { return fmt.Errorf("googlecloud: %w", err) } } rec := &gdns.ResourceRecordSet{ Name: info.EffectiveFQDN, Rrdatas: []string{info.Value}, Ttl: int64(d.config.TTL), Type: "TXT", } // Append existing TXT record data to the new TXT record data for _, rrSet := range existingRrSet { for _, rr := range rrSet.Rrdatas { if rr != info.Value { rec.Rrdatas = append(rec.Rrdatas, rr) } } } change := &gdns.Change{ Additions: []*gdns.ResourceRecordSet{rec}, } if err = d.applyChanges(ctx, zone, change); err != nil { return fmt.Errorf("googlecloud: %w", err) } return nil } func (d *DNSProvider) applyChanges(ctx context.Context, zone string, change *gdns.Change) error { if d.config.Debug { data, _ := json.Marshal(change) log.Printf("change (Create): %s", string(data)) } chg, err := d.client.Changes.Create(d.config.Project, zone, change).Do() if err != nil { var v *googleapi.Error if errors.As(err, &v) && v.Code == http.StatusNotFound { return nil } data, _ := json.Marshal(change) return fmt.Errorf("failed to perform changes [zone %s, change %s]: %w", zone, string(data), err) } if chg.Status == changeStatusDone { return nil } chgID := chg.Id // wait for change to be acknowledged return wait.Retry(ctx, func() error { if d.config.Debug { data, _ := json.Marshal(change) log.Printf("change (Get): %s", string(data)) } chg, err = d.client.Changes.Get(d.config.Project, zone, chgID).Do() if err != nil { data, _ := json.Marshal(change) return fmt.Errorf("failed to get changes [zone %s, change %s]: %w", zone, string(data), err) } if chg.Status != changeStatusDone { return fmt.Errorf("status: %s", chg.Status) } return nil }, backoff.WithBackOff(backoff.NewConstantBackOff(3*time.Second)), backoff.WithMaxElapsedTime(30*time.Second), ) } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("googlecloud: %w", err) } records, err := d.findTxtRecords(zone, info.EffectiveFQDN) if err != nil { return fmt.Errorf("googlecloud: %w", err) } if len(records) == 0 { return nil } _, err = d.client.Changes.Create(d.config.Project, zone, &gdns.Change{Deletions: records}).Do() if err != nil { return fmt.Errorf("googlecloud: %w", err) } return nil } // Timeout customizes the timeout values used by the ACME package for checking // DNS record validity. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // getHostedZone returns the managed-zone. func (d *DNSProvider) getHostedZone(domain string) (string, error) { authZone, zones, err := d.lookupHostedZoneID(domain) if err != nil { return "", err } if len(zones) == 0 { return "", fmt.Errorf("no matching domain found for domain %s", authZone) } for _, z := range zones { if z.Visibility == "public" || z.Visibility == "" || (z.Visibility == "private" && d.config.AllowPrivateZone) { return z.Name, nil } } if d.config.AllowPrivateZone { return "", fmt.Errorf("no public or private zone found for domain %s", authZone) } return "", fmt.Errorf("no public zone found for domain %s", authZone) } // lookupHostedZoneID finds the managed zone ID in Google. // // Be careful here. // An automated system might run in a GCloud Service Account, with access to edit the zone // // (gcloud dns managed-zones get-iam-policy $zone_id) (role roles/dns.admin) // // but not with project-wide access to list all zones // // (gcloud projects get-iam-policy $project_id) (a role with permission dns.managedZones.list) // // If we force a zone list to succeed, we demand more permissions than needed. func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*gdns.ManagedZone, error) { // GCE_ZONE_ID override for service accounts to avoid needing zones-list permission if d.config.ZoneID != "" { zone, err := d.client.ManagedZones.Get(d.config.Project, d.config.ZoneID).Do() if err != nil { return "", nil, fmt.Errorf("API call ManagedZones.Get for explicit zone ID %q in project %q failed: %w", d.config.ZoneID, d.config.Project, err) } return zone.DnsName, []*gdns.ManagedZone{zone}, nil } authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(domain)) if err != nil { return "", nil, fmt.Errorf("could not find zone: %w", err) } zones, err := d.client.ManagedZones. List(d.config.Project). DnsName(authZone). Do() if err != nil { return "", nil, fmt.Errorf("API call ManagedZones.List failed: %w", err) } return authZone, zones.ManagedZones, nil } func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*gdns.ResourceRecordSet, error) { recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do() if err != nil { return nil, err } return recs.Rrsets, nil } func newClientFromCredentials(ctx context.Context, config *Config) (*http.Client, error) { if config.ImpersonateServiceAccount != "" { ts, err := google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/cloud-platform") if err != nil { return nil, fmt.Errorf("unable to get default token source: %w", err) } return newImpersonateClient(ctx, config.ImpersonateServiceAccount, ts) } client, err := google.DefaultClient(ctx, gdns.NdevClouddnsReadwriteScope) if err != nil { return nil, fmt.Errorf("unable to get Google Cloud client: %w", err) } return client, nil } func newClientFromServiceAccountKey(ctx context.Context, config *Config, saKey []byte) (*http.Client, error) { if config.ImpersonateServiceAccount != "" { conf, err := google.JWTConfigFromJSON(saKey, "https://www.googleapis.com/auth/cloud-platform") if err != nil { return nil, fmt.Errorf("unable to acquire config: %w", err) } return newImpersonateClient(ctx, config.ImpersonateServiceAccount, conf.TokenSource(ctx)) } conf, err := google.JWTConfigFromJSON(saKey, gdns.NdevClouddnsReadwriteScope) if err != nil { return nil, fmt.Errorf("unable to acquire config: %w", err) } return conf.Client(ctx), nil } func newImpersonateClient(ctx context.Context, impersonateServiceAccount string, ts oauth2.TokenSource) (*http.Client, error) { impersonatedTS, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ TargetPrincipal: impersonateServiceAccount, Scopes: []string{gdns.NdevClouddnsReadwriteScope}, }, option.WithTokenSource(ts)) if err != nil { return nil, fmt.Errorf("unable to create impersonated credentials: %w", err) } return oauth2.NewClient(ctx, impersonatedTS), nil } func mustUnquote(raw string) string { clean, err := strconv.Unquote(raw) if err != nil { return raw } return clean } func autodetectProjectID(ctx context.Context) string { if pid, err := metadata.ProjectIDWithContext(ctx); err == nil { return pid } return "" } ================================================ FILE: providers/dns/gcloud/googlecloud_test.go ================================================ package gcloud import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "sort" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" "golang.org/x/oauth2/google" "google.golang.org/api/dns/v1" ) const ( envDomain = envNamespace + "DOMAIN" envServiceAccountFile = envNamespace + "SERVICE_ACCOUNT_FILE" envMetadataHost = envNamespace + "METADATA_HOST" envGoogleApplicationCredentials = "GOOGLE_APPLICATION_CREDENTIALS" ) var envTest = tester.NewEnvTest( EnvProject, envServiceAccountFile, envGoogleApplicationCredentials, envMetadataHost, EnvServiceAccount, EnvImpersonateServiceAccount). WithDomain(envDomain). WithLiveTestExtra(func() bool { _, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope) return err == nil }) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "invalid credentials", envVars: map[string]string{ EnvProject: "123", envServiceAccountFile: "", // as Travis run on GCE, we have to alter env envGoogleApplicationCredentials: "not-a-secret-file", envMetadataHost: "http://example.com", // defined here to avoid the client cache. }, // the error message varies according to the OS used. expected: "googlecloud: unable to get Google Cloud client: google: error getting credentials using GOOGLE_APPLICATION_CREDENTIALS environment variable: ", }, { desc: "missing project", envVars: map[string]string{ EnvProject: "", envServiceAccountFile: "", // as Travis run on GCE, we have to alter env envMetadataHost: "http://example.com", }, expected: "googlecloud: project name missing", }, { desc: "success key file", envVars: map[string]string{ EnvProject: "", envServiceAccountFile: "fixtures/gce_account_service_file.json", }, }, { desc: "success key", envVars: map[string]string{ EnvProject: "", EnvServiceAccount: `{"project_id": "A","type": "service_account","client_email": "foo@bar.com","private_key_id": "pki","private_key": "pk","token_uri": "/token","client_secret": "secret","client_id": "C","refresh_token": "D"}`, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.Error(t, err) require.Contains(t, err.Error(), test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string project string expected string }{ { desc: "invalid project", project: "123", expected: "googlecloud: unable to create Google Cloud DNS service: client is nil", }, { desc: "missing project", expected: "googlecloud: unable to create Google Cloud DNS service: client is nil", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() config := NewDefaultConfig() config.Project = test.project p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestPresentNoExistingRR(t *testing.T) { provider := mockBuilder(). // getHostedZone Route("GET /dns/v1/projects/manhattan/managedZones", servermock.JSONEncode(&dns.ManagedZonesListResponse{ ManagedZones: []*dns.ManagedZone{ {Name: "test", Visibility: "public"}, }, }), servermock.CheckQueryParameter().Strict(). With("dnsName", "example.com."). With("prettyPrint", "false"). With("alt", "json")). // findTxtRecords Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ Rrsets: []*dns.ResourceRecordSet{}, }), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge.example.com."). With("type", "TXT"). With("prettyPrint", "false"). With("alt", "json")). // applyChanges [Create] Route("POST /dns/v1/projects/manhattan/managedZones/test/changes", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { var chgReq dns.Change if err := json.NewDecoder(req.Body).Decode(&chgReq); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } chgResp := chgReq chgResp.Status = changeStatusDone if err := json.NewEncoder(rw).Encode(chgResp); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } }), servermock.CheckQueryParameter().Strict(). With("prettyPrint", "false"). With("alt", "json")). Build(t) domain := "example.com" err := provider.Present(domain, "", "") require.NoError(t, err) } func TestPresentWithExistingRR(t *testing.T) { provider := mockBuilder(). // getHostedZone Route("GET /dns/v1/projects/manhattan/managedZones", servermock.JSONEncode(&dns.ManagedZonesListResponse{ ManagedZones: []*dns.ManagedZone{ {Name: "test", Visibility: "public"}, }, }), servermock.CheckQueryParameter().Strict(). With("dnsName", "example.com."). With("prettyPrint", "false"). With("alt", "json")). // findTxtRecords Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ Rrsets: []*dns.ResourceRecordSet{{ Name: "_acme-challenge.example.com.", Rrdatas: []string{`"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, Ttl: 120, Type: "TXT", }}, }), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge.example.com."). With("type", "TXT"). With("prettyPrint", "false"). With("alt", "json")). // applyChanges [Create] Route("POST /dns/v1/projects/manhattan/managedZones/test/changes", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { var chgReq dns.Change if err := json.NewDecoder(req.Body).Decode(&chgReq); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } if len(chgReq.Additions) > 0 { sort.Strings(chgReq.Additions[0].Rrdatas) } var prevVal string for _, addition := range chgReq.Additions { for _, value := range addition.Rrdatas { if prevVal == value { http.Error(rw, fmt.Sprintf("The resource %s already exists", value), http.StatusConflict) return } prevVal = value } } chgResp := chgReq chgResp.Status = changeStatusDone if err := json.NewEncoder(rw).Encode(chgResp); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } }), servermock.CheckQueryParameter().Strict(). With("prettyPrint", "false"). With("alt", "json")). Build(t) domain := "example.com" err := provider.Present(domain, "", "") require.NoError(t, err) } func TestPresentSkipExistingRR(t *testing.T) { provider := mockBuilder(). // getHostedZone Route("GET /dns/v1/projects/manhattan/managedZones", servermock.JSONEncode(&dns.ManagedZonesListResponse{ ManagedZones: []*dns.ManagedZone{ {Name: "test", Visibility: "public"}, }, }), servermock.CheckQueryParameter().Strict(). With("dnsName", "example.com."). With("prettyPrint", "false"). With("alt", "json")). // findTxtRecords Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ Rrsets: []*dns.ResourceRecordSet{{ Name: "_acme-challenge.example.com.", Rrdatas: []string{`"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, Ttl: 120, Type: "TXT", }}, }), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge.example.com."). With("type", "TXT"). With("prettyPrint", "false"). With("alt", "json")). Build(t) domain := "example.com" err := provider.Present(domain, "", "") require.NoError(t, err) } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProviderCredentials(envTest.GetValue(EnvProject)) require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLivePresentMultiple(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProviderCredentials(envTest.GetValue(EnvProject)) require.NoError(t, err) // Check that we're able to create multiple entries err = provider.Present(envTest.GetDomain(), "1", "123d==") require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "2", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProviderCredentials(envTest.GetValue(EnvProject)) require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.Project = "manhattan" p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BasePath = server.URL return p, err }) } ================================================ FILE: providers/dns/gcore/gcore.go ================================================ // Package gcore implements a DNS provider for solving the DNS-01 challenge using G-Core. package gcore import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/gcore" ) // Environment variables names. const ( envNamespace = "GCORE_" EnvPermanentAPIToken = envNamespace + "PERMANENT_API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config for DNSProvider. type Config = gcore.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, gcore.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, gcore.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider an implementation of challenge.Provider contract. type DNSProvider struct { prv challenge.ProviderTimeout } // NewDNSProvider returns an instance of DNSProvider configured for G-Core DNS API. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvPermanentAPIToken) if err != nil { return nil, fmt.Errorf("gcore: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvPermanentAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for G-Core DNS API. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("gcore: the configuration of the DNS provider is nil") } provider, err := gcore.NewDNSProviderConfig(config, "") if err != nil { return nil, fmt.Errorf("gcore: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("gcore: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("gcore: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/gcore/gcore.toml ================================================ Name = "G-Core" Description = '''''' URL = "https://gcore.com/dns/" Code = "gcore" Since = "v4.5.0" Example = ''' GCORE_PERMANENT_API_TOKEN=xxxxx \ lego --dns gcore -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GCORE_PERMANENT_API_TOKEN = "Permanent API token (https://gcore.com/blog/permanent-api-token-explained/)" [Configuration.Additional] GCORE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" GCORE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" GCORE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" GCORE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api.gcore.com/docs/dns#tag/zones" ================================================ FILE: providers/dns/gcore/gcore_test.go ================================================ package gcore import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvPermanentAPIToken).WithDomain(envNamespace + "DOMAIN") func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvPermanentAPIToken: "A", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvPermanentAPIToken: "", }, expected: "gcore: some credentials information are missing: GCORE_PERMANENT_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "A", }, { desc: "missing credentials", expected: "gcore: incomplete credentials provided", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/gigahostno/gigahostno.go ================================================ // Package gigahostno implements a DNS provider for solving the DNS-01 challenge using Gigahost.no. package gigahostno import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/gigahostno/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "GIGAHOSTNO_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvSecret = envNamespace + "SECRET" 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 { Username string Password string Secret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config identifier *internal.Identifier client *internal.Client tokenMu sync.Mutex token *internal.Token } // NewDNSProvider returns a DNSProvider instance configured for Gigahost. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("gigahostno: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.Secret = env.GetOrFile(EnvSecret) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Gigahost. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("gigahostno: the configuration of the DNS provider is nil") } identifier, err := internal.NewIdentifier(config.Username, config.Password, config.Secret) if err != nil { return nil, fmt.Errorf("gigahostno: %w", err) } if config.HTTPClient != nil { identifier.HTTPClient = config.HTTPClient } identifier.HTTPClient = clientdebug.Wrap(identifier.HTTPClient) client := internal.NewClient() if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, identifier: identifier, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) err := d.authenticate(ctx) if err != nil { return fmt.Errorf("gigahostno: %w", err) } ctx = internal.WithContext(ctx, d.token.Token) zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("gigahostno: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) if err != nil { return fmt.Errorf("gigahostno: %w", err) } record := internal.Record{ Name: subDomain, Type: "TXT", Value: info.Value, TTL: d.config.TTL, } err = d.client.CreateNewRecord(ctx, zone.ID, record) if err != nil { return fmt.Errorf("gigahostno: create new record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) err := d.authenticate(ctx) if err != nil { return fmt.Errorf("gigahostno: %w", err) } ctx = internal.WithContext(ctx, d.token.Token) zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("gigahostno: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Name) if err != nil { return fmt.Errorf("gigahostno: %w", err) } records, err := d.client.GetZoneRecords(ctx, zone.ID) if err != nil { return fmt.Errorf("gigahostno: get zone records: %w", err) } for _, record := range records { if record.Type == "TXT" && record.Name == subDomain && record.Value == info.Value { err := d.client.DeleteRecord(ctx, zone.ID, record.ID, record.Name, record.Type) if err != nil { return fmt.Errorf("gigahostno: delete record: %w", err) } break } } return 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 } func (d *DNSProvider) authenticate(ctx context.Context) error { d.tokenMu.Lock() defer d.tokenMu.Unlock() if !d.token.IsExpired() { return nil } tok, err := d.identifier.Authenticate(ctx) if err != nil { return fmt.Errorf("authenticate: %w", err) } d.token = tok return nil } func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (*internal.Zone, error) { zones, err := d.client.GetZones(ctx) if err != nil { return nil, fmt.Errorf("get zones: %w", err) } for d := range dns01.UnFqdnDomainsSeq(fqdn) { for _, zone := range zones { if zone.Name == d && zone.Active == "1" { return &zone, nil } } } return nil, fmt.Errorf("zone not found for %q", fqdn) } ================================================ FILE: providers/dns/gigahostno/gigahostno.toml ================================================ Name = "Gigahost.no" Description = '''''' URL = "https://gigahost.no/" Code = "gigahostno" Since = "v4.29.0" Example = ''' GIGAHOSTNO_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \ GIGAHOSTNO_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \ lego --dns gigahostno -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GIGAHOSTNO_USERNAME = "Username" GIGAHOSTNO_PASSWORD = "Password" [Configuration.Additional] GIGAHOSTNO_SECRET = "TOTP secret" GIGAHOSTNO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" GIGAHOSTNO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" GIGAHOSTNO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" GIGAHOSTNO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://gigahost.no/api-dokumentasjon" ================================================ FILE: providers/dns/gigahostno/gigahostno_test.go ================================================ package gigahostno import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/gigahostno/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvPassword, EnvSecret, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", EnvSecret: "super-secret", }, }, { desc: "missing GIGAHOSTNO_USERNAME", envVars: map[string]string{ EnvPassword: "secret", }, expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_USERNAME", }, { desc: "missing GIGAHOSTNO_PASSWORD", envVars: map[string]string{ EnvUsername: "user", }, expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "gigahostno: some credentials information are missing: GIGAHOSTNO_USERNAME,GIGAHOSTNO_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string secret string expected string }{ { desc: "success", username: "user", password: "secret", secret: "super-secret", }, { desc: "missing username", password: "secret", expected: "gigahostno: credentials missing", }, { desc: "missing password", username: "user", expected: "gigahostno: credentials missing", }, { desc: "missing credentials", expected: "gigahostno: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password config.Secret = test.secret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Username = "user" config.Password = "secret" config.Secret = "JBSWY3DPEHPK3PXP" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) p.identifier.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /authenticate", servermock.ResponseFromInternal("authenticate.json")). Route("GET /dns/zones", servermock.ResponseFromInternal("zones.json"), servermock.CheckHeader(). WithAuthorization("Bearer secrettoken")). Route("POST /dns/zones/123/records", servermock.ResponseFromInternal("create_record.json"), servermock.CheckRequestJSONBodyFromInternal("create_record-request.json"), servermock.CheckHeader(). WithAuthorization("Bearer secrettoken")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_Present_token_not_expired(t *testing.T) { provider := mockBuilder(). Route("GET /dns/zones", servermock.ResponseFromInternal("zones.json"), servermock.CheckHeader(). WithAuthorization("Bearer secret-token")). Route("POST /dns/zones/123/records", servermock.ResponseFromInternal("create_record.json"), servermock.CheckRequestJSONBodyFromInternal("create_record-request.json"), servermock.CheckHeader(). WithAuthorization("Bearer secret-token")). Build(t) provider.token = &internal.Token{ Token: "secret-token", TokenExpire: 65322892800, // 2040-01-01 CustomerID: "123", } err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("POST /authenticate", servermock.ResponseFromInternal("authenticate.json")). Route("GET /dns/zones", servermock.ResponseFromInternal("zones.json"), servermock.CheckHeader(). WithAuthorization("Bearer secrettoken")). Route("GET /dns/zones/123/records", servermock.ResponseFromInternal("zone_records.json"), servermock.CheckHeader(). WithAuthorization("Bearer secrettoken")). Route("DELETE /dns/zones/123/records/jkl012", servermock.ResponseFromInternal("delete_record.json"), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge"). With("type", "TXT"), servermock.CheckHeader(). WithAuthorization("Bearer secrettoken")). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp_token_not_expired(t *testing.T) { provider := mockBuilder(). Route("GET /dns/zones", servermock.ResponseFromInternal("zones.json"), servermock.CheckHeader(). WithAuthorization("Bearer secret-token")). Route("GET /dns/zones/123/records", servermock.ResponseFromInternal("zone_records.json"), servermock.CheckHeader(). WithAuthorization("Bearer secret-token")). Route("DELETE /dns/zones/123/records/jkl012", servermock.ResponseFromInternal("delete_record.json"), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge"). With("type", "TXT"), servermock.CheckHeader(). WithAuthorization("Bearer secret-token")). Build(t) provider.token = &internal.Token{ Token: "secret-token", TokenExpire: 65322892800, // 2040-01-01 CustomerID: "123", } err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/gigahostno/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://api.gigahost.no/api/v0" const authorizationHeader = "Authorization" // Client the Gigahost.no API client. type Client struct { BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient() *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // GetZones returns all zones. func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { endpoint := c.BaseURL.JoinPath("dns", "zones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result APIResponse[[]Zone] err = c.do(ctx, req, &result) if err != nil { return nil, err } return result.Data, nil } // GetZoneRecords returns all records for a zone. func (c *Client) GetZoneRecords(ctx context.Context, zoneID string) ([]Record, error) { endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result APIResponse[[]Record] err = c.do(ctx, req, &result) if err != nil { return nil, err } return result.Data, nil } // CreateNewRecord creates a new record. func (c *Client) CreateNewRecord(ctx context.Context, zoneID string, record Record) error { endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(ctx, req, nil) } // DeleteRecord deletes a record. func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID, name, recordType string) error { endpoint := c.BaseURL.JoinPath("dns", "zones", zoneID, "records", recordID) query := endpoint.Query() query.Set("name", name) query.Set("type", recordType) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(ctx, req, nil) } func (c *Client) do(ctx context.Context, req *http.Request, result any) error { useragent.SetHeader(req.Header) req.Header.Set(authorizationHeader, "Bearer "+getToken(ctx)) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/gigahostno/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient() client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestClient_GetZones(t *testing.T) { client := mockBuilder(). Route("GET /dns/zones", servermock.ResponseFromFixture("zones.json")). Build(t) zones, err := client.GetZones(mockContext(t)) require.NoError(t, err) expected := []Zone{ { ID: "123", Name: "example.com", NameDisplay: "example.com", Type: "NATIVE", Active: "1", }, { ID: "226", Name: "example.org", NameDisplay: "example.org", Type: "NATIVE", Active: "1", }, { ID: "229", Name: "example.xn--zckzah", NameDisplay: "example.テスト", Type: "NATIVE", Active: "1", }, } assert.Equal(t, expected, zones) } func TestClient_GetZones_error(t *testing.T) { client := mockBuilder(). Route("GET /dns/zones", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetZones(mockContext(t)) require.EqualError(t, err, "401: 401 Unauthorized: 401 Unauthorized") } func TestClient_GetZoneRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/zones/123/records", servermock.ResponseFromFixture("zone_records.json")). Build(t) zones, err := client.GetZoneRecords(mockContext(t), "123") require.NoError(t, err) expected := []Record{ { ID: "abc123", Name: "@", Type: "A", Value: "185.125.168.166", TTL: 3600, }, { ID: "def456", Name: "www", Type: "A", Value: "185.125.168.166", TTL: 3600, }, { ID: "ghi789", Name: "@", Type: "MX", Value: "mail.example.no", TTL: 3600, }, { ID: "jkl012", Name: "_acme-challenge", Type: "TXT", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, }, } assert.Equal(t, expected, zones) } func TestClient_CreateNewRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/zones/example.com/records", servermock.ResponseFromFixture("create_record.json"), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := Record{ Name: "_acme-challenge", Type: "TXT", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, } err := client.CreateNewRecord(mockContext(t), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("/dns/zones/123/records/abc123", servermock.ResponseFromFixture("delete_record.json"), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge"). With("type", "TXT")). Build(t) err := client.DeleteRecord(mockContext(t), "123", "abc123", "_acme-challenge", "TXT") require.NoError(t, err) } ================================================ FILE: providers/dns/gigahostno/internal/fixtures/authenticate-request.json ================================================ { "username": "user", "password": "secret" } ================================================ FILE: providers/dns/gigahostno/internal/fixtures/authenticate.json ================================================ { "meta": { "status": 200, "status_message": "200 OK", "maintenance": false }, "data": { "token": "secrettoken", "token_expire": 1577836800, "customer_id": "16030", "contact_id": "15182", "customer_name": "Cloudline AS", "contact_username": "test@example.com", "contact_access_level": "admin", "customer_address": "Grønland 14", "customer_zipcode": "5918", "customer_city": "Frekhaug", "customer_province": "Vestland", "ga_secret": "ga_secret", "ga_enabled": "1", "vat": 1 } } ================================================ FILE: providers/dns/gigahostno/internal/fixtures/create_record-request.json ================================================ { "record_name": "_acme-challenge", "record_type": "TXT", "record_value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "record_ttl": 120 } ================================================ FILE: providers/dns/gigahostno/internal/fixtures/create_record.json ================================================ { "meta": { "status": 201, "status_message": "201 Created", "message": "Record created successfully." } } ================================================ FILE: providers/dns/gigahostno/internal/fixtures/delete_record.json ================================================ { "meta": { "status": 200, "status_message": "200 OK", "message": "Record deleted successfully." } } ================================================ FILE: providers/dns/gigahostno/internal/fixtures/error.json ================================================ { "meta": { "status": 401, "status_message": "401 Unauthorized", "maintenance": false, "message": "401 Unauthorized" }, "data": [] } ================================================ FILE: providers/dns/gigahostno/internal/fixtures/zone_records.json ================================================ { "meta": { "status": 200, "status_message": "200 OK" }, "data": [ { "record_id": "abc123", "record_name": "@", "record_type": "A", "record_value": "185.125.168.166", "record_ttl": 3600, "record_priority": null }, { "record_id": "def456", "record_name": "www", "record_type": "A", "record_value": "185.125.168.166", "record_ttl": 3600, "record_priority": null }, { "record_id": "ghi789", "record_name": "@", "record_type": "MX", "record_value": "mail.example.no", "record_ttl": 3600, "record_priority": 10 }, { "record_id": "jkl012", "record_name": "_acme-challenge", "record_type": "TXT", "record_value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "record_ttl": 120 } ] } ================================================ FILE: providers/dns/gigahostno/internal/fixtures/zones.json ================================================ { "meta": { "status": 200, "status_message": "200 OK", "maintenance": false, "message": "200 OK" }, "data": [ { "zone_id": "123", "cust_id": "16030", "order_id": "26117", "zone_name": "example.com", "zone_type": "NATIVE", "zone_active": "1", "zone_protected": "1", "zone_is_registered": "1", "domain_registrar": "norid", "domain_status": "active", "domain_registered_date": "2025-11-23 15:17:38", "domain_expiry_date": "2026-11-23 15:17:38", "domain_updated_date": "2025-11-23 16:17:38", "domain_auto_renew": "1", "domain_epp_id": "LEG2175D-NORID", "domain_registrant_id": "CA19777O", "domain_tech_id": "GH295R", "domain_auth_info": "XXXXXXXXXXXXXXX", "domain_locked": "0", "domain_dnssec": "0", "domain_dnssec_data": null, "domain_protected_email": null, "zone_created": "2025-11-23 16:17:29", "zone_updated": 1700000000, "external_dns": "0", "record_count": 4, "zone_name_display": "example.com" }, { "zone_id": "226", "cust_id": "16030", "order_id": "26114", "zone_name": "example.org", "zone_type": "NATIVE", "zone_active": "1", "zone_protected": "1", "zone_is_registered": "1", "domain_registrar": "norid", "domain_status": "active", "domain_registered_date": "2025-11-23 14:15:01", "domain_expiry_date": "2026-11-23 14:15:01", "domain_updated_date": "2025-11-23 15:15:02", "domain_auto_renew": "1", "domain_epp_id": "TEO218D-NORID", "domain_registrant_id": "CA19774O", "domain_tech_id": "GH295R", "domain_auth_info": "XXXXXXXXXXXXXX", "domain_locked": "0", "domain_dnssec": "0", "domain_dnssec_data": null, "domain_protected_email": null, "zone_created": "2025-11-23 15:13:27", "zone_updated": 1700000000, "external_dns": "0", "record_count": 5, "zone_name_display": "example.org" }, { "zone_id": "229", "cust_id": "16030", "order_id": "26119", "zone_name": "example.xn--zckzah", "zone_type": "NATIVE", "zone_active": "1", "zone_protected": "1", "zone_is_registered": "1", "domain_registrar": "norid", "domain_status": "active", "domain_registered_date": "2014-12-01 12:40:48", "domain_expiry_date": "2026-12-01 12:40:48", "domain_updated_date": "2025-11-23 15:37:36", "domain_auto_renew": "1", "domain_epp_id": "DIT1003D-NORID", "domain_registrant_id": "DCA822O", "domain_tech_id": "GH295R", "domain_auth_info": "XXXXXXXXXXXXXX", "domain_locked": "0", "domain_dnssec": "0", "domain_dnssec_data": null, "domain_protected_email": null, "zone_created": "2025-11-23 16:37:15", "zone_updated": 1700000000, "external_dns": "0", "record_count": 4, "zone_name_display": "example.\u30C6\u30B9\u30C8" } ] } ================================================ FILE: providers/dns/gigahostno/internal/identity.go ================================================ package internal import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/pquerna/otp/totp" ) type token string const tokenKey token = "token" type Identifier struct { username string password string Secret string BaseURL *url.URL HTTPClient *http.Client } func NewIdentifier(username, password, secret string) (*Identifier, error) { if username == "" || password == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Identifier{ username: username, password: password, Secret: secret, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Identifier) Authenticate(ctx context.Context) (*Token, error) { endpoint := c.BaseURL.JoinPath("authenticate") auth := Auth{Username: c.username, Password: c.password} if c.Secret != "" { tan, err := totp.GenerateCode(c.Secret, time.Now()) if err != nil { return nil, fmt.Errorf("generate TOTP: %w", err) } auth.Code, err = strconv.Atoi(tan) if err != nil { return nil, fmt.Errorf("parse TOTP: %w", err) } } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, auth) if err != nil { return nil, err } var result APIResponse[*Token] err = c.do(req, &result) if err != nil { return nil, err } return result.Data, nil } func (c *Identifier) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func WithContext(ctx context.Context, credential string) context.Context { return context.WithValue(ctx, tokenKey, credential) } func getToken(ctx context.Context) string { credential, ok := ctx.Value(tokenKey).(string) if !ok { return "" } return credential } ================================================ FILE: providers/dns/gigahostno/internal/identity_test.go ================================================ package internal import ( "context" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupIdentifierClient(server *httptest.Server) (*Identifier, error) { client, err := NewIdentifier("user", "secret", "") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil } func mockContext(t *testing.T) context.Context { t.Helper() return context.WithValue(t.Context(), tokenKey, "secret") } func TestIdentifier_Authenticate(t *testing.T) { identifier := servermock.NewBuilder[*Identifier](setupIdentifierClient). Route("POST /authenticate", servermock.ResponseFromFixture("authenticate.json"), servermock.CheckRequestJSONBodyFromFixture("authenticate-request.json")). Build(t) token, err := identifier.Authenticate(context.Background()) require.NoError(t, err) expected := &Token{ Token: "secrettoken", TokenExpire: 1577836800, CustomerID: "16030", ContactID: "15182", CustomerName: "Cloudline AS", ContactUsername: "test@example.com", ContactAccessLevel: "admin", CustomerAddress: "Grønland 14", CustomerZipcode: "5918", CustomerCity: "Frekhaug", CustomerProvince: "Vestland", GASecret: "ga_secret", GAEnabled: "1", VAT: 1, } assert.Equal(t, expected, token) } func TestToken_IsExpired(t *testing.T) { testCases := []struct { desc string token *Token assert assert.BoolAssertionFunc }{ { desc: "nil", assert: assert.True, }, { desc: "empty", token: &Token{}, assert: assert.True, }, { desc: "not expired", token: &Token{ TokenExpire: 65322892800, // 2040-01-01 }, assert: assert.False, }, { desc: "now", token: &Token{ TokenExpire: time.Now().Unix(), }, assert: assert.True, }, { desc: "now + 2 minutes", token: &Token{ TokenExpire: time.Now().Add(2 * time.Minute).Unix(), }, assert: assert.False, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() test.assert(t, test.token.IsExpired()) }) } } ================================================ FILE: providers/dns/gigahostno/internal/types.go ================================================ package internal import ( "fmt" "time" ) type APIError struct { Meta MetaData `json:"meta"` } func (a *APIError) Error() string { return fmt.Sprintf("%d: %s: %s", a.Meta.Status, a.Meta.StatusMessage, a.Meta.Message) } type MetaData struct { Status int `json:"status,omitempty"` StatusMessage string `json:"status_message,omitempty"` Maintenance bool `json:"maintenance"` Message string `json:"message,omitempty"` } type APIResponse[T any] struct { Meta MetaData `json:"meta"` Data T `json:"data,omitempty"` } type Zone struct { ID string `json:"zone_id,omitempty"` Name string `json:"zone_name,omitempty"` NameDisplay string `json:"zone_name_display,omitempty"` Type string `json:"zone_type,omitempty"` Active string `json:"zone_active,omitempty"` } type Record struct { ID string `json:"record_id,omitempty"` Name string `json:"record_name,omitempty"` Type string `json:"record_type,omitempty"` Value string `json:"record_value,omitempty"` TTL int `json:"record_ttl,omitempty"` } type Auth struct { Username string `json:"username"` Password string `json:"password"` Code int `json:"code,omitempty"` } type Token struct { Token string `json:"token,omitempty"` TokenExpire int64 `json:"token_expire,omitempty"` CustomerID string `json:"customer_id,omitempty"` ContactID string `json:"contact_id,omitempty"` CustomerName string `json:"customer_name,omitempty"` ContactUsername string `json:"contact_username,omitempty"` ContactAccessLevel string `json:"contact_access_level,omitempty"` CustomerAddress string `json:"customer_address,omitempty"` CustomerZipcode string `json:"customer_zipcode,omitempty"` CustomerCity string `json:"customer_city,omitempty"` CustomerProvince string `json:"customer_province,omitempty"` GASecret string `json:"ga_secret,omitempty"` GAEnabled string `json:"ga_enabled,omitempty"` VAT int `json:"vat,omitempty"` } func (t *Token) IsExpired() bool { if t == nil { return true } return time.Now().UTC().Add(1 * time.Minute).After(time.Unix(t.TokenExpire, 0).UTC()) } ================================================ FILE: providers/dns/glesys/glesys.go ================================================ // Package glesys implements a DNS provider for solving the DNS-01 challenge using GleSYS api. package glesys import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/glesys/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "GLESYS_" EnvAPIUser = envNamespace + "API_USER" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 60 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIUser string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 20*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 20*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client activeRecords map[string]int inProgressMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for GleSYS. // Credentials must be passed in the environment variables: // GLESYS_API_USER and GLESYS_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIUser, EnvAPIKey) if err != nil { return nil, fmt.Errorf("glesys: %w", err) } config := NewDefaultConfig() config.APIUser = values[EnvAPIUser] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for GleSYS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("glesys: the configuration of the DNS provider is nil") } if config.APIUser == "" || config.APIKey == "" { return nil, errors.New("glesys: incomplete credentials provided") } if config.TTL < minTTL { return nil, fmt.Errorf("glesys: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.APIUser, config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, activeRecords: make(map[string]int), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // find authZone authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("glesys: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("glesys: %w", err) } // acquire lock and check there is not a challenge already in progress for this value of authZone d.inProgressMu.Lock() defer d.inProgressMu.Unlock() // add TXT record into authZone recordID, err := d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value, d.config.TTL) if err != nil { return err } // save data necessary for CleanUp d.activeRecords[info.EffectiveFQDN] = recordID return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // acquire lock and retrieve authZone d.inProgressMu.Lock() defer d.inProgressMu.Unlock() if _, ok := d.activeRecords[info.EffectiveFQDN]; !ok { // if there is no cleanup information then just return return nil } recordID := d.activeRecords[info.EffectiveFQDN] delete(d.activeRecords, info.EffectiveFQDN) // delete TXT record from authZone return d.client.DeleteTXTRecord(context.Background(), recordID) } // Timeout returns the values (20*time.Minute, 20*time.Second) which // are used by the acme package as timeout and check interval values // when checking for DNS record propagation with GleSYS. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } ================================================ FILE: providers/dns/glesys/glesys.toml ================================================ Name = "Glesys" Description = '''''' URL = "https://glesys.com/" Code = "glesys" Since = "v0.5.0" Example = ''' GLESYS_API_USER=xxxxx \ GLESYS_API_KEY=yyyyy \ lego --dns glesys -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GLESYS_API_USER = "API user" GLESYS_API_KEY = "API key" [Configuration.Additional] GLESYS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" GLESYS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)" GLESYS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" GLESYS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://github.com/GleSYS/API/wiki/API-Documentation" ================================================ FILE: providers/dns/glesys/glesys_test.go ================================================ package glesys import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIUser, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIUser: "A", EnvAPIKey: "B", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIUser: "", EnvAPIKey: "", }, expected: "glesys: some credentials information are missing: GLESYS_API_USER,GLESYS_API_KEY", }, { desc: "missing api user", envVars: map[string]string{ EnvAPIUser: "", EnvAPIKey: "B", }, expected: "glesys: some credentials information are missing: GLESYS_API_USER", }, { desc: "missing api key", envVars: map[string]string{ EnvAPIUser: "A", EnvAPIKey: "", }, expected: "glesys: some credentials information are missing: GLESYS_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.activeRecords) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiUser string apiKey string expected string }{ { desc: "success", apiUser: "A", apiKey: "B", }, { desc: "missing credentials", expected: "glesys: incomplete credentials provided", }, { desc: "missing api user", apiUser: "", apiKey: "B", expected: "glesys: incomplete credentials provided", }, { desc: "missing api key", apiUser: "A", apiKey: "", expected: "glesys: incomplete credentials provided", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.APIUser = test.apiUser p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.activeRecords) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/glesys/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // defaultBaseURL is the GleSYS API endpoint used by Present and CleanUp. const defaultBaseURL = "https://api.glesys.com/" type Client struct { apiUser string apiKey string baseURL *url.URL HTTPClient *http.Client } func NewClient(apiUser, apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiUser: apiUser, apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // AddTXTRecord adds a dns record to a domain. // https://github.com/GleSYS/API/wiki/API-Documentation#domainaddrecord func (c *Client) AddTXTRecord(ctx context.Context, domain, name, value string, ttl int) (int, error) { endpoint := c.baseURL.JoinPath("domain", "addrecord") request := addRecordRequest{ DomainName: domain, Host: name, Type: "TXT", Data: value, TTL: ttl, } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, request) if err != nil { return 0, err } response, err := c.do(req) if err != nil { return 0, err } if response != nil && response.Response.Status.Code == http.StatusOK { return response.Response.Record.RecordID, nil } return 0, err } // DeleteTXTRecord removes a dns record from a domain. // https://github.com/GleSYS/API/wiki/API-Documentation#domaindeleterecord func (c *Client) DeleteTXTRecord(ctx context.Context, recordID int) error { endpoint := c.baseURL.JoinPath("domain", "deleterecord") request := deleteRecordRequest{RecordID: recordID} req, err := newJSONRequest(ctx, http.MethodPost, endpoint, request) if err != nil { return err } _, err = c.do(req) return err } func (c *Client) do(req *http.Request) (*apiResponse, error) { req.SetBasicAuth(c.apiUser, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var response apiResponse err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &response, nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/glesys/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("user", "secret"), ) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /domain/addrecord", servermock.ResponseFromFixture("add-record.json"), servermock.CheckRequestJSONBody(`{"domainname":"example.com","host":"foo","type":"TXT","data":"txt","ttl":120}`)). Build(t) recordID, err := client.AddTXTRecord(t.Context(), "example.com", "foo", "txt", 120) require.NoError(t, err) assert.Equal(t, 123, recordID) } func TestClient_DeleteTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /domain/deleterecord", servermock.ResponseFromFixture("delete-record.json"), servermock.CheckRequestJSONBody(`{"recordid":123}`)). Build(t) err := client.DeleteTXTRecord(t.Context(), 123) require.NoError(t, err) } ================================================ FILE: providers/dns/glesys/internal/fixtures/add-record.json ================================================ { "response": { "status": { "code": 200 }, "record": { "recordid": 123 } } } ================================================ FILE: providers/dns/glesys/internal/fixtures/delete-record.json ================================================ { "response": { "status": { "code": 200 }, "record": { "recordid": 123 } } } ================================================ FILE: providers/dns/glesys/internal/types.go ================================================ package internal type addRecordRequest struct { DomainName string `json:"domainname"` Host string `json:"host"` Type string `json:"type"` Data string `json:"data"` TTL int `json:"ttl,omitempty"` } type deleteRecordRequest struct { RecordID int `json:"recordid"` } type apiResponse struct { Response Response `json:"response"` } type Response struct { Status Status `json:"status"` Record Record `json:"record"` } type Status struct { Code int `json:"code"` } type Record struct { RecordID int `json:"recordid"` } ================================================ FILE: providers/dns/godaddy/godaddy.go ================================================ // Package godaddy implements a DNS provider for solving the DNS-01 challenge using godaddy DNS. package godaddy import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/godaddy/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "GODADDY_" EnvAPIKey = envNamespace + "API_KEY" EnvAPISecret = envNamespace + "API_SECRET" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 600 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string APISecret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for godaddy. // Credentials must be passed in the environment variables: // GODADDY_API_KEY and GODADDY_API_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvAPISecret) if err != nil { return nil, fmt.Errorf("godaddy: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.APISecret = values[EnvAPISecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for godaddy. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("godaddy: the configuration of the DNS provider is nil") } if config.APIKey == "" || config.APISecret == "" { return nil, errors.New("godaddy: credentials missing") } if config.TTL < minTTL { return nil, fmt.Errorf("godaddy: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.APIKey, config.APISecret) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("godaddy: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("godaddy: %w", err) } ctx := context.Background() existingRecords, err := d.client.GetRecords(ctx, authZone, "TXT", subDomain) if err != nil { return fmt.Errorf("godaddy: failed to get TXT records: %w", err) } var newRecords []internal.DNSRecord for _, record := range existingRecords { if record.Data != "" { newRecords = append(newRecords, record) } } record := internal.DNSRecord{ Type: "TXT", Name: subDomain, Data: info.Value, TTL: d.config.TTL, } newRecords = append(newRecords, record) err = d.client.UpdateTxtRecords(ctx, newRecords, authZone, subDomain) if err != nil { return fmt.Errorf("godaddy: failed to add TXT record: %w", err) } return nil } // CleanUp removes the record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("godaddy: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("godaddy: %w", err) } ctx := context.Background() existingRecords, err := d.client.GetRecords(ctx, authZone, "TXT", subDomain) if err != nil { return fmt.Errorf("godaddy: failed to get all TXT records: %w", err) } var recordsToKeep []internal.DNSRecord for _, record := range existingRecords { if record.Data != info.Value && record.Data != "" { recordsToKeep = append(recordsToKeep, record) } } if len(recordsToKeep) == 0 { err = d.client.DeleteTxtRecords(ctx, authZone, subDomain) if err != nil { return fmt.Errorf("godaddy: failed to delete TXT record: %w", err) } return nil } err = d.client.UpdateTxtRecords(ctx, recordsToKeep, authZone, subDomain) if err != nil { return fmt.Errorf("godaddy: failed to remove TXT record: %w", err) } return nil } ================================================ FILE: providers/dns/godaddy/godaddy.toml ================================================ Name = "Go Daddy" Description = '''''' URL = "https://godaddy.com" Code = "godaddy" Since = "v0.5.0" Example = ''' GODADDY_API_KEY=xxxxxxxx \ GODADDY_API_SECRET=yyyyyyyy \ lego --dns godaddy -d '*.example.com' -d example.com run ''' Additional = ''' GoDaddy has recently (2024-04) updated the account requirements to access parts of their production Domains API: - Availability API: Limited to accounts with 50 or more domains. - Management and DNS APIs: Limited to accounts with 10 or more domains and/or an active Discount Domain Club plan. https://community.letsencrypt.org/t/getting-unauthorized-url-error-while-trying-to-get-cert-for-subdomains/217329/12 ''' [Configuration] [Configuration.Credentials] GODADDY_API_KEY = "API key" GODADDY_API_SECRET = "API secret" [Configuration.Additional] GODADDY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" GODADDY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" GODADDY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" GODADDY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.godaddy.com/doc/endpoint/domains" ================================================ FILE: providers/dns/godaddy/godaddy_test.go ================================================ package godaddy import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey, EnvAPISecret). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvAPISecret: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvAPISecret: "", }, expected: "godaddy: some credentials information are missing: GODADDY_API_KEY,GODADDY_API_SECRET", }, { desc: "missing access key", envVars: map[string]string{ EnvAPIKey: "", EnvAPISecret: "456", }, expected: "godaddy: some credentials information are missing: GODADDY_API_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAPIKey: "123", EnvAPISecret: "", }, expected: "godaddy: some credentials information are missing: GODADDY_API_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string apiSecret string expected string }{ { desc: "success", apiKey: "123", apiSecret: "456", }, { desc: "missing credentials", expected: "godaddy: credentials missing", }, { desc: "missing api key", apiSecret: "456", expected: "godaddy: credentials missing", }, { desc: "missing secret key", apiKey: "123", expected: "godaddy: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.APISecret = test.apiSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/godaddy/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultBaseURL represents the API endpoint to call. const DefaultBaseURL = "https://api.godaddy.com" const authorizationHeader = "Authorization" type Client struct { apiKey string apiSecret string baseURL *url.URL HTTPClient *http.Client } func NewClient(apiKey, apiSecret string) *Client { baseURL, _ := url.Parse(DefaultBaseURL) return &Client{ apiKey: apiKey, apiSecret: apiSecret, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetRecords retrieves DNS Records for the specified Domain. // https://developer.godaddy.com/doc/endpoint/domains#/v1/recordGet func (c *Client) GetRecords(ctx context.Context, domainZone, rType, recordName string) ([]DNSRecord, error) { endpoint := c.baseURL.JoinPath("v1", "domains", domainZone, "records", rType, recordName) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var records []DNSRecord err = c.do(req, &records) if err != nil { return nil, err } return records, nil } // UpdateTxtRecords replaces all DNS Records for the specified Domain with the specified Type. // https://developer.godaddy.com/doc/endpoint/domains#/v1/recordReplaceType func (c *Client) UpdateTxtRecords(ctx context.Context, records []DNSRecord, domainZone, recordName string) error { endpoint := c.baseURL.JoinPath("v1", "domains", domainZone, "records", "TXT", recordName) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, records) if err != nil { return err } return c.do(req, nil) } // DeleteTxtRecords deletes all DNS Records for the specified Domain with the specified Type and Name. // https://developer.godaddy.com/doc/endpoint/domains#/v1/recordDeleteTypeName func (c *Client) DeleteTxtRecords(ctx context.Context, domainZone, recordName string) error { endpoint := c.baseURL.JoinPath("v1", "domains", domainZone, "records", "TXT", recordName) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, fmt.Sprintf("sso-key %s:%s", c.apiKey, c.apiSecret)) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, &errAPI) } ================================================ FILE: providers/dns/godaddy/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("key", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("sso-key key:secret")) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/example.com/records/TXT/", servermock.ResponseFromFixture("getrecords.json")). Build(t) records, err := client.GetRecords(t.Context(), "example.com", "TXT", "") require.NoError(t, err) expected := []DNSRecord{ {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, {Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600}, {Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600}, } assert.Equal(t, expected, records) } func TestClient_GetRecords_errors(t *testing.T) { client := mockBuilder(). Route("GET /v1/domains/example.com/records/TXT/", servermock.ResponseFromFixture("errors.json").WithStatusCode(http.StatusUnprocessableEntity)). Build(t) records, err := client.GetRecords(t.Context(), "example.com", "TXT", "") require.EqualError(t, err, "[status code: 422] INVALID_BODY: Request body doesn't fulfill schema, see details in `fields`") assert.Nil(t, records) } func TestClient_UpdateTxtRecords(t *testing.T) { client := mockBuilder(). Route("PUT /v1/domains/example.com/records/TXT/lego", nil, servermock.CheckRequestJSONBodyFromFixture("update_records-request.json")). Build(t) records := []DNSRecord{ {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, {Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600}, {Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600}, } err := client.UpdateTxtRecords(t.Context(), records, "example.com", "lego") require.NoError(t, err) } func TestClient_UpdateTxtRecords_errors(t *testing.T) { client := mockBuilder(). Route("PUT /v1/domains/example.com/records/TXT/lego", servermock.ResponseFromFixture("errors.json").WithStatusCode(http.StatusUnprocessableEntity), servermock.CheckRequestJSONBodyFromFixture("update_records-request.json")). Build(t) records := []DNSRecord{ {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, {Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600}, {Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600}, {Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600}, } err := client.UpdateTxtRecords(t.Context(), records, "example.com", "lego") require.EqualError(t, err, "[status code: 422] INVALID_BODY: Request body doesn't fulfill schema, see details in `fields`") } func TestClient_DeleteTxtRecords(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/domains/example.com/records/TXT/foo", servermock.Noop().WithStatusCode(http.StatusNoContent)). Build(t) err := client.DeleteTxtRecords(t.Context(), "example.com", "foo") require.NoError(t, err) } func TestClient_DeleteTxtRecords_errors(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/domains/example.com/records/TXT/foo", servermock.ResponseFromFixture("error-extended.json").WithStatusCode(http.StatusConflict)). Build(t) err := client.DeleteTxtRecords(t.Context(), "example.com", "foo") require.EqualError(t, err, "[status code: 409] ACCESS_DENIED: Authenticated user is not allowed access [test: content (path=/foo) (pathRelated=/bar)]") } ================================================ FILE: providers/dns/godaddy/internal/fixtures/error-extended.json ================================================ { "code": "ACCESS_DENIED", "fields": [ { "code": "test", "message": "content", "path": "/foo", "pathRelated": "/bar" } ], "message": "Authenticated user is not allowed access" } ================================================ FILE: providers/dns/godaddy/internal/fixtures/errors.json ================================================ { "code": "INVALID_BODY", "message": "Request body doesn't fulfill schema, see details in `fields`" } ================================================ FILE: providers/dns/godaddy/internal/fixtures/getrecords.json ================================================ [ { "name":"_acme-challenge", "type":"TXT", "data":" ", "ttl":600 }, { "name":"_acme-challenge.example", "type":"TXT", "data":"6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", "ttl":600 }, { "name":"_acme-challenge.example", "type":"TXT", "data":"8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", "ttl":600 }, { "name":"_acme-challenge.lego", "type":"TXT", "data":" ", "ttl":600 }, { "name":"_acme-challenge.lego", "type":"TXT", "data":"0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", "ttl":600 }, { "name":"_acme-challenge.lego", "type":"TXT", "data":"acme", "ttl":600 } ] ================================================ FILE: providers/dns/godaddy/internal/fixtures/update_records-request.json ================================================ [ { "name": "_acme-challenge", "type": "TXT", "data": " ", "ttl": 600 }, { "name": "_acme-challenge.example", "type": "TXT", "data": "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", "ttl": 600 }, { "name": "_acme-challenge.example", "type": "TXT", "data": "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", "ttl": 600 }, { "name": "_acme-challenge.lego", "type": "TXT", "data": " ", "ttl": 600 }, { "name": "_acme-challenge.lego", "type": "TXT", "data": "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", "ttl": 600 }, { "name": "_acme-challenge.lego", "type": "TXT", "data": "acme", "ttl": 600 } ] ================================================ FILE: providers/dns/godaddy/internal/types.go ================================================ package internal import ( "fmt" "strings" ) // DNSRecord a DNS record. type DNSRecord struct { Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Data string `json:"data"` TTL int `json:"ttl,omitempty"` Priority int `json:"priority,omitempty"` Port int `json:"port,omitempty"` Protocol string `json:"protocol,omitempty"` Service string `json:"service,omitempty"` Weight int `json:"weight,omitempty"` } type APIError struct { Code string `json:"code,omitempty"` Fields []Field `json:"fields,omitempty"` Message string `json:"message,omitempty"` } func (a APIError) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "%s: %s", a.Code, a.Message) for _, field := range a.Fields { msg.WriteString(" ") msg.WriteString(field.String()) } return msg.String() } type Field struct { Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` Path string `json:"path,omitempty"` PathRelated string `json:"pathRelated,omitempty"` } func (f Field) String() string { msg := fmt.Sprintf("[%s: %s", f.Code, f.Message) if f.Path != "" { msg += fmt.Sprintf(" (path=%s)", f.Path) } if f.PathRelated != "" { msg += fmt.Sprintf(" (pathRelated=%s)", f.PathRelated) } msg += "]" return msg } ================================================ FILE: providers/dns/googledomains/googledomains.go ================================================ // Package googledomains implements a DNS provider for solving the DNS-01 challenge using Google Domains DNS API. package googledomains import ( "errors" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" ) // Environment variables names. const ( envNamespace = "GOOGLE_DOMAINS_" EnvAccessToken = envNamespace + "ACCESS_TOKEN" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AccessToken string PollingInterval time.Duration PropagationTimeout time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{} } type DNSProvider struct{} // NewDNSProvider returns the Google Domains DNS provider with a default configuration. func NewDNSProvider() (*DNSProvider, error) { return NewDNSProviderConfig(&Config{}) } // NewDNSProviderConfig returns the Google Domains DNS provider with the provided config. func NewDNSProviderConfig(_ *Config) (*DNSProvider, error) { return nil, errors.New("googledomains: provider has shut down") } func (d *DNSProvider) Present(_, _, _ string) error { return nil } func (d *DNSProvider) CleanUp(_, _, _ string) error { return nil } func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return dns01.DefaultPropagationTimeout, dns01.DefaultPollingInterval } ================================================ FILE: providers/dns/googledomains/googledomains.toml ================================================ Name = "Google Domains" Description = ''' The Google Domains DNS provider has shut down. ''' URL = "https://github.com/go-acme/lego/issues/2553" Code = "googledomains" Since = "v4.11.0" Example = ''' GOOGLE_DOMAINS_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns googledomains -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GOOGLE_DOMAINS_ACCESS_TOKEN = "Access token" [Configuration.Additional] GOOGLE_DOMAINS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" GOOGLE_DOMAINS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" GOOGLE_DOMAINS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] GoClient = "https://github.com/googleapis/google-api-go-client" ================================================ FILE: providers/dns/gravity/gravity.go ================================================ // Package gravity implements a DNS provider for solving the DNS-01 challenge using Gravity. package gravity import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/gravity/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/google/uuid" ) // Environment variables names. const ( envNamespace = "GRAVITY_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvServerURL = envNamespace + "SERVER_URL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string ServerURL string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 1*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client records map[string]internal.Record recordsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Gravity. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword, EnvServerURL) if err != nil { return nil, fmt.Errorf("gravity: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.ServerURL = values[EnvServerURL] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Gravity. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("gravity: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.ServerURL, config.Username, config.Password) if err != nil { return nil, fmt.Errorf("gravity: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, records: make(map[string]internal.Record), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) _, err := d.client.Login(ctx) if err != nil { return fmt.Errorf("gravity: login: %w", err) } zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("gravity: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("gravity: %w", err) } id := uuid.New() record := internal.Record{ Data: info.Value, Hostname: subDomain, Type: "TXT", UID: id.String(), } err = d.client.CreateDNSRecord(ctx, zone, record) if err != nil { return fmt.Errorf("gravity: create DNS record: %w", err) } d.recordsMu.Lock() record.Fqdn = zone d.records[token] = record d.recordsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordsMu.Lock() record, ok := d.records[token] d.recordsMu.Unlock() if !ok { return fmt.Errorf("gravity: unknown record for '%s' '%s'", info.EffectiveFQDN, token) } err := d.client.DeleteDNSRecord(context.Background(), record.Fqdn, record) if err != nil { return fmt.Errorf("gravity: delete record: %w", err) } d.recordsMu.Lock() delete(d.records, token) d.recordsMu.Unlock() return 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 } // Sequential implements the [dns01.sequential] interface. // It changes the behavior of the provider to resolve DNS challenges sequentially. // Returns the interval between each iteration. // // Gravity supports adding multiple records for the same domain, but the DNS server doesn't work as expected: // if you call the DNS server, it will answer only the latest record instead of all of them. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } func (d *DNSProvider) findZone(ctx context.Context, effectiveFQDN string) (string, error) { var zone string for fqdn := range dns01.DomainsSeq(effectiveFQDN) { zones, err := d.client.GetDNSZones(ctx, fqdn) if err != nil { return "", fmt.Errorf("get DNS zones: %w", err) } if len(zones) != 0 { zone = zones[0].Name break } } if zone == "" { return "", fmt.Errorf("could not find zone for %q", effectiveFQDN) } return zone, nil } ================================================ FILE: providers/dns/gravity/gravity.toml ================================================ Name = "Gravity" Description = '''''' URL = "https://gravity.beryju.io/" Code = "gravity" Since = "v4.30.0" Example = ''' GRAVITY_SERVER_URL="https://example.org:1234" \ GRAVITY_USERNAME="xxxxxxxxxxxxxxxxxxxxx" \ GRAVITY_PASSWORD="yyyyyyyyyyyyyyyyyyyyy" \ lego --dns gravity -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] GRAVITY_SERVER_URL = "URL of the server" GRAVITY_USERNAME = "Username" GRAVITY_PASSWORD = "Password" [Configuration.Additional] GRAVITY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" GRAVITY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" GRAVITY_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 1)" GRAVITY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://gravity.beryju.io/docs/api/reference/" ================================================ FILE: providers/dns/gravity/gravity_test.go ================================================ package gravity import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/gravity/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvPassword, EnvServerURL, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", EnvServerURL: "https://example.org:1234", }, }, { desc: "missing EnvUsername", envVars: map[string]string{ EnvUsername: "", EnvPassword: "secret", EnvServerURL: "https://example.org:1234", }, expected: "gravity: some credentials information are missing: GRAVITY_USERNAME", }, { desc: "missing EnvPassword", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "", EnvServerURL: "https://example.org:1234", }, expected: "gravity: some credentials information are missing: GRAVITY_PASSWORD", }, { desc: "missing EnvServerURL", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", EnvServerURL: "", }, expected: "gravity: some credentials information are missing: GRAVITY_SERVER_URL", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "gravity: some credentials information are missing: GRAVITY_USERNAME,GRAVITY_PASSWORD,GRAVITY_SERVER_URL", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string serverURL string expected string }{ { desc: "success", username: "user", password: "secret", serverURL: "https://example.org:1234", }, { desc: "missing username", username: "", password: "secret", serverURL: "https://example.org:1234", expected: "gravity: credentials missing", }, { desc: "missing password", username: "user", password: "", serverURL: "https://example.org:1234", expected: "gravity: credentials missing", }, { desc: "missing server URL", username: "user", password: "secret", serverURL: "", expected: "gravity: server URL missing", }, { desc: "missing credentials", expected: "gravity: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password config.ServerURL = test.serverURL p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Username = "user" config.Password = "secret" config.ServerURL = server.URL config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } return p, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /api/v1/auth/login", servermock.ResponseFromInternal("login.json"), servermock.CheckRequestJSONBodyFromInternal("login-request.json")). Route("GET /api/v1/dns/", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { if req.URL.Query().Get("name") != "example.com." { servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req) return } servermock.ResponseFromInternal("zones_empty.json").ServeHTTP(rw, req) }), ). Route("POST /api/v1/dns/zones/records", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckQueryParameter().Strict(). With("zone", "example.com."). WithRegexp("uid", `\w{8}-\w{4}-\w{4}-\w{4}-\w{12}`). With("hostname", "_acme-challenge")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /api/v1/dns/zones/records", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckQueryParameter().Strict(). With("zone", "example.com."). With("uid", "123"). With("type", "TXT"). With("hostname", "_acme-challenge")). Build(t) provider.records["abc"] = internal.Record{ Fqdn: "example.com.", Hostname: "_acme-challenge", Type: "TXT", UID: "123", } err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/gravity/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "golang.org/x/net/publicsuffix" ) // Client the Gravity API client. type Client struct { username string password string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(serverURL, username, password string) (*Client, error) { if username == "" || password == "" { return nil, errors.New("credentials missing") } if serverURL == "" { return nil, errors.New("server URL missing") } baseURL, err := url.Parse(serverURL) if err != nil { return nil, err } return &Client{ username: username, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) Login(ctx context.Context) (*Auth, error) { jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) if err != nil { return nil, err } c.HTTPClient.Jar = jar login := Login{ Username: c.username, Password: c.password, } endpoint := c.baseURL.JoinPath("api", "v1", "auth", "login") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, login) if err != nil { return nil, err } result := &Auth{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } func (c *Client) Me(ctx context.Context) (*UserInfo, error) { endpoint := c.baseURL.JoinPath("api", "v1", "auth", "me") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := &UserInfo{} err = c.do(req, result) if err != nil { return nil, err } return result, err } func (c *Client) GetDNSZones(ctx context.Context, name string) ([]Zone, error) { endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones") if name != "" { query := endpoint.Query() query.Set("name", name) endpoint.RawQuery = query.Encode() } req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := Zones{} err = c.do(req, &result) if err != nil { return nil, err } return result.Zones, nil } func (c *Client) CreateDNSRecord(ctx context.Context, zone string, record Record) error { endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones", "records") query := endpoint.Query() query.Set("zone", zone) query.Set("hostname", record.Hostname) // When the UID is the same as an existing one, the record is updated, else a new record is created. // An explicit UID is not required to create a record. if record.UID != "" { query.Set("uid", record.UID) } endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } func (c *Client) DeleteDNSRecord(ctx context.Context, zone string, record Record) error { endpoint := c.baseURL.JoinPath("api", "v1", "dns", "zones", "records") query := endpoint.Query() query.Set("zone", zone) query.Set("hostname", record.Hostname) query.Set("uid", record.UID) query.Set("type", record.Type) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/gravity/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestClient_Login(t *testing.T) { client := mockBuilder(). Route("POST /api/v1/auth/login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { http.SetCookie(rw, &http.Cookie{ Name: "gravity_session", Value: "session_id", Path: "/", }) servermock.ResponseFromFixture("login.json").ServeHTTP(rw, req) }), servermock.CheckRequestJSONBodyFromFixture("login-request.json")). Build(t) auth, err := client.Login(t.Context()) require.NoError(t, err) cookies := client.HTTPClient.Jar.Cookies(client.baseURL) require.Len(t, cookies, 1) assert.Equal(t, "gravity_session", cookies[0].Name) assert.Equal(t, "session_id", cookies[0].Value) expected := &Auth{Successful: true} assert.Equal(t, expected, auth) } func TestClient_Login_error(t *testing.T) { client := mockBuilder(). Route("POST /api/v1/auth/login", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.Login(t.Context()) require.EqualError(t, err, "status: UNAUTHENTICATED, error: unauthenticated, additionalProp1: string") } func TestClient_Me(t *testing.T) { client := mockBuilder(). Route("GET /api/v1/auth/me", servermock.ResponseFromFixture("me.json")). Build(t) info, err := client.Me(t.Context()) require.NoError(t, err) expected := &UserInfo{ Username: "admin", Authenticated: true, Permissions: []Permission{{ Methods: []string{"GET", "POST", "PUT", "HEAD", "DELETE"}, Path: "/*", }}, } assert.Equal(t, expected, info) } func TestClient_GetDNSZones(t *testing.T) { client := mockBuilder(). Route("GET /api/v1/dns/", servermock.ResponseFromFixture("zones.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com.")). Build(t) zones, err := client.GetDNSZones(t.Context(), "example.com.") require.NoError(t, err) expected := []Zone{{ Name: "example.com.", HandlerConfigs: []HandlerConfig{ {Type: "memory"}, {Type: "etcd"}, }, DefaultTTL: 86400, RecordCount: 1, }} assert.Equal(t, expected, zones) } func TestClient_CreateDNSRecord(t *testing.T) { client := mockBuilder(). Route("POST /api/v1/dns/zones/records", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json"), servermock.CheckQueryParameter().Strict(). With("zone", "example.com."). With("uid", "123"). With("hostname", "_acme-challenge")). Build(t) record := Record{ Data: "txtTXTtxt", Hostname: "_acme-challenge", Type: "TXT", UID: "123", } err := client.CreateDNSRecord(t.Context(), "example.com.", record) require.NoError(t, err) } func TestClient_DeleteDNSRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /api/v1/dns/zones/records", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckQueryParameter().Strict(). With("zone", "example.com."). With("uid", "123"). With("type", "TXT"). With("hostname", "_acme-challenge")). Build(t) record := Record{ Data: "txtTXTtxt", Hostname: "_acme-challenge", Type: "TXT", UID: "123", } err := client.DeleteDNSRecord(t.Context(), "example.com.", record) require.NoError(t, err) } ================================================ FILE: providers/dns/gravity/internal/fixtures/create_record-request.json ================================================ { "data": "txtTXTtxt", "hostname": "_acme-challenge", "type": "TXT", "uid": "123" } ================================================ FILE: providers/dns/gravity/internal/fixtures/error.json ================================================ { "code": 0, "context": { "additionalProp1": "string" }, "error": "unauthenticated", "status": "UNAUTHENTICATED" } ================================================ FILE: providers/dns/gravity/internal/fixtures/login-request.json ================================================ { "username": "user", "password": "secret" } ================================================ FILE: providers/dns/gravity/internal/fixtures/login.json ================================================ { "successful": true } ================================================ FILE: providers/dns/gravity/internal/fixtures/me.json ================================================ { "username": "admin", "authenticated": true, "permissions": [ { "path": "/*", "methods": [ "GET", "POST", "PUT", "HEAD", "DELETE" ] } ] } ================================================ FILE: providers/dns/gravity/internal/fixtures/me_unauthenticated.json ================================================ { "username": "", "authenticated": false, "permissions": null } ================================================ FILE: providers/dns/gravity/internal/fixtures/zones.json ================================================ { "zones": [ { "name": "example.com.", "handlerConfigs": [ { "type": "memory" }, { "type": "etcd" } ], "defaultTTL": 86400, "authoritative": false, "hook": "", "recordCount": 1 } ] } ================================================ FILE: providers/dns/gravity/internal/fixtures/zones_empty.json ================================================ { "zones": null } ================================================ FILE: providers/dns/gravity/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type APIError struct { Status string `json:"status"` ErrorMsg string `json:"error"` Code int `json:"code"` Context map[string]string `json:"context"` } func (a *APIError) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "status: %s, error: %s", a.Status, a.ErrorMsg) if a.Code != 0 { _, _ = fmt.Fprintf(msg, ", code: %d", a.Code) } if len(a.Context) != 0 { for k, v := range a.Context { _, _ = fmt.Fprintf(msg, ", %s: %s", k, v) } } return msg.String() } type Login struct { Username string `json:"username"` Password string `json:"password"` } type Auth struct { Successful bool `json:"successful"` } type UserInfo struct { Username string `json:"username"` Authenticated bool `json:"authenticated"` Permissions []Permission `json:"permissions"` } type Permission struct { Methods []string `json:"methods"` Path string `json:"path"` } type Zones struct { Zones []Zone `json:"zones"` } type Zone struct { Name string `json:"name"` HandlerConfigs []HandlerConfig `json:"handlerConfigs"` DefaultTTL int `json:"defaultTTL"` Authoritative bool `json:"authoritative"` Hook string `json:"hook"` RecordCount int `json:"recordCount"` } type HandlerConfig struct { Type string `json:"type"` CacheTTL int `json:"cache_ttl,omitempty"` To []string `json:"to,omitempty"` } type Record struct { Data string `json:"data,omitempty"` Fqdn string `json:"fqdn,omitempty"` Hostname string `json:"hostname,omitempty"` MxPreference int `json:"mxPreference,omitempty"` SrvPort int `json:"srvPort,omitempty"` SrvPriority int `json:"srvPriority,omitempty"` SrvWeight int `json:"srvWeight,omitempty"` Type string `json:"type,omitempty"` UID string `json:"uid,omitempty"` } ================================================ FILE: providers/dns/hetzner/hetzner.go ================================================ // Package hetzner implements a DNS provider for solving the DNS-01 challenge using Hetzner DNS. package hetzner import ( "errors" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1" "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy" ) // Environment variables names. const ( // Deprecated: use EnvAPIToken instead. EnvAPIKey = legacy.EnvAPIKey EnvAPIToken = hetznerv1.EnvAPIToken EnvTTL = hetznerv1.EnvTTL EnvPropagationTimeout = hetznerv1.EnvPropagationTimeout EnvPollingInterval = hetznerv1.EnvPollingInterval EnvHTTPTimeout = hetznerv1.EnvHTTPTimeout ) const minTTL = 60 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { // Deprecated: use APIToken instead APIKey string APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), 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 { provider challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for hetzner. func NewDNSProvider() (*DNSProvider, error) { foundAPIToken := env.GetOrFile(EnvAPIToken) != "" foundAPIKey := env.GetOrFile(EnvAPIKey) != "" switch { case foundAPIToken: provider, err := hetznerv1.NewDNSProvider() if err != nil { return nil, err } return &DNSProvider{provider: provider}, nil case foundAPIKey: log.Warnf("APIKey (legacy Hetzner DNS API) is deprecated, please use APIToken (Hetzner Cloud API) instead.") provider, err := legacy.NewDNSProvider() if err != nil { return nil, err } return &DNSProvider{provider: provider}, nil default: provider, err := hetznerv1.NewDNSProvider() if err != nil { return nil, err } return &DNSProvider{provider: provider}, nil } } // NewDNSProviderConfig return a DNSProvider instance configured for hetzner. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("hetzner: the configuration of the DNS provider is nil") } switch { case config.APIToken != "": cfg := &hetznerv1.Config{ APIToken: config.APIToken, PropagationTimeout: config.PropagationTimeout, PollingInterval: config.PollingInterval, TTL: config.TTL, HTTPClient: config.HTTPClient, } provider, err := hetznerv1.NewDNSProviderConfig(cfg) if err != nil { return nil, err } return &DNSProvider{provider: provider}, nil case config.APIKey != "": log.Warnf("%s (legacy Hetzner DNS API) is deprecated, please use %s (Hetzner Cloud API) instead.", EnvAPIKey, EnvAPIToken) cfg := &legacy.Config{ APIKey: config.APIKey, PropagationTimeout: config.PropagationTimeout, PollingInterval: config.PollingInterval, TTL: config.TTL, HTTPClient: config.HTTPClient, } provider, err := legacy.NewDNSProviderConfig(cfg) if err != nil { return nil, err } return &DNSProvider{provider: provider}, nil } return nil, errors.New("hetzner: credentials missing") } // 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.provider.Timeout() } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { return d.provider.Present(domain, token, keyAuth) } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return d.provider.CleanUp(domain, token, keyAuth) } ================================================ FILE: providers/dns/hetzner/hetzner.toml ================================================ Name = "Hetzner" Description = '''''' URL = "https://hetzner.com" Code = "hetzner" Since = "v3.7.0" Example = ''' HETZNER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns hetzner -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] HETZNER_API_TOKEN = "API token" [Configuration.Additional] HETZNER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HETZNER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" HETZNER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" HETZNER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.hetzner.cloud/reference/cloud#dns" ================================================ FILE: providers/dns/hetzner/hetzner_test.go ================================================ package hetzner import ( "testing" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1" "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAPIKey, EnvAPIToken) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expectedProvider challenge.ProviderTimeout expectedError string }{ { desc: "success (v1)", envVars: map[string]string{ EnvAPIToken: "123", }, expectedProvider: &hetznerv1.DNSProvider{}, }, { desc: "success (legacy)", envVars: map[string]string{ EnvAPIKey: "123", }, expectedProvider: &legacy.DNSProvider{}, }, { desc: "success (both)", envVars: map[string]string{ EnvAPIKey: "123", EnvAPIToken: "123", }, expectedProvider: &hetznerv1.DNSProvider{}, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvAPIToken: "", }, expectedError: "hetzner: some credentials information are missing: HETZNER_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expectedError == "" { require.NoError(t, err) assert.IsType(t, test.expectedProvider, p.provider) require.NotNil(t, p) } else { require.EqualError(t, err, test.expectedError) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string apiToken string ttl int expectedProvider challenge.ProviderTimeout expectedError string }{ { desc: "success (v1)", ttl: minTTL, apiToken: "123", expectedProvider: &hetznerv1.DNSProvider{}, }, { desc: "success (legacy)", ttl: minTTL, apiKey: "456", expectedProvider: &legacy.DNSProvider{}, }, { desc: "success (both)", ttl: minTTL, apiToken: "123", apiKey: "456", expectedProvider: &hetznerv1.DNSProvider{}, }, { desc: "missing credentials", ttl: minTTL, expectedError: "hetzner: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken config.APIKey = test.apiKey config.TTL = test.ttl p, err := NewDNSProviderConfig(config) if test.expectedError == "" { require.NoError(t, err) assert.IsType(t, test.expectedProvider, p.provider) } else { require.EqualError(t, err, test.expectedError) } }) } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records-request.json ================================================ { "ttl": 120, "records": [ { "value": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"" } ] } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/fixtures/add_rrset_records.json ================================================ { "action": { "id": 1, "command": "add_rrset_records", "status": "running", "progress": 50, "started": "2016-01-30T23:55:00+00:00", "finished": null, "resources": [ { "id": 42, "type": "zone" } ], "error": null } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_error.json ================================================ { "action": { "id": 1, "command": "remove_rrset_records", "status": "error", "started": "2016-01-30T23:55:00+00:00", "finished": "2016-01-30T23:55:00+00:00", "progress": 50, "resources": [ { "id": 42, "type": "zone" } ], "error": { "code": "action_failed", "message": "Action failed" } } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_running.json ================================================ { "action": { "id": 1, "command": "remove_rrset_records", "status": "running", "started": "2016-01-30T23:55:00+00:00", "finished": "2016-01-30T23:55:00+00:00", "progress": 50, "resources": [ { "id": 42, "type": "zone" } ] } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/fixtures/get_action_success.json ================================================ { "action": { "id": 1, "command": "remove_rrset_records", "status": "success", "started": "2016-01-30T23:55:00+00:00", "finished": "2016-01-30T23:55:00+00:00", "progress": 100, "resources": [ { "id": 42, "type": "zone" } ] } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records-request.json ================================================ { "records": [ { "value": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"" } ] } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/fixtures/remove_rrset_records.json ================================================ { "action": { "id": 1, "command": "remove_rrset_records", "status": "running", "progress": 50, "started": "2016-01-30T23:55:00+00:00", "finished": null, "resources": [ { "id": 42, "type": "zone" } ], "error": null } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/hetznerv1.go ================================================ // Package hetznerv1 implements a DNS provider for solving the DNS-01 challenge using Hetzner. package hetznerv1 import ( "context" "errors" "fmt" "net/http" "strconv" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/hetznerv1/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "golang.org/x/net/idna" ) // Environment variables names. const ( envNamespace = "HETZNER_" EnvAPIToken = envNamespace + "API_TOKEN" 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 { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Hetzner. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("hetzner: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Hetzner. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("hetzner: the configuration of the DNS provider is nil") } if config.APIToken == "" { return nil, errors.New("hetzner: credentials missing") } client, err := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.APIToken), ), ) if err != nil { return nil, fmt.Errorf("hetzner: %w", err) } return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("hetzner: %w", err) } subDomainPunnycoded, err := idna.ToASCII(dns01.UnFqdn(subDomain)) if err != nil { return fmt.Errorf("hetzner: %w", err) } zone, err := idna.ToASCII(dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("hetzner: %w", err) } records := []internal.Record{{Value: strconv.Quote(info.Value)}} action, err := d.client.AddRRSetRecords(ctx, zone, "TXT", subDomainPunnycoded, d.config.TTL, records) if err != nil { return fmt.Errorf("hetzner: add RRSet records: %w", err) } err = d.waitAction(ctx, action.ID) if err != nil { return fmt.Errorf("hetzner: wait (add RRSet records): %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hetzner: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("hetzner: %w", err) } subDomainPunnycoded, err := idna.ToASCII(dns01.UnFqdn(subDomain)) if err != nil { return fmt.Errorf("hetzner: %w", err) } zone, err := idna.ToASCII(dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("hetzner: %w", err) } records := []internal.Record{{Value: strconv.Quote(info.Value)}} action, err := d.client.RemoveRRSetRecords(ctx, zone, "TXT", subDomainPunnycoded, records) if err != nil { return fmt.Errorf("hetzner: remove RRSet records: %w", err) } err = d.waitAction(ctx, action.ID) if err != nil { return fmt.Errorf("hetzner: wait (remove RRSet records): %w", err) } return 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 } func (d *DNSProvider) waitAction(ctx context.Context, actionID int64) error { return wait.Retry(ctx, func() error { result, err := d.client.GetAction(ctx, actionID) if err != nil { return backoff.Permanent(fmt.Errorf("get action %d: %w", actionID, err)) } switch result.Status { case internal.StatusRunning: return fmt.Errorf("action %d is %s", actionID, internal.StatusRunning) case internal.StatusError: return backoff.Permanent(fmt.Errorf("action %d: %s: %w", actionID, internal.StatusError, result.ErrorInfo)) default: return nil } }, backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), backoff.WithMaxElapsedTime(d.config.PropagationTimeout), ) } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/hetznerv1_test.go ================================================ package hetznerv1 import ( "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "hetzner: some credentials information are missing: HETZNER_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "secret", }, { desc: "missing credentials", expected: "hetzner: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIToken = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records", servermock.ResponseFromFixture("add_rrset_records.json"), servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")). Route("GET /actions/1", servermock.ResponseFromFixture("get_action_success.json")). Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err) } func TestDNSProvider_Present_error(t *testing.T) { provider := mockBuilder(). Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records", servermock.ResponseFromFixture("add_rrset_records.json"), servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")). Route("GET /actions/1", servermock.ResponseFromFixture("get_action_error.json")). Build(t) provider.config.PollingInterval = 20 * time.Millisecond provider.config.PropagationTimeout = 1 * time.Second err := provider.Present("example.com", "", "foobar") require.EqualError(t, err, "hetzner: wait (add RRSet records): action 1: error: action_failed: Action failed") } func TestDNSProvider_Present_running(t *testing.T) { provider := mockBuilder(). Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/add_records", servermock.ResponseFromFixture("add_rrset_records.json"), servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")). Route("GET /actions/1", servermock.ResponseFromFixture("get_action_running.json")). Build(t) provider.config.PollingInterval = 20 * time.Millisecond provider.config.PropagationTimeout = 1 * time.Second err := provider.Present("example.com", "", "foobar") require.EqualError(t, err, "hetzner: wait (add RRSet records): action 1 is running") } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records", servermock.ResponseFromFixture("remove_rrset_records.json"), servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")). Route("GET /actions/1", servermock.ResponseFromFixture("get_action_success.json")). Build(t) err := provider.CleanUp("example.com", "", "foobar") require.NoError(t, err) } func TestDNSProvider_CleanUp_error(t *testing.T) { provider := mockBuilder(). Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records", servermock.ResponseFromFixture("remove_rrset_records.json"), servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")). Route("GET /actions/1", servermock.ResponseFromFixture("get_action_error.json")). Build(t) provider.config.PollingInterval = 20 * time.Millisecond provider.config.PropagationTimeout = 1 * time.Second err := provider.CleanUp("example.com", "", "foobar") require.EqualError(t, err, "hetzner: wait (remove RRSet records): action 1: error: action_failed: Action failed") } func TestDNSProvider_CleanUp_running(t *testing.T) { provider := mockBuilder(). Route("POST /zones/example.com/rrsets/_acme-challenge/TXT/actions/remove_records", servermock.ResponseFromFixture("remove_rrset_records.json"), servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")). Route("GET /actions/1", servermock.ResponseFromFixture("get_action_running.json")). Build(t) provider.config.PollingInterval = 20 * time.Millisecond provider.config.PropagationTimeout = 1 * time.Second err := provider.CleanUp("example.com", "", "foobar") require.EqualError(t, err, "hetzner: wait (remove RRSet records): action 1 is running") } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) const defaultBaseURL = "https://api.hetzner.cloud/v1" const ( StatusRunning = "running" StatusSuccess = "success" StatusError = "error" ) // Client the Hetzner API client. type Client struct { BaseURL *url.URL httpClient *http.Client } // NewClient creates a new Client. func NewClient(hc *http.Client) (*Client, error) { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } return &Client{ BaseURL: baseURL, httpClient: hc, }, nil } // AddRRSetRecords adds records to an RRSet. // https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset func (c *Client) AddRRSetRecords(ctx context.Context, zoneIDName, recordType, recordName string, ttl int, records []Record) (*Action, error) { endpoint := c.BaseURL.JoinPath("zones", zoneIDName, "rrsets", recordName, recordType, "actions", "add_records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, RRSet{TTL: ttl, Records: records}) if err != nil { return nil, err } var result ActionResponse err = c.do(req, &result) if err != nil { return nil, err } return result.Action, nil } // RemoveRRSetRecords removes records from an RRSet. // https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset func (c *Client) RemoveRRSetRecords(ctx context.Context, zoneIDName, recordType, recordName string, records []Record) (*Action, error) { endpoint := c.BaseURL.JoinPath("zones", zoneIDName, "rrsets", recordName, recordType, "actions", "remove_records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, RRSet{Records: records}) if err != nil { return nil, err } var result ActionResponse err = c.do(req, &result) if err != nil { return nil, err } return result.Action, nil } // GetAction gets an action. // https://docs.hetzner.cloud/reference/cloud#actions-get-an-action func (c *Client) GetAction(ctx context.Context, id int64) (*Action, error) { endpoint := c.BaseURL.JoinPath("actions", strconv.FormatInt(id, 10)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result ActionResponse err = c.do(req, &result) if err != nil { return nil, err } return result.Action, nil } func (c *Client) do(req *http.Request, result any) error { resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestClient_AddRRSetRecords(t *testing.T) { client := mockBuilder(). Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records", servermock.ResponseFromFixture("add_rrset_records.json"), servermock.CheckRequestJSONBodyFromFixture("add_rrset_records-request.json")). Build(t) records := []Record{{ Value: "198.51.100.1", Comment: "My web server at Hetzner Cloud.", }} result, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 3600, records) require.NoError(t, err) expected := &Action{ ID: 1, Command: "add_rrset_records", Status: "running", Progress: 50, Resources: []Resources{{ID: 590000000000000, Type: "zone"}}, } assert.Equal(t, expected, result) } func TestClient_AddRRSetRecords_error_invalid_input(t *testing.T) { client := mockBuilder(). Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records", servermock.ResponseFromFixture("error-invalid_input.json"). WithStatusCode(http.StatusBadRequest)). Build(t) records := []Record{{ Value: "198.51.100.1", Comment: "My web server at Hetzner Cloud.", }} _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records) require.EqualError(t, err, "invalid_input: invalid input in field 'broken_field': is too longfield: broken_field: is too long") } func TestClient_AddRRSetRecords_error_resource_limit_exceeded(t *testing.T) { client := mockBuilder(). Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records", servermock.ResponseFromFixture("error-resource_limit_exceeded.json"). WithStatusCode(http.StatusBadRequest)). Build(t) records := []Record{{ Value: "198.51.100.1", Comment: "My web server at Hetzner Cloud.", }} _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records) require.EqualError(t, err, "resource_limit_exceeded: project limit exceededlimit: project_limit") } func TestClient_AddRRSetRecords_error_deprecated_api_endpoint(t *testing.T) { client := mockBuilder(). Route("POST /zones/example.com/rrsets/www/TXT/actions/add_records", servermock.ResponseFromFixture("error-deprecated_api_endpoint.json"). WithStatusCode(http.StatusBadRequest)). Build(t) records := []Record{{ Value: "198.51.100.1", Comment: "My web server at Hetzner Cloud.", }} _, err := client.AddRRSetRecords(t.Context(), "example.com", "TXT", "www", 0, records) require.EqualError(t, err, "deprecated_api_endpoint: API functionality was removed: https://docs.hetzner.cloud/changelog#2023-07-20-foo-endpoint-is-deprecated") } func TestClient_RemoveRRSetRecords(t *testing.T) { client := mockBuilder(). Route("POST /zones/example.com/rrsets/www/TXT/actions/remove_records", servermock.ResponseFromFixture("remove_rrset_records.json"), servermock.CheckRequestJSONBodyFromFixture("remove_rrset_records-request.json")). Build(t) records := []Record{{ Value: "198.51.100.1", Comment: "My web server at Hetzner Cloud.", }} result, err := client.RemoveRRSetRecords(t.Context(), "example.com", "TXT", "www", records) require.NoError(t, err) expected := &Action{ ID: 1, Command: "remove_rrset_records", Status: "running", Progress: 50, Resources: []Resources{{ID: 42, Type: "zone"}}, } assert.Equal(t, expected, result) } func TestClient_GetAction(t *testing.T) { client := mockBuilder(). Route("GET /actions/123", servermock.ResponseFromFixture("get_action.json")). Route("/", servermock.DumpRequest()). Build(t) result, err := client.GetAction(t.Context(), 123) require.NoError(t, err) expected := &Action{ ID: 590000000000000, Command: "start_resource", Status: "running", Progress: 100, Resources: []Resources{{ID: 590000000000000, Type: "server"}}, ErrorInfo: &ErrorInfo{ Code: "action_failed", Message: "Action failed", }, } assert.Equal(t, expected, result) } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records-request.json ================================================ { "ttl": 3600, "records": [ { "value": "198.51.100.1", "comment": "My web server at Hetzner Cloud." } ] } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/fixtures/add_rrset_records.json ================================================ { "action": { "id": 1, "command": "add_rrset_records", "status": "running", "progress": 50, "started": "2016-01-30T23:55:00+00:00", "finished": null, "resources": [ { "id": 590000000000000, "type": "zone" } ], "error": null } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-deprecated_api_endpoint.json ================================================ { "error": { "code": "deprecated_api_endpoint", "message": "API functionality was removed", "details": { "announcement": "https://docs.hetzner.cloud/changelog#2023-07-20-foo-endpoint-is-deprecated" } } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-invalid_input.json ================================================ { "error": { "code": "invalid_input", "message": "invalid input in field 'broken_field': is too long", "details": { "fields": [ { "name": "broken_field", "messages": [ "is too long" ] } ] } } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/fixtures/error-resource_limit_exceeded.json ================================================ { "error": { "code": "resource_limit_exceeded", "message": "project limit exceeded", "details": { "limits": [ { "name": "project_limit" } ] } } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/fixtures/get_action.json ================================================ { "action": { "id": 590000000000000, "command": "start_resource", "status": "running", "started": "2016-01-30T23:55:00+00:00", "finished": "2016-01-30T23:55:00+00:00", "progress": 100, "resources": [ { "id": 590000000000000, "type": "server" } ], "error": { "code": "action_failed", "message": "Action failed" } } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records-request.json ================================================ { "records": [ { "value": "198.51.100.1", "comment": "My web server at Hetzner Cloud." } ] } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/fixtures/remove_rrset_records.json ================================================ { "action": { "id": 1, "command": "remove_rrset_records", "status": "running", "progress": 50, "started": "2016-01-30T23:55:00+00:00", "finished": null, "resources": [ { "id": 42, "type": "zone" } ], "error": null } } ================================================ FILE: providers/dns/hetzner/internal/hetznerv1/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type APIError struct { ErrorInfo ErrorInfo `json:"error"` } type ErrorInfo struct { Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` Details ErrorDetails `json:"details"` } func (i *ErrorInfo) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "%s: %s", i.Code, i.Message) if i.Details.Announcement != "" { _, _ = fmt.Fprintf(msg, ": %s", i.Details.Announcement) } for _, limit := range i.Details.Limits { _, _ = fmt.Fprintf(msg, "limit: %s", limit.Name) } for _, field := range i.Details.Fields { _, _ = fmt.Fprintf(msg, "field: %s: %s", field.Name, strings.Join(field.Messages, ", ")) } return msg.String() } type ErrorDetails struct { Announcement string `json:"announcement,omitempty"` Limits []LimitError `json:"limits,omitempty"` Fields []FieldError `json:"fields,omitempty"` } type FieldError struct { Name string `json:"name,omitempty"` Messages []string `json:"messages,omitempty"` } type LimitError struct { Name string `json:"name,omitempty"` } func (a *APIError) Error() string { return a.ErrorInfo.Error() } type RRSet struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` TTL int `json:"ttl,omitempty"` Labels map[string]string `json:"labels,omitempty"` Protection *Protection `json:"protection,omitempty"` Records []Record `json:"records,omitempty"` ZoneID int `json:"zone,omitempty"` } type Protection struct { Change bool `json:"change,omitempty"` } type Record struct { Value string `json:"value,omitempty"` Comment string `json:"comment,omitempty"` } type ActionResponse struct { Action *Action `json:"action,omitempty"` } type Action struct { ID int64 `json:"id,omitempty"` Command string `json:"command,omitempty"` // It can be: `running`, `success`, `error`. // https://docs.hetzner.cloud/reference/cloud#zone-actions-get-an-action // https://docs.hetzner.cloud/reference/cloud#zone-actions Status string `json:"status,omitempty"` Progress int `json:"progress,omitempty"` Resources []Resources `json:"resources,omitempty"` ErrorInfo *ErrorInfo `json:"error,omitempty"` } type Resources struct { ID int64 `json:"id,omitempty"` Type string `json:"type,omitempty"` } ================================================ FILE: providers/dns/hetzner/internal/legacy/hetzner.go ================================================ // Package legacy implements a DNS provider for solving the DNS-01 challenge using Hetzner DNS. package legacy import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/hetzner/internal/legacy/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "HETZNER_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 60 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for hetzner. // Credentials must be passed in the environment variable: HETZNER_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("hetzner (legacy): %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for hetzner. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("hetzner (legacy): the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("hetzner (legacy): credentials missing") } if config.TTL < minTTL { return nil, fmt.Errorf("hetzner (legacy): invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hetzner (legacy): could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) ctx := context.Background() zoneID, err := d.client.GetZoneID(ctx, zone) if err != nil { return fmt.Errorf("hetzner (legacy): %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("hetzner (legacy): %w", err) } record := internal.DNSRecord{ Type: "TXT", Name: subDomain, Value: info.Value, TTL: d.config.TTL, ZoneID: zoneID, } if err := d.client.CreateRecord(ctx, record); err != nil { return fmt.Errorf("hetzner (legacy): failed to add TXT record: fqdn=%s, zoneID=%s: %w", info.EffectiveFQDN, zoneID, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hetzner (legacy): could not find zone for domain %q: %w", domain, err) } zone := dns01.UnFqdn(authZone) ctx := context.Background() zoneID, err := d.client.GetZoneID(ctx, zone) if err != nil { return fmt.Errorf("hetzner (legacy): %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("hetzner (legacy): %w", err) } record, err := d.client.GetTxtRecord(ctx, subDomain, info.Value, zoneID) if err != nil { return fmt.Errorf("hetzner (legacy): %w", err) } if err := d.client.DeleteRecord(ctx, record.ID); err != nil { return fmt.Errorf("hetzner (legacy): failed to delete TXT record: id=%s, name=%s: %w", record.ID, record.Name, err) } return nil } ================================================ FILE: providers/dns/hetzner/internal/legacy/hetzner_test.go ================================================ package legacy import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", }, expected: "hetzner (legacy): some credentials information are missing: HETZNER_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string ttl int expected string }{ { desc: "success", ttl: minTTL, apiKey: "123", }, { desc: "missing credentials", ttl: minTTL, expected: "hetzner (legacy): credentials missing", }, { desc: "invalid TTL", apiKey: "123", ttl: 10, expected: "hetzner (legacy): invalid TTL, TTL (10) must be greater than 60", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.TTL = test.ttl p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/hetzner/internal/legacy/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // defaultBaseURL represents the API endpoint to call. const defaultBaseURL = "https://dns.hetzner.com" const authHeader = "Auth-API-Token" // Client the Hetzner client. type Client struct { apiKey string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Hetzner client. func NewClient(apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetTxtRecord gets a TXT record. func (c *Client) GetTxtRecord(ctx context.Context, name, value, zoneID string) (*DNSRecord, error) { records, err := c.getRecords(ctx, zoneID) if err != nil { return nil, err } for _, record := range records.Records { if record.Type == "TXT" && record.Name == name && record.Value == value { return &record, nil } } return nil, fmt.Errorf("could not find record: zone ID: %s; Record: %s", zoneID, name) } // https://dns.hetzner.com/api-docs#operation/GetRecords func (c *Client) getRecords(ctx context.Context, zoneID string) (*DNSRecords, error) { endpoint := c.baseURL.JoinPath("api", "v1", "records") query := endpoint.Query() query.Set("zone_id", zoneID) endpoint.RawQuery = query.Encode() req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } records := &DNSRecords{} err = json.Unmarshal(raw, records) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return records, nil } // CreateRecord creates a DNS record. // https://dns.hetzner.com/api-docs#operation/CreateRecord func (c *Client) CreateRecord(ctx context.Context, record DNSRecord) error { endpoint := c.baseURL.JoinPath("api", "v1", "records") req, err := c.newRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } // DeleteRecord deletes a DNS record. // https://dns.hetzner.com/api-docs#operation/DeleteRecord func (c *Client) DeleteRecord(ctx context.Context, recordID string) error { endpoint := c.baseURL.JoinPath("api", "v1", "records", recordID) req, err := c.newRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } // GetZoneID gets the zone ID for a domain. func (c *Client) GetZoneID(ctx context.Context, domain string) (string, error) { zones, err := c.getZones(ctx, domain) if err != nil { return "", err } for _, zone := range zones.Zones { if zone.Name == domain { return zone.ID, nil } } return "", fmt.Errorf("could not get zone for domain %s not found", domain) } // https://dns.hetzner.com/api-docs#operation/GetZones func (c *Client) getZones(ctx context.Context, name string) (*Zones, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones") query := endpoint.Query() query.Set("name", name) endpoint.RawQuery = query.Encode() req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("could not get zones: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() // EOF fallback if resp.StatusCode == http.StatusNotFound { return &Zones{}, nil } if resp.StatusCode != http.StatusOK { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } zones := &Zones{} err = json.Unmarshal(raw, zones) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return zones, nil } func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set(authHeader, c.apiKey) return req, nil } ================================================ FILE: providers/dns/hetzner/internal/legacy/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder(apiKey string) *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(apiKey) client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With(authHeader, apiKey)) } func TestClient_GetTxtRecord(t *testing.T) { const zoneID = "zoneA" client := mockBuilder("myKeyA"). Route("GET /api/v1/records", servermock.ResponseFromFixture("get_txt_record.json"), servermock.CheckQueryParameter().Strict(). With("zone_id", zoneID)). Build(t) record, err := client.GetTxtRecord(t.Context(), "test1", "txttxttxt", zoneID) require.NoError(t, err) expected := &DNSRecord{ ID: "1b", Name: "test1", Type: "TXT", Value: "txttxttxt", Priority: 0, TTL: 600, ZoneID: "zoneA", } assert.Equal(t, expected, record) } func TestClient_CreateRecord(t *testing.T) { const zoneID = "zoneA" client := mockBuilder("myKeyB"). Route("POST /api/v1/records", servermock.ResponseFromFixture("create_txt_record.json"), servermock.CheckRequestJSONBodyFromFixture("create_txt_record-request.json")). Build(t) record := DNSRecord{ Name: "test", Type: "TXT", Value: "txttxttxt", TTL: 600, ZoneID: zoneID, } err := client.CreateRecord(t.Context(), record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder("myKeyC"). Route("DELETE /api/v1/records/recordID", nil). Build(t) err := client.DeleteRecord(t.Context(), "recordID") require.NoError(t, err) } func TestClient_GetZoneID(t *testing.T) { client := mockBuilder("myKeyD"). Route("GET /api/v1/zones", servermock.ResponseFromFixture("get_zone_id.json")). Build(t) zoneID, err := client.GetZoneID(t.Context(), "example.com") require.NoError(t, err) assert.Equal(t, "zoneA", zoneID) } ================================================ FILE: providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record-request.json ================================================ { "name": "test", "type": "TXT", "value": "txttxttxt", "ttl": 600, "zone_id": "zoneA" } ================================================ FILE: providers/dns/hetzner/internal/legacy/internal/fixtures/create_txt_record.json ================================================ { "record": { "type": "A", "id": "string", "created": "2020-05-08T10:49:18Z", "modified": "2020-05-08T10:49:18Z", "zone_id": "string", "name": "string", "value": "string", "ttl": 0 } } ================================================ FILE: providers/dns/hetzner/internal/legacy/internal/fixtures/get_txt_record.json ================================================ { "records": [ { "type": "A", "id": "1a", "created": "2020-05-08T10:49:18Z", "modified": "2020-05-08T10:49:18Z", "zone_id": "zoneA", "name": "test", "value": "10.10.10.10", "ttl": 600 }, { "type": "TXT", "id": "1b", "created": "2020-05-08T10:49:19Z", "modified": "2020-05-08T10:49:19Z", "zone_id": "zoneA", "name": "test1", "value": "txttxttxt", "ttl": 600 } ], "meta": { "pagination": { "page": 1, "per_page": 20, "last_page": 1, "total_entries": 2 } } } ================================================ FILE: providers/dns/hetzner/internal/legacy/internal/fixtures/get_zone_id.json ================================================ { "zones": [ { "id": "zoneA", "created": "2020-05-08T10:49:18Z", "modified": "2020-05-08T10:49:18Z", "legacy_dns_host": "string", "legacy_ns": [ "string" ], "name": "example.com", "ns": [ "string" ], "owner": "string", "paused": true, "permission": "string", "project": "string", "registrar": "string", "status": "verified", "ttl": 0, "verified": "2020-05-08T10:49:18Z", "records_count": 0, "is_secondary_dns": true, "txt_verification": { "name": "string", "token": "string" } }, { "id": "zoneB", "created": "2020-05-08T10:49:18Z", "modified": "2020-05-08T10:49:18Z", "legacy_dns_host": "string", "legacy_ns": [ "string" ], "name": "example.org", "ns": [ "string" ], "owner": "string", "paused": true, "permission": "string", "project": "string", "registrar": "string", "status": "verified", "ttl": 0, "verified": "2020-05-08T10:49:18Z", "records_count": 0, "is_secondary_dns": true, "txt_verification": { "name": "string", "token": "string" } } ], "meta": { "pagination": { "page": 1, "per_page": 1, "last_page": 1, "total_entries": 0 } } } ================================================ FILE: providers/dns/hetzner/internal/legacy/internal/types.go ================================================ package internal // DNSRecord a DNS record. type DNSRecord struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value"` Priority int `json:"priority,omitempty"` TTL int `json:"ttl,omitempty"` ZoneID string `json:"zone_id,omitempty"` } // DNSRecords a set of DNS record. type DNSRecords struct { Records []DNSRecord `json:"records"` } // Zone a DNS zone. type Zone struct { ID string `json:"id"` Name string `json:"name"` } // Zones a set of DNS zones. type Zones struct { Zones []Zone `json:"zones"` Meta Meta `json:"meta"` } // Meta response metadata. type Meta struct { Pagination Pagination `json:"pagination"` } // Pagination information about pagination. type Pagination struct { Page int `json:"page,omitempty" url:"page"` PerPage int `json:"per_page,omitempty" url:"per_page"` LastPage int `json:"last_page,omitempty" url:"-"` TotalEntries int `json:"total_entries,omitempty" url:"-"` } ================================================ FILE: providers/dns/hostingde/hostingde.go ================================================ // Package hostingde implements a DNS provider for solving the DNS-01 challenge using hosting.de. package hostingde import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/hostingde" ) // Environment variables names. const ( envNamespace = "HOSTINGDE_" EnvAPIKey = envNamespace + "API_KEY" EnvZoneName = envNamespace + "ZONE_NAME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = hostingde.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), 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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for hosting.de. // Credentials must be passed in the environment variables: // HOSTINGDE_ZONE_NAME and HOSTINGDE_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("hostingde: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for hosting.de. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("hostingde: the configuration of the DNS provider is nil") } provider, err := hostingde.NewDNSProviderConfig(config, "") if err != nil { return nil, fmt.Errorf("hostingde: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("hostingde: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("hostingde: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/hostingde/hostingde.toml ================================================ Name = "Hosting.de" Description = '''''' URL = "https://www.hosting.de/" Code = "hostingde" Since = "v1.1.0" Example = ''' HOSTINGDE_API_KEY=xxxxxxxx \ lego --dns hostingde -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] HOSTINGDE_API_KEY = "API key" [Configuration.Additional] HOSTINGDE_ZONE_NAME = "Zone name in ACE format" HOSTINGDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HOSTINGDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" HOSTINGDE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" HOSTINGDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.hosting.de/api/#dns" ================================================ FILE: providers/dns/hostingde/hostingde_test.go ================================================ package hostingde import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey, EnvZoneName). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvZoneName: "example.org", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvZoneName: "", }, expected: "hostingde: some credentials information are missing: HOSTINGDE_API_KEY", }, { desc: "missing access key", envVars: map[string]string{ EnvAPIKey: "", EnvZoneName: "456", }, expected: "hostingde: some credentials information are missing: HOSTINGDE_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string zoneName string expected string }{ { desc: "success", apiKey: "123", zoneName: "example.org", }, { desc: "missing credentials", expected: "hostingde: API key missing", }, { desc: "missing api key", zoneName: "456", expected: "hostingde: API key missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.ZoneName = test.zoneName p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/hostinger/hostinger.go ================================================ // Package hostinger implements a DNS provider for solving the DNS-01 challenge using Hostinger. package hostinger import ( "context" "errors" "fmt" "net/http" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/hostinger/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "HOSTINGER_" EnvAPIToken = envNamespace + "API_TOKEN" 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 { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Hostinger. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("hostinger: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Hostinger. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("hostinger: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIToken) if err != nil { return nil, fmt.Errorf("hostinger: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hostinger: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("hostinger: %w", err) } ctx := context.Background() request := internal.ZoneRequest{ Overwrite: false, Zone: []internal.RecordSet{{ Name: subDomain, Type: "TXT", TTL: d.config.TTL, Records: []internal.Record{ {Content: info.Value}, }, }}, } err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request) if err != nil { return fmt.Errorf("hostinger: update DNS records (add): %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hostinger: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("hostinger: %w", err) } ctx := context.Background() recordSet, err := d.findRecordSet(ctx, authZone, subDomain) if err != nil { return fmt.Errorf("hostinger: %w", err) } var newRecords []internal.Record for _, record := range recordSet.Records { if record.Content == info.Value || record.Content == strconv.Quote(info.Value) { continue } newRecords = append(newRecords, record) } recordSet.Records = newRecords if len(recordSet.Records) > 0 { request := internal.ZoneRequest{ Overwrite: true, Zone: []internal.RecordSet{recordSet}, } err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request) if err != nil { return fmt.Errorf("hostinger: update DNS records (delete): %w", err) } return nil } filters := []internal.Filter{{ Name: subDomain, Type: "TXT", }} err = d.client.DeleteDNSRecords(ctx, dns01.UnFqdn(authZone), filters) if err != nil { return fmt.Errorf("hostinger: delete DNS records: %w", err) } return 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 } func (d *DNSProvider) findRecordSet(ctx context.Context, authZone, subDomain string) (internal.RecordSet, error) { recordSets, err := d.client.GetDNSRecords(ctx, dns01.UnFqdn(authZone)) if err != nil { return internal.RecordSet{}, fmt.Errorf("get DNS records: %w", err) } for _, recordSet := range recordSets { if recordSet.Name != subDomain || recordSet.Type != "TXT" { continue } return recordSet, nil } return internal.RecordSet{}, fmt.Errorf("no record found for domain %q and subdomain %q", authZone, subDomain) } ================================================ FILE: providers/dns/hostinger/hostinger.toml ================================================ Name = "Hostinger" Description = '''''' URL = "https://www.hostinger.com/" Code = "hostinger" Since = "v4.27.0" Example = ''' HOSTINGER_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns hostinger -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] HOSTINGER_API_TOKEN = "API Token" [Configuration.Additional] HOSTINGER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HOSTINGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" HOSTINGER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" HOSTINGER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.hostinger.com/#tag/dns-zone" ================================================ FILE: providers/dns/hostinger/hostinger_test.go ================================================ package hostinger import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "secret", }, }, { desc: "missing API token", envVars: map[string]string{ EnvAPIToken: "", }, expected: "hostinger: some credentials information are missing: HOSTINGER_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "secret", }, { desc: "missing API token", expected: "hostinger: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIToken = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("PUT /api/dns/v1/zones/example.com", servermock.ResponseFromInternal("update_dns_records.json"), servermock.CheckRequestJSONBodyFromInternal("update_dns_records-request.json")). Build(t) err := provider.Present("example.com", "", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp_update(t *testing.T) { provider := mockBuilder(). Route("GET /api/dns/v1/zones/example.com", servermock.ResponseFromInternal("get_dns_records_acme.json")). Route("PUT /api/dns/v1/zones/example.com", servermock.ResponseFromInternal("update_dns_records.json"), servermock.CheckRequestJSONBodyFromInternal("update_dns_records_base-request.json")). Build(t) err := provider.CleanUp("example.com", "", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp_delete(t *testing.T) { provider := mockBuilder(). Route("GET /api/dns/v1/zones/example.com", servermock.ResponseFromInternal("get_dns_records_empty.json")). Route("DELETE /api/dns/v1/zones/example.com", servermock.ResponseFromInternal("delete_dns_records.json"), servermock.CheckRequestJSONBody(`{"filters":[{"name":"_acme-challenge","type":"TXT"}]}`)). Build(t) err := provider.CleanUp("example.com", "", "123d==") require.NoError(t, err) } 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) } ================================================ FILE: providers/dns/hostinger/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://developers.hostinger.com" const authorizationHeader = "Authorization" // Client the Hostinger API client. type Client struct { token string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(token string) (*Client, error) { if token == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // GetDNSRecords retrieves DNS zone records for a specific domain. // https://developers.hostinger.com/#tag/dns-zone/get/api/dns/v1/zones/{domain} func (c *Client) GetDNSRecords(ctx context.Context, domain string) ([]RecordSet, error) { endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []RecordSet err = c.do(req, &result) if err != nil { return nil, err } return result, nil } // UpdateDNSRecords updates DNS records for the selected domain. // https://developers.hostinger.com/#tag/dns-zone/put/api/dns/v1/zones/{domain} func (c *Client) UpdateDNSRecords(ctx context.Context, domain string, zone ZoneRequest) error { endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, zone) if err != nil { return err } return c.do(req, nil) } // DeleteDNSRecords deletes DNS records for the selected domain. // https://developers.hostinger.com/#tag/dns-zone/delete/api/dns/v1/zones/{domain} func (c *Client) DeleteDNSRecords(ctx context.Context, domain string, filters []Filter) error { endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, Filters{Filters: filters}) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, "Bearer "+c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/hostinger/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With("Authorization", "Bearer secret"), ) } func TestClient_GetDNSRecords(t *testing.T) { client := mockBuilder(). Route("GET /api/dns/v1/zones/example.com", servermock.ResponseFromFixture("get_dns_records.json")). Build(t) records, err := client.GetDNSRecords(t.Context(), "example.com") require.NoError(t, err) expected := []RecordSet{ { Name: "_acme-challenge", Records: []Record{{ Content: "aaa", }}, TTL: 14400, Type: "TXT", }, { Name: "_acme-challenge", Records: []Record{{ Content: "example.com.", }}, TTL: 14400, Type: "A", }, } assert.Equal(t, expected, records) } func TestClient_GetDNSRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /api/dns/v1/zones/example.com", servermock.ResponseFromFixture("error_401.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetDNSRecords(t.Context(), "example.com") require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: Unauthenticated") } func TestClient_UpdateDNSRecords(t *testing.T) { client := mockBuilder(). Route("PUT /api/dns/v1/zones/example.com", servermock.ResponseFromFixture("update_dns_records.json"), servermock.CheckRequestJSONBodyFromFixture("update_dns_records-request.json")). Build(t) zone := ZoneRequest{ Overwrite: false, Zone: []RecordSet{ { Name: "_acme-challenge", Records: []Record{ {Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, }, TTL: 120, Type: "TXT", }, }, } err := client.UpdateDNSRecords(t.Context(), "example.com", zone) require.NoError(t, err) } func TestClient_UpdateDNSRecords_error(t *testing.T) { client := mockBuilder(). Route("PUT /api/dns/v1/zones/example.com", servermock.ResponseFromFixture("error_422.json"). WithStatusCode(http.StatusBadRequest)). Build(t) zone := ZoneRequest{ Zone: []RecordSet{{ Name: "_acme-challenge", Records: []Record{{ Content: "aaa", }}, TTL: 14400, Type: "TXT", }}, } err := client.UpdateDNSRecords(t.Context(), "example.com", zone) require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: The name field is required. (and 1 more error): field_1: The field_1 field is required., The field_1 must be a number.") } func TestClient_DeleteDNSRecords(t *testing.T) { client := mockBuilder(). Route("DELETE /api/dns/v1/zones/example.com", servermock.ResponseFromFixture("delete_dns_records.json"), servermock.CheckRequestJSONBody(`{"filters":[{"name":"_acme-challenge","type":"TXT"}]}`)). Build(t) filters := []Filter{{ Name: "_acme-challenge", Type: "TXT", }} err := client.DeleteDNSRecords(t.Context(), "example.com", filters) require.NoError(t, err) } func TestClient_DeleteDNSRecords_error(t *testing.T) { client := mockBuilder(). Route("DELETE /api/dns/v1/zones/example.com", servermock.ResponseFromFixture("error_401.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) filters := []Filter{{ Name: "_acme-challenge", Type: "TXT", }} err := client.DeleteDNSRecords(t.Context(), "example.com", filters) require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: Unauthenticated") } ================================================ FILE: providers/dns/hostinger/internal/fixtures/delete_dns_records.json ================================================ { "message": "Request accepted" } ================================================ FILE: providers/dns/hostinger/internal/fixtures/error_401.json ================================================ { "message": "Unauthenticated", "correlation_id": "26a91bd9-f8c8-4a83-9df9-83e23d696fe3" } ================================================ FILE: providers/dns/hostinger/internal/fixtures/error_422.json ================================================ { "message": "The name field is required. (and 1 more error)", "errors": { "field_1": [ "The field_1 field is required.", "The field_1 must be a number." ] }, "correlation_id": "26a91bd9-f8c8-4a83-9df9-83e23d696fe3" } ================================================ FILE: providers/dns/hostinger/internal/fixtures/get_dns_records.json ================================================ [ { "name": "_acme-challenge", "records": [ { "content": "aaa", "is_disabled": false } ], "ttl": 14400, "type": "TXT" }, { "name": "_acme-challenge", "records": [ { "content": "example.com.", "is_disabled": false } ], "ttl": 14400, "type": "A" } ] ================================================ FILE: providers/dns/hostinger/internal/fixtures/get_dns_records_acme.json ================================================ [ { "name": "_acme-challenge", "records": [ { "content": "aaa", "is_disabled": false }, { "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" } ], "ttl": 14400, "type": "TXT" }, { "name": "_acme-challenge", "records": [ { "content": "example.com.", "is_disabled": false } ], "ttl": 14400, "type": "A" } ] ================================================ FILE: providers/dns/hostinger/internal/fixtures/get_dns_records_empty.json ================================================ [ { "name": "_acme-challenge", "records": [ { "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" } ], "ttl": 14400, "type": "TXT" }, { "name": "_acme-challenge", "records": [ { "content": "example.com.", "is_disabled": false } ], "ttl": 14400, "type": "A" } ] ================================================ FILE: providers/dns/hostinger/internal/fixtures/update_dns_records-request.json ================================================ { "overwrite": false, "zone": [ { "name": "_acme-challenge", "records": [ { "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" } ], "ttl": 120, "type": "TXT" } ] } ================================================ FILE: providers/dns/hostinger/internal/fixtures/update_dns_records.json ================================================ { "message": "Request accepted" } ================================================ FILE: providers/dns/hostinger/internal/fixtures/update_dns_records_base-request.json ================================================ { "overwrite": true, "zone": [ { "name": "_acme-challenge", "records": [ { "content": "aaa" } ], "ttl": 14400, "type": "TXT" } ] } ================================================ FILE: providers/dns/hostinger/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type APIError struct { Message string `json:"message,omitempty"` Errors map[string][]string `json:"errors,omitempty"` CorrelationID string `json:"correlation_id,omitempty"` } func (a *APIError) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "%s: %s", a.CorrelationID, a.Message) for field, values := range a.Errors { _, _ = fmt.Fprintf(msg, ": %s: %s", field, strings.Join(values, ", ")) } return msg.String() } type ZoneRequest struct { Overwrite bool `json:"overwrite"` Zone []RecordSet `json:"zone,omitempty"` } type RecordSet struct { Name string `json:"name,omitempty"` Records []Record `json:"records,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` } type Record struct { Content string `json:"content,omitempty"` IsDisabled bool `json:"is_disabled,omitempty"` } type Filters struct { Filters []Filter `json:"filters"` } type Filter struct { Name string `json:"name"` Type string `json:"type"` } ================================================ FILE: providers/dns/hostingnl/hostingnl.go ================================================ // Package hostingnl implements a DNS provider for solving the DNS-01 challenge using hosting.nl. package hostingnl import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/hostingnl/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "HOSTINGNL_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string HTTPClient *http.Client PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for hosting.nl. // Credentials must be passed in the environment variables: // HOSTINGNL_APIKEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("hostingnl: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for hosting.nl. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("hostingnl: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("hostingnl: APIKey is missing") } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hostingnl: could not find zone for domain %q: %w", domain, err) } record := internal.Record{ Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", Content: strconv.Quote(info.Value), TTL: d.config.TTL, Priority: 0, } newRecord, err := d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("hostingnl: failed to create TXT record, fqdn=%s: %w", info.EffectiveFQDN, err) } d.recordIDsMu.Lock() d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT records matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hostingnl: could not find zone for domain %q: %w", domain, err) } // gets the record's unique ID d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("hostingnl: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) if err != nil { return fmt.Errorf("hostingnl: failed to delete TXT record, id=%s: %w", recordID, err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/hostingnl/hostingnl.toml ================================================ Name = "Hosting.nl" Description = '''''' URL = "https://hosting.nl" Code = "hostingnl" Since = "v4.30.0" Example = ''' HOSTINGNL_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns hostingnl -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] HOSTINGNL_API_KEY = "The API key" [Configuration.Additional] HOSTINGNL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HOSTINGNL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" HOSTINGNL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" HOSTINGNL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api.hosting.nl/api/documentation" ================================================ FILE: providers/dns/hostingnl/hostingnl_test.go ================================================ package hostingnl import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", }, }, { desc: "missing API key", envVars: map[string]string{}, expected: "hostingnl: some credentials information are missing: HOSTINGNL_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "key", }, { desc: "missing API key", expected: "hostingnl: APIKey is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = "secret" config.HTTPClient = server.Client() provider, err := NewDNSProviderConfig(config) if err != nil { return nil, err } provider.client.BaseURL, _ = url.Parse(server.URL) return provider, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("API-TOKEN", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /domains/example.com/dns", servermock.ResponseFromInternal("add_record.json"), servermock.CheckQueryParameter().Strict(), servermock.CheckRequestJSONBodyFromInternal("add_record-request.json")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /domains/example.com/dns", servermock.ResponseFromInternal("delete_record.json"), servermock.CheckQueryParameter().Strict(), servermock.CheckRequestJSONBodyFromInternal("delete_record-request.json")). Build(t) provider.recordIDs["abc"] = "12345" err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/hostingnl/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://api.hosting.nl" type Client struct { apiKey string BaseURL *url.URL HTTPClient *http.Client } func NewClient(apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c Client) AddRecord(ctx context.Context, domain string, record Record) (*Record, error) { endpoint := c.BaseURL.JoinPath("domains", domain, "dns") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, []Record{record}) if err != nil { return nil, err } var result APIResponse[Record] err = c.do(req, &result) if err != nil { return nil, err } if len(result.Data) != 1 { return nil, fmt.Errorf("unexpected response data: %v", result.Data) } return &result.Data[0], nil } func (c Client) DeleteRecord(ctx context.Context, domain, recordID string) error { endpoint := c.BaseURL.JoinPath("domains", domain, "dns") req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, []Record{{ID: recordID}}) if err != nil { return err } var result APIResponse[Record] err = c.do(req, &result) if err != nil { return err } return nil } func (c Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) req.Header.Set("API-TOKEN", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var apiErr APIError err := json.Unmarshal(raw, &apiErr) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, apiErr) } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/hostingnl/internal/client_test.go ================================================ package internal import ( "context" "net/http" "net/http/httptest" "net/url" "strconv" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder( func(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("API-TOKEN", "secret"), ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/dns", servermock.ResponseFromFixture("add_record.json"), servermock.CheckQueryParameter().Strict(), servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")). Build(t) record := Record{ Name: "_acme-challenge.example.com", Type: "TXT", Content: strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"), TTL: 120, } newRecord, err := client.AddRecord(context.Background(), "example.com", record) require.NoError(t, err) expected := &Record{ ID: "12345", Name: "_acme-challenge.example.com", Type: "TXT", Content: strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"), TTL: 120, } assert.Equal(t, expected, newRecord) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/dns", servermock.ResponseFromFixture("delete_record.json"), servermock.CheckQueryParameter().Strict(), servermock.CheckRequestJSONBodyFromFixture("delete_record-request.json")). Build(t) err := client.DeleteRecord(context.Background(), "example.com", "12345") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/dns", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(context.Background(), "example.com", "12345") require.EqualError(t, err, "[status code: 401] Something went wrong") } func TestClient_DeleteRecord_error_other(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/dns", servermock.ResponseFromFixture("error_other.json"). WithStatusCode(http.StatusNotFound)). Build(t) err := client.DeleteRecord(context.Background(), "example.com", "12345") require.EqualError(t, err, "[status code: 404] Resource not found") } ================================================ FILE: providers/dns/hostingnl/internal/fixtures/add_record-request.json ================================================ [ { "name": "_acme-challenge.example.com", "type": "TXT", "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"", "ttl": 120 } ] ================================================ FILE: providers/dns/hostingnl/internal/fixtures/add_record.json ================================================ { "success": true, "data": [ { "id": "12345", "type": "TXT", "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"", "name": "_acme-challenge.example.com", "prio": 0, "ttl": 120 } ] } ================================================ FILE: providers/dns/hostingnl/internal/fixtures/delete_record-request.json ================================================ [ { "id": "12345" } ] ================================================ FILE: providers/dns/hostingnl/internal/fixtures/delete_record.json ================================================ { "success": true, "data": [ { "id": "12345" } ] } ================================================ FILE: providers/dns/hostingnl/internal/fixtures/error.json ================================================ { "errors": { "message": "Something went wrong" } } ================================================ FILE: providers/dns/hostingnl/internal/fixtures/error_other.json ================================================ { "error": "Resource not found" } ================================================ FILE: providers/dns/hostingnl/internal/types.go ================================================ package internal type Record struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` Priority int `json:"prio,omitempty"` } type APIResponse[T any] struct { Success bool `json:"success"` Data []T `json:"data"` } type APIError struct { ErrorMsg string `json:"error"` Errors Error `json:"errors"` } func (e APIError) Error() string { if e.ErrorMsg != "" { return e.ErrorMsg } return e.Errors.Error() } type Error struct { Message string `json:"message"` } func (e Error) Error() string { return e.Message } ================================================ FILE: providers/dns/hosttech/hosttech.go ================================================ // Package hosttech implements a DNS provider for solving the DNS-01 challenge using hosttech. package hosttech import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/hosttech/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "HOSTTECH_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for hosttech. // Credentials must be passed in the environment variable: HOSTTECH_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("hosttech: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for hosttech. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("hosttech: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("hosttech: missing credentials") } client := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.APIKey), ), ) return &DNSProvider{ config: config, client: client, recordIDs: map[string]int{}, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hosttech: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() zone, err := d.client.GetZone(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("hosttech: could not find zone for domain %q (%s): %w", domain, authZone, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("hosttech: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Text: info.Value, TTL: d.config.TTL, } newRecord, err := d.client.AddRecord(ctx, strconv.Itoa(zone.ID), record) if err != nil { return fmt.Errorf("hosttech: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("hosttech: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() zone, err := d.client.GetZone(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("hosttech: could not find zone for domain %q (%s): %w", domain, authZone, err) } // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("hosttech: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.DeleteRecord(ctx, strconv.Itoa(zone.ID), strconv.Itoa(recordID)) if err != nil { return fmt.Errorf("hosttech: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/hosttech/hosttech.toml ================================================ Name = "Hosttech" Description = '''''' URL = "https://www.hosttech.eu/" Code = "hosttech" Since = "v4.5.0" Example = ''' HOSTTECH_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns hosttech -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] HOSTTECH_API_KEY = "API login" HOSTTECH_PASSWORD = "API password" [Configuration.Additional] HOSTTECH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HOSTTECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" HOSTTECH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" HOSTTECH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.ns1.hosttech.eu/api/documentation" ================================================ FILE: providers/dns/hosttech/hosttech_test.go ================================================ package hosttech import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "secret", }, }, { desc: "missing API key", expected: "hosttech: some credentials information are missing: HOSTTECH_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "secret", }, { desc: "missing API key", expected: "hosttech: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/hosttech/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) const defaultBaseURL = "https://api.ns1.hosttech.eu/api" // Client a Hosttech client. type Client struct { baseURL *url.URL httpClient *http.Client } // NewClient creates a new Client. func NewClient(hc *http.Client) *Client { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } return &Client{baseURL: baseURL, httpClient: hc} } // GetZones Get a list of all zones. // https://api.ns1.hosttech.eu/api/documentation/#/Zones/get_api_user_v1_zones func (c *Client) GetZones(ctx context.Context, query string, limit, offset int) ([]Zone, error) { endpoint := c.baseURL.JoinPath("user", "v1", "zones") values := endpoint.Query() values.Set("query", query) if limit > 0 { values.Set("limit", strconv.Itoa(limit)) } if offset > 0 { values.Set("offset", strconv.Itoa(offset)) } endpoint.RawQuery = values.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } result := apiResponse[[]Zone]{} err = c.do(req, &result) if err != nil { return nil, err } return result.Data, nil } // GetZone Get a single zone. // https://api.ns1.hosttech.eu/api/documentation/#/Zones/get_api_user_v1_zones__zoneId_ func (c *Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) { endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } result := apiResponse[*Zone]{} err = c.do(req, &result) if err != nil { return nil, err } return result.Data, nil } // GetRecords Returns a list of all records for the given zone. // https://api.ns1.hosttech.eu/api/documentation/#/Records/get_api_user_v1_zones__zoneId__records func (c *Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Record, error) { endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records") values := endpoint.Query() if recordType != "" { values.Set("type", recordType) } endpoint.RawQuery = values.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } result := apiResponse[[]Record]{} err = c.do(req, &result) if err != nil { return nil, err } return result.Data, nil } // AddRecord Adds a new record to the zone and returns the newly created record. // https://api.ns1.hosttech.eu/api/documentation/#/Records/post_api_user_v1_zones__zoneId__records func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, fmt.Errorf("create request: %w", err) } result := apiResponse[*Record]{} err = c.do(req, &result) if err != nil { return nil, err } return result.Data, nil } // DeleteRecord Deletes a single record for the given id. // https://api.ns1.hosttech.eu/api/documentation/#/Records/delete_api_user_v1_zones__zoneId__records__recordId_ func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return fmt.Errorf("create request: %w", err) } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { resp, errD := c.httpClient.Do(req) if errD != nil { return errutils.NewHTTPDoError(req, errD) } defer func() { _ = resp.Body.Close() }() switch resp.StatusCode { case http.StatusOK, http.StatusCreated: raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil case http.StatusNoContent: return nil default: return parseError(req, resp) } } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIError{StatusCode: resp.StatusCode} err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errAPI } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/hosttech/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testAPIKey = "secret" func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer secret")) } func TestClient_GetZones(t *testing.T) { client := mockBuilder(). Route("GET /user/v1/zones", servermock.ResponseFromFixture("zones.json"), servermock.CheckQueryParameter().Strict(). With("limit", "100"). With("query", "")). Build(t) zones, err := client.GetZones(t.Context(), "", 100, 0) require.NoError(t, err) expected := []Zone{ { ID: 10, Name: "user1.ch", Email: "test@hosttech.ch", TTL: 10800, Nameserver: "ns1.hosttech.ch", Dnssec: false, DnssecEmail: "test@hosttech.ch", }, } assert.Equal(t, expected, zones) } func TestClient_GetZones_error(t *testing.T) { client := mockBuilder(). Route("GET /user/v1/zones", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetZones(t.Context(), "", 100, 0) require.Error(t, err) } func TestClient_GetZone(t *testing.T) { client := mockBuilder(). Route("GET /user/v1/zones/123", servermock.ResponseFromFixture("zone.json")). Build(t) zone, err := client.GetZone(t.Context(), "123") require.NoError(t, err) expected := &Zone{ ID: 10, Name: "user1.ch", Email: "test@hosttech.ch", TTL: 10800, Nameserver: "ns1.hosttech.ch", Dnssec: false, DnssecEmail: "test@hosttech.ch", } assert.Equal(t, expected, zone) } func TestClient_GetZone_error(t *testing.T) { client := mockBuilder(). Route("GET /user/v1/zones/123", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetZone(t.Context(), "123") require.EqualError(t, err, "401: Unauthenticated.") } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /user/v1/zones/123/records", servermock.ResponseFromFixture("records.json"), servermock.CheckQueryParameter().Strict(). With("type", "TXT")). Build(t) records, err := client.GetRecords(t.Context(), "123", "TXT") require.NoError(t, err) expected := []Record{ { ID: 10, Type: "A", Name: "www", TTL: 3600, Comment: "my first record", }, { ID: 11, Type: "AAAA", Name: "www", TTL: 3600, Comment: "my first record", }, { ID: 12, Type: "CAA", TTL: 3600, Comment: "my first record", }, { ID: 13, Type: "CNAME", Name: "www", TTL: 3600, Comment: "my first record", }, { ID: 14, Type: "MX", Name: "mail.example.com", TTL: 3600, Comment: "my first record", }, { ID: 14, Type: "NS", Name: "ns1.example.com", TTL: 3600, Comment: "my first record", }, { ID: 15, Type: "PTR", Name: "smtp.example.com", TTL: 3600, Comment: "my first record", }, { ID: 16, Type: "SRV", TTL: 3600, Comment: "my first record", }, { ID: 17, Type: "TXT", Text: "v=spf1 ip4:1.2.3.4/32 -all", TTL: 3600, Comment: "my first record", }, { ID: 17, Type: "TLSA", Text: "0 0 1 d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971", TTL: 3600, Comment: "my first record", }, } assert.Equal(t, expected, records) } func TestClient_GetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /user/v1/zones/123/records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetRecords(t.Context(), "123", "TXT") require.EqualError(t, err, "401: Unauthenticated.") } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /user/v1/zones/123/records", servermock.ResponseFromFixture("record.json"). WithStatusCode(http.StatusCreated)). Build(t) record := Record{ Type: "TXT", Name: "lego", Text: "content", TTL: 3600, Comment: "example", } newRecord, err := client.AddRecord(t.Context(), "123", record) require.NoError(t, err) expected := &Record{ ID: 10, Type: "TXT", Name: "lego", Text: "content", TTL: 3600, Comment: "example", } assert.Equal(t, expected, newRecord) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /user/v1/zones/123/records", servermock.ResponseFromFixture("error-details.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) record := Record{ Type: "TXT", Name: "lego", Text: "content", TTL: 3600, Comment: "example", } _, err := client.AddRecord(t.Context(), "123", record) require.EqualError(t, err, "401: The given data was invalid. type: [Darf nicht leer sein.]") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /user/v1/zones/123/records/6", servermock.Noop().WithStatusCode(http.StatusNoContent). WithStatusCode(http.StatusCreated)). Build(t) err := client.DeleteRecord(t.Context(), "123", "6") require.Error(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /user/v1/zones/123/records/6", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(t.Context(), "123", "6") require.EqualError(t, err, "401: Unauthenticated.") } ================================================ FILE: providers/dns/hosttech/internal/fixtures/error-details.json ================================================ { "message": "The given data was invalid.", "errors": { "type": [ "Darf nicht leer sein." ] } } ================================================ FILE: providers/dns/hosttech/internal/fixtures/error.json ================================================ { "message": "Unauthenticated." } ================================================ FILE: providers/dns/hosttech/internal/fixtures/record.json ================================================ { "data": { "id": 10, "type": "TXT", "name": "lego", "Text": "content", "ttl": 3600, "comment": "example" } } ================================================ FILE: providers/dns/hosttech/internal/fixtures/records.json ================================================ { "data": [ { "id": 10, "type": "A", "name": "www", "ipv4": "1.2.3.4", "ttl": 3600, "comment": "my first record" }, { "id": 11, "type": "AAAA", "name": "www", "ipv6": "2001:db8:1234::1", "ttl": 3600, "comment": "my first record" }, { "id": 12, "type": "CAA", "name": "", "flag": "0", "tag": "issue", "value": "letsencrypt.org", "ttl": 3600, "comment": "my first record" }, { "id": 13, "type": "CNAME", "name": "www", "cname": "site.example.com", "ttl": 3600, "comment": "my first record" }, { "id": 14, "type": "MX", "ownername": "", "name": "mail.example.com", "pref": 10, "ttl": 3600, "comment": "my first record" }, { "id": 14, "type": "NS", "ownername": "sub", "name": "ns1.example.com", "ttl": 3600, "comment": "my first record" }, { "id": 15, "type": "PTR", "origin": "4.3.2.1", "name": "smtp.example.com", "ttl": 3600, "comment": "my first record" }, { "id": 16, "type": "SRV", "service": "_autodiscover._tcp", "priority": 0, "weight": 0, "port": 443, "target": "exchange.example.com", "ttl": 3600, "comment": "my first record" }, { "id": 17, "type": "TXT", "name": "", "text": "v=spf1 ip4:1.2.3.4/32 -all", "ttl": 3600, "comment": "my first record" }, { "id": 17, "type": "TLSA", "name": "", "text": "0 0 1 d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971", "ttl": 3600, "comment": "my first record" } ] } ================================================ FILE: providers/dns/hosttech/internal/fixtures/zone.json ================================================ { "data": { "id": 10, "name": "user1.ch", "email": "test@hosttech.ch", "ttl": 10800, "nameserver": "ns1.hosttech.ch", "dnssec": false, "dnssec_email": "test@hosttech.ch", "ds_records": "[]", "records": "[{'id': 10, 'type': 'A', 'name': 'www', 'ipv4': '1.2.3.4', 'ttl': 3600, 'comment': 'my first record'}]" } } ================================================ FILE: providers/dns/hosttech/internal/fixtures/zones.json ================================================ { "data": [ { "id": 10, "name": "user1.ch", "email": "test@hosttech.ch", "ttl": 10800, "nameserver": "ns1.hosttech.ch", "dnssec": false, "dnssec_email": "test@hosttech.ch" } ] } ================================================ FILE: providers/dns/hosttech/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type apiResponse[T any] struct { Data T `json:"data"` } type APIError struct { Message string `json:"message,omitempty"` Errors map[string]any `json:"errors,omitempty"` StatusCode int `json:"-"` } func (a APIError) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "%d: %s", a.StatusCode, a.Message) for k, v := range a.Errors { _, _ = fmt.Fprintf(msg, " %s: %v", k, v) } return msg.String() } type Zone struct { ID int `json:"id"` Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` TTL int `json:"ttl,omitempty"` Nameserver string `json:"nameserver,omitempty"` Dnssec bool `json:"dnssec,omitempty"` DnssecEmail string `json:"dnssec_email,omitempty"` } type Record struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Zone string `json:"zone,omitempty"` Text string `json:"text,omitempty"` TTL int `json:"ttl,omitempty"` Comment string `json:"comment,omitempty"` } ================================================ FILE: providers/dns/httpnet/httpnet.go ================================================ // Package httpnet implements a DNS provider for solving the DNS-01 challenge using http.net. package httpnet import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/hostingde" ) // Environment variables names. const ( envNamespace = "HTTPNET_" EnvAPIKey = envNamespace + "API_KEY" EnvZoneName = envNamespace + "ZONE_NAME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://partner.http.net/api/dns/v1/json" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = hostingde.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), 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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for http.net. // Credentials must be passed in the environment variables: // HTTPNET_ZONE_NAME and HTTPNET_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("httpnet: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for http.net. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("httpnet: the configuration of the DNS provider is nil") } provider, err := hostingde.NewDNSProviderConfig(config, defaultBaseURL) if err != nil { return nil, fmt.Errorf("httpnet: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("httpnet: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("httpnet: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/httpnet/httpnet.toml ================================================ Name = "http.net" Description = '''''' URL = "https://www.http.net/" Code = "httpnet" Since = "v4.15.0" Example = ''' HTTPNET_API_KEY=xxxxxxxx \ lego --dns httpnet -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] HTTPNET_API_KEY = "API key" [Configuration.Additional] HTTPNET_ZONE_NAME = "Zone name in ACE format" HTTPNET_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HTTPNET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" HTTPNET_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" HTTPNET_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.http.net/docs/api/#dns" ================================================ FILE: providers/dns/httpnet/httpnet_test.go ================================================ package httpnet import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIKey, EnvZoneName). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvZoneName: "example.org", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvZoneName: "", }, expected: "httpnet: some credentials information are missing: HTTPNET_API_KEY", }, { desc: "missing access key", envVars: map[string]string{ EnvAPIKey: "", EnvZoneName: "456", }, expected: "httpnet: some credentials information are missing: HTTPNET_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string zoneName string expected string }{ { desc: "success", apiKey: "123", zoneName: "example.org", }, { desc: "missing credentials", expected: "httpnet: API key missing", }, { desc: "missing api key", zoneName: "456", expected: "httpnet: API key missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.ZoneName = test.zoneName p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/httpreq/httpreq.go ================================================ // Package httpreq implements a DNS provider for solving the DNS-01 challenge through an HTTP server. package httpreq import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // Environment variables names. const ( envNamespace = "HTTPREQ_" EnvEndpoint = envNamespace + "ENDPOINT" EnvMode = envNamespace + "MODE" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) type message struct { FQDN string `json:"fqdn"` Value string `json:"value"` } type messageRaw struct { Domain string `json:"domain"` Token string `json:"token"` KeyAuth string `json:"keyAuth"` } // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL Mode string Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config } // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvEndpoint) if err != nil { return nil, fmt.Errorf("httpreq: %w", err) } endpoint, err := url.Parse(values[EnvEndpoint]) if err != nil { return nil, fmt.Errorf("httpreq: %w", err) } config := NewDefaultConfig() config.Mode = env.GetOrFile(EnvMode) config.Username = env.GetOrFile(EnvUsername) config.Password = env.GetOrFile(EnvPassword) config.Endpoint = endpoint return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("httpreq: the configuration of the DNS provider is nil") } if config.Endpoint == nil { return nil, errors.New("httpreq: the endpoint is missing") } config.HTTPClient = clientdebug.Wrap(config.HTTPClient) return &DNSProvider{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 { ctx := context.Background() if d.config.Mode == "RAW" { msg := &messageRaw{ Domain: domain, Token: token, KeyAuth: keyAuth, } err := d.doPost(ctx, "/present", msg) if err != nil { return fmt.Errorf("httpreq: %w", err) } return nil } info := dns01.GetChallengeInfo(domain, keyAuth) msg := &message{ FQDN: info.EffectiveFQDN, Value: info.Value, } err := d.doPost(ctx, "/present", msg) if err != nil { return fmt.Errorf("httpreq: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() if d.config.Mode == "RAW" { msg := &messageRaw{ Domain: domain, Token: token, KeyAuth: keyAuth, } err := d.doPost(ctx, "/cleanup", msg) if err != nil { return fmt.Errorf("httpreq: %w", err) } return nil } info := dns01.GetChallengeInfo(domain, keyAuth) msg := &message{ FQDN: info.EffectiveFQDN, Value: info.Value, } err := d.doPost(ctx, "/cleanup", msg) if err != nil { return fmt.Errorf("httpreq: %w", err) } return nil } func (d *DNSProvider) doPost(ctx context.Context, uri string, msg any) error { reqBody := new(bytes.Buffer) err := json.NewEncoder(reqBody).Encode(msg) if err != nil { return fmt.Errorf("failed to create request JSON body: %w", err) } endpoint := d.config.Endpoint.JoinPath(uri) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), reqBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") if d.config.Username != "" && d.config.Password != "" { req.SetBasicAuth(d.config.Username, d.config.Password) } resp, err := d.config.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } ================================================ FILE: providers/dns/httpreq/httpreq.toml ================================================ Name = "HTTP request" Description = '''''' URL = "/lego/dns/httpreq/" Code = "httpreq" Since = "v2.0.0" Example = ''' HTTPREQ_ENDPOINT=http://my.server.com:9090 \ lego --dns httpreq -d '*.example.com' -d example.com run ''' Additional = ''' ## Description The server must provide: - `POST` `/present` - `POST` `/cleanup` The URL of the server must be defined by `HTTPREQ_ENDPOINT`. ### Mode There are 2 modes (`HTTPREQ_MODE`): - default mode: ```json { "fqdn": "_acme-challenge.domain.", "value": "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM" } ``` - `RAW` ```json { "domain": "domain", "token": "token", "keyAuth": "key" } ``` ### Authentication Basic authentication (optional) can be set with some environment variables: - `HTTPREQ_USERNAME` and `HTTPREQ_PASSWORD` - both values must be set, otherwise basic authentication is not defined. ''' [Configuration] [Configuration.Credentials] HTTPREQ_MODE = "`RAW`, none" HTTPREQ_ENDPOINT = "The URL of the server" [Configuration.Additional] HTTPREQ_USERNAME = "Basic authentication username" HTTPREQ_PASSWORD = "Basic authentication password" HTTPREQ_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HTTPREQ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" HTTPREQ_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" ================================================ FILE: providers/dns/httpreq/httpreq_test.go ================================================ package httpreq import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvEndpoint, EnvMode, EnvUsername, EnvPassword) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvEndpoint: "http://localhost:8090", }, }, { desc: "invalid URL", envVars: map[string]string{ EnvEndpoint: ":", }, expected: `httpreq: parse ":": missing protocol scheme`, }, { desc: "missing endpoint", envVars: map[string]string{ EnvEndpoint: "", }, expected: "httpreq: some credentials information are missing: HTTPREQ_ENDPOINT", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string endpoint *url.URL expected string }{ { desc: "success", endpoint: mustParse("http://localhost:8090"), }, { desc: "missing endpoint", expected: "httpreq: the endpoint is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Endpoint = test.endpoint p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProvider_Present(t *testing.T) { envTest.RestoreEnv() testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "success", builder: mockBuilder(""). Route("/present", servermock.RawStringResponse("lego"), servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { desc: "success with path prefix", builder: mockBuilderWithPathPrefix("", "/api/acme/"). Route("/api/acme/present", servermock.RawStringResponse("lego"), servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { desc: "error", builder: mockBuilder(""), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { desc: "success raw mode", builder: mockBuilder("RAW"). Route("/present", servermock.RawStringResponse("lego"), servermock.CheckRequestBody(`{"domain":"domain","token":"token","keyAuth":"key"}`)), }, { desc: "error raw mode", builder: mockBuilder("RAW"), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { desc: "basic auth fail", builder: mockBuilderWithBasicAuth("nope", "nope"). Route("/present", servermock.Noop()), expectedError: `httpreq: unexpected status code: [status code: 400] body: invalid credentials: got [username: "nope", password: "nope"], want [username: "user", password: "secret"]`, }, { desc: "basic auth success", builder: mockBuilderWithBasicAuth("user", "secret"). Route("/present", servermock.RawStringResponse("lego"), servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p := test.builder.Build(t) err := p.Present("domain", "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { require.EqualError(t, err, test.expectedError) } }) } } func TestNewDNSProvider_Cleanup(t *testing.T) { envTest.RestoreEnv() testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "success", builder: mockBuilder(""). Route("/cleanup", servermock.RawStringResponse("lego"), servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { desc: "error", builder: mockBuilder(""), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { desc: "success raw mode", builder: mockBuilder("RAW"). Route("/cleanup", servermock.RawStringResponse("lego"), servermock.CheckRequestBody(`{"domain":"domain","token":"token","keyAuth":"key"}`)), }, { desc: "error raw mode", builder: mockBuilder("RAW"), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { desc: "basic auth fail", builder: mockBuilderWithBasicAuth("test", "example"). Route("/cleanup", servermock.Noop()), expectedError: `httpreq: unexpected status code: [status code: 400] body: invalid credentials: got [username: "test", password: "example"], want [username: "user", password: "secret"]`, }, { desc: "basic auth success", builder: mockBuilderWithBasicAuth("user", "secret"). Route("/cleanup", servermock.RawStringResponse("lego"), servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p := test.builder.Build(t) err := p.CleanUp("domain", "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { require.EqualError(t, err, test.expectedError) } }) } } func mockBuilder(mode string) *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.Endpoint, _ = url.Parse(server.URL) config.Mode = mode return NewDNSProviderConfig(config) }) } func mockBuilderWithPathPrefix(mode, prefix string) *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.Endpoint, _ = url.Parse(server.URL + prefix) config.Mode = mode return NewDNSProviderConfig(config) }) } func mockBuilderWithBasicAuth(username, password string) *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.Endpoint, _ = url.Parse(server.URL) config.Username = username config.Password = password return NewDNSProviderConfig(config) }, servermock.CheckHeader().WithBasicAuth("user", "secret")) } func mustParse(rawURL string) *url.URL { uri, err := url.Parse(rawURL) if err != nil { panic(err) } return uri } ================================================ FILE: providers/dns/huaweicloud/huaweicloud.go ================================================ // Package huaweicloud implements a DNS provider for solving the DNS-01 challenge using Huawei Cloud. package huaweicloud import ( "context" "errors" "fmt" "strconv" "strings" "sync" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/huaweicloud/internal" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" hwauthbasic "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic" hwconfig "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/config" hwdns "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2" hwmodel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/model" hwregion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/region" ) // Environment variables names. const ( envNamespace = "HUAWEICLOUD_" EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY" EnvRegion = envNamespace + "REGION" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AccessKeyID string SecretAccessKey string Region string PropagationTimeout time.Duration PollingInterval time.Duration TTL int32 HTTPTimeout time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: int32(env.GetOrDefaultInt(EnvTTL, 300)), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.DnsClient recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Huawei Cloud. // Credentials must be passed in the environment variables: // HUAWEICLOUD_ACCESS_KEY_ID, HUAWEICLOUD_SECRET_ACCESS_KEY, and HUAWEICLOUD_REGION. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion) if err != nil { return nil, fmt.Errorf("huaweicloud: %w", err) } config := NewDefaultConfig() config.AccessKeyID = values[EnvAccessKeyID] config.SecretAccessKey = values[EnvSecretAccessKey] config.Region = values[EnvRegion] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Huawei Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("huaweicloud: the configuration of the DNS provider is nil") } if config.AccessKeyID == "" || config.SecretAccessKey == "" || config.Region == "" { return nil, errors.New("huaweicloud: credentials missing") } auth, err := hwauthbasic.NewCredentialsBuilder(). WithAk(config.AccessKeyID). WithSk(config.SecretAccessKey). SafeBuild() if err != nil { return nil, fmt.Errorf("huaweicloud: crendential build: %w", err) } region, err := hwregion.SafeValueOf(config.Region) if err != nil { return nil, fmt.Errorf("huaweicloud: safe region: %w", err) } client, err := hwdns.DnsClientBuilder(). WithHttpConfig(hwconfig.DefaultHttpConfig().WithTimeout(config.HTTPTimeout)). WithRegion(region). WithCredential(auth). SafeBuild() if err != nil { return nil, fmt.Errorf("huaweicloud: client build: %w", err) } return &DNSProvider{ config: config, client: internal.NewDnsClient(client), recordIDs: map[string]string{}, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("huaweicloud: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.getZoneID(authZone) if err != nil { return fmt.Errorf("huaweicloud: %w", err) } recordSetID, err := d.getOrCreateRecordSetID(domain, zoneID, info) if err != nil { return fmt.Errorf("huaweicloud: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordSetID d.recordIDsMu.Unlock() err = wait.Retry(context.Background(), func() error { rs, errShow := d.client.ShowRecordSet(&hwmodel.ShowRecordSetRequest{ ZoneId: zoneID, RecordsetId: recordSetID, }) if errShow != nil { return fmt.Errorf("show record set: %w", errShow) } if !strings.HasSuffix(ptr.Deref(rs.Status), "PENDING_") { return nil } return fmt.Errorf("status: %s", ptr.Deref(rs.Status)) }, backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), backoff.WithMaxElapsedTime(d.config.PropagationTimeout), ) if err != nil { return fmt.Errorf("huaweicloud: record set sync on %s: %w", domain, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("huaweicloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("huaweicloud: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.getZoneID(authZone) if err != nil { return fmt.Errorf("huaweicloud: %w", err) } request := &hwmodel.DeleteRecordSetRequest{ ZoneId: zoneID, RecordsetId: recordID, } _, err = d.client.DeleteRecordSet(request) if err != nil { return fmt.Errorf("huaweicloud: delete record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.ChallengeInfo) (string, error) { records, err := d.client.ListRecordSetsByZone(&hwmodel.ListRecordSetsByZoneRequest{ ZoneId: zoneID, Name: ptr.Pointer(info.EffectiveFQDN), }) if err != nil { return "", fmt.Errorf("record list: unable to get record %s for zone %s: %w", info.EffectiveFQDN, domain, err) } var existingRecordSet *hwmodel.ListRecordSets for _, record := range ptr.Deref(records.Recordsets) { if ptr.Deref(record.Type) == "TXT" && ptr.Deref(record.Name) == info.EffectiveFQDN { existingRecordSet = &record } } value := strconv.Quote(info.Value) if existingRecordSet == nil { request := &hwmodel.CreateRecordSetRequest{ ZoneId: zoneID, Body: &hwmodel.CreateRecordSetRequestBody{ Name: info.EffectiveFQDN, Description: ptr.Pointer("Added TXT record for ACME dns-01 challenge using lego client"), Type: "TXT", Ttl: ptr.Pointer(d.config.TTL), Records: []string{value}, }, } resp, errCreate := d.client.CreateRecordSet(request) if errCreate != nil { return "", fmt.Errorf("create record set: %w", errCreate) } return ptr.Deref(resp.Id), nil } updateRequest := &hwmodel.UpdateRecordSetRequest{ ZoneId: zoneID, RecordsetId: ptr.Deref(existingRecordSet.Id), Body: &hwmodel.UpdateRecordSetReq{ Name: existingRecordSet.Name, Description: existingRecordSet.Description, Type: existingRecordSet.Type, Ttl: existingRecordSet.Ttl, Records: ptr.Pointer(append(ptr.Deref(existingRecordSet.Records), value)), }, } resp, err := d.client.UpdateRecordSet(updateRequest) if err != nil { return "", fmt.Errorf("update record set: %w", err) } return ptr.Deref(resp.Id), nil } func (d *DNSProvider) getZoneID(authZone string) (string, error) { zones, err := d.client.ListPublicZones(&hwmodel.ListPublicZonesRequest{}) if err != nil { return "", fmt.Errorf("unable to get zone: %w", err) } for _, zone := range ptr.Deref(zones.Zones) { if ptr.Deref(zone.Name) == authZone { return ptr.Deref(zone.Id), nil } } return "", fmt.Errorf("zone %q not found", authZone) } ================================================ FILE: providers/dns/huaweicloud/huaweicloud.toml ================================================ Name = "Huawei Cloud" Description = '''''' URL = "https://huaweicloud.com" Code = "huaweicloud" Since = "v4.19" Example = ''' HUAWEICLOUD_ACCESS_KEY_ID=your-access-key-id \ HUAWEICLOUD_SECRET_ACCESS_KEY=your-secret-access-key \ HUAWEICLOUD_REGION=cn-south-1 \ lego --dns huaweicloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] HUAWEICLOUD_ACCESS_KEY_ID = "Access key ID" HUAWEICLOUD_SECRET_ACCESS_KEY = "Access Key secret" HUAWEICLOUD_REGION = "Region" [Configuration.Additional] HUAWEICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HUAWEICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" HUAWEICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" HUAWEICLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://console-intl.huaweicloud.com/apiexplorer/#/openapi/DNS/doc?locale=en-us" CN_API = "https://support.huaweicloud.com/api-dns/zh-cn_topic_0132421999.html" GoClient = "https://github.com/huaweicloud/huaweicloud-sdk-go-v3" ================================================ FILE: providers/dns/huaweicloud/huaweicloud_test.go ================================================ package huaweicloud import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" hwregion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/region" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ // The "success" cannot be tested because there is an API call that require a valid authentication. { desc: "missing credentials", envVars: map[string]string{ EnvAccessKeyID: "", EnvSecretAccessKey: "", EnvRegion: "", }, expected: "huaweicloud: some credentials information are missing: HUAWEICLOUD_ACCESS_KEY_ID,HUAWEICLOUD_SECRET_ACCESS_KEY,HUAWEICLOUD_REGION", }, { desc: "missing access id", envVars: map[string]string{ EnvAccessKeyID: "", EnvSecretAccessKey: "456", EnvRegion: hwregion.CN_EAST_2.Id, }, expected: "huaweicloud: some credentials information are missing: HUAWEICLOUD_ACCESS_KEY_ID", }, { desc: "missing secret key", envVars: map[string]string{ EnvAccessKeyID: "123", EnvSecretAccessKey: "", EnvRegion: hwregion.CN_EAST_2.Id, }, expected: "huaweicloud: some credentials information are missing: HUAWEICLOUD_SECRET_ACCESS_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAccessKeyID: "123", EnvSecretAccessKey: "456", EnvRegion: "", }, expected: "huaweicloud: some credentials information are missing: HUAWEICLOUD_REGION", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accessKeyID string secretAccessKey string region string expected string }{ // The "success" cannot be tested because there is an API call that require a valid authentication. { desc: "missing credentials", expected: "huaweicloud: credentials missing", }, { desc: "missing secret id", secretAccessKey: "456", region: hwregion.CN_EAST_2.Id, expected: "huaweicloud: credentials missing", }, { desc: "missing secret key", accessKeyID: "123", region: hwregion.CN_EAST_2.Id, expected: "huaweicloud: credentials missing", }, { desc: "missing region", accessKeyID: "123", secretAccessKey: "456", expected: "huaweicloud: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessKeyID = test.accessKeyID config.SecretAccessKey = test.secretAccessKey config.Region = test.region p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/huaweicloud/internal/client.go ================================================ /* Copyright (c) Huawei Technologies Co., Ltd. 2020-present. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package internal is a partial copy of https://github.com/huaweicloud/huaweicloud-sdk-go-v3/blob/v0.1.159/services/dns/v2/dns_client.go package internal import ( httpclient "github.com/huaweicloud/huaweicloud-sdk-go-v3/core" hwdns "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2" "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/model" ) type DnsClient struct { HcClient *httpclient.HcHttpClient } func NewDnsClient(hcClient *httpclient.HcHttpClient) *DnsClient { return &DnsClient{HcClient: hcClient} } func (c *DnsClient) ShowRecordSet(request *model.ShowRecordSetRequest) (*model.ShowRecordSetResponse, error) { requestDef := hwdns.GenReqDefForShowRecordSet() if resp, err := c.HcClient.Sync(request, requestDef); err != nil { return nil, err } else { return resp.(*model.ShowRecordSetResponse), nil } } func (c *DnsClient) CreateRecordSet(request *model.CreateRecordSetRequest) (*model.CreateRecordSetResponse, error) { requestDef := hwdns.GenReqDefForCreateRecordSet() if resp, err := c.HcClient.Sync(request, requestDef); err != nil { return nil, err } else { return resp.(*model.CreateRecordSetResponse), nil } } func (c *DnsClient) UpdateRecordSet(request *model.UpdateRecordSetRequest) (*model.UpdateRecordSetResponse, error) { requestDef := hwdns.GenReqDefForUpdateRecordSet() if resp, err := c.HcClient.Sync(request, requestDef); err != nil { return nil, err } else { return resp.(*model.UpdateRecordSetResponse), nil } } func (c *DnsClient) DeleteRecordSet(request *model.DeleteRecordSetRequest) (*model.DeleteRecordSetResponse, error) { requestDef := hwdns.GenReqDefForDeleteRecordSet() if resp, err := c.HcClient.Sync(request, requestDef); err != nil { return nil, err } else { return resp.(*model.DeleteRecordSetResponse), nil } } func (c *DnsClient) ListRecordSetsByZone(request *model.ListRecordSetsByZoneRequest) (*model.ListRecordSetsByZoneResponse, error) { requestDef := hwdns.GenReqDefForListRecordSetsByZone() if resp, err := c.HcClient.Sync(request, requestDef); err != nil { return nil, err } else { return resp.(*model.ListRecordSetsByZoneResponse), nil } } func (c *DnsClient) ListPublicZones(request *model.ListPublicZonesRequest) (*model.ListPublicZonesResponse, error) { requestDef := hwdns.GenReqDefForListPublicZones() if resp, err := c.HcClient.Sync(request, requestDef); err != nil { return nil, err } else { return resp.(*model.ListPublicZonesResponse), nil } } ================================================ FILE: providers/dns/hurricane/hurricane.go ================================================ package hurricane import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/hurricane/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "HURRICANE_" EnvTokens = envNamespace + "TOKENS" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Credentials map[string]string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 300*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Hurricane Electric. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() values, err := env.Get(EnvTokens) if err != nil { return nil, fmt.Errorf("hurricane: %w", err) } credentials, err := env.ParsePairs(values[EnvTokens]) if err != nil { return nil, fmt.Errorf("hurricane: credentials: %w", err) } config.Credentials = credentials return NewDNSProviderConfig(config) } func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("hurricane: the configuration of the DNS provider is nil") } if len(config.Credentials) == 0 { return nil, errors.New("hurricane: credentials missing") } client := internal.NewClient(config.Credentials) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present updates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.UpdateTxtRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("hurricane: %w", err) } return nil } // CleanUp updates the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.UpdateTxtRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), ".") if err != nil { return fmt.Errorf("hurricane: %w", err) } return 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/hurricane/hurricane.toml ================================================ Name = "Hurricane Electric DNS" Description = '''''' URL = "https://dns.he.net/" Code = "hurricane" Since = "v4.3.0" Example = ''' HURRICANE_TOKENS=example.org:token \ lego --dns hurricane -d '*.example.com' -d example.com run HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 \ lego --dns hurricane -d my.example.org -d demo.example.org ''' Additional = """ Before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), create a TXT record named `_acme-challenge.my.example.org`, and enable dynamic updates on it. Generate a token for each URL with Hurricane Electric's UI, and copy it down. Stick to alphanumeric tokens for greatest reliability. To authenticate with the Hurricane Electric API, add each record name/token pair you want to update to the `HURRICANE_TOKENS` environment variable, as shown in the examples. Record names (without the `_acme-challenge.` component) and their tokens are separated with colons, while the credential pairs are concatenated into a comma-separated list, like so: ``` HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 ``` If you are issuing both a wildcard certificate and a standard certificate for a given subdomain, you should not have repeat entries for that name, as both will use the same credential. ``` HURRICANE_TOKENS=example.org:token ``` """ [Configuration] [Configuration.Credentials] HURRICANE_TOKENS = "TXT record names and tokens" [Configuration.Additional] HURRICANE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" HURRICANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation (Default: 300)" HURRICANE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" HURRICANE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://dns.he.net/" ================================================ FILE: providers/dns/hurricane/hurricane_test.go ================================================ package hurricane import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvTokens).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvTokens: "example.org:123", }, }, { desc: "success multiple domains", envVars: map[string]string{ EnvTokens: "example.org:123,example.com:456,example.net:789", }, }, { desc: "invalid credentials", envVars: map[string]string{ EnvTokens: ",", }, expected: "hurricane: credentials: incorrect pair: ", }, { desc: "invalid credentials, partial", envVars: map[string]string{ EnvTokens: "example.org:123,example.net", }, expected: "hurricane: credentials: incorrect pair: example.net", }, { desc: "missing credentials", envVars: map[string]string{ EnvTokens: "", }, expected: "hurricane: some credentials information are missing: HURRICANE_TOKENS", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string creds map[string]string expected string }{ { desc: "success", creds: map[string]string{"example.org": "123"}, }, { desc: "success multiple domains", creds: map[string]string{ "example.org": "123", "example.com": "456", "example.net": "789", }, }, { desc: "missing credentials", expected: "hurricane: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Credentials = test.creds p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/hurricane/internal/client.go ================================================ package internal import ( "bytes" "context" "fmt" "io" "log" "net/http" "net/url" "strings" "sync" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/time/rate" ) const defaultBaseURL = "https://dyn.dns.he.net/nic/update" const ( codeGood = "good" codeNoChg = "nochg" codeAbuse = "abuse" codeBadAgent = "badagent" codeBadAuth = "badauth" codeInterval = "interval" codeNoHost = "nohost" codeNotFqdn = "notfqdn" ) const defaultBurst = 5 // Client the Hurricane Electric client. type Client struct { HTTPClient *http.Client rateLimiters sync.Map baseURL string credentials map[string]string credMu sync.Mutex } // NewClient Creates a new Client. func NewClient(credentials map[string]string) *Client { return &Client{ HTTPClient: &http.Client{Timeout: 5 * time.Second}, baseURL: defaultBaseURL, credentials: credentials, } } // UpdateTxtRecord updates a TXT record. func (c *Client) UpdateTxtRecord(ctx context.Context, hostname, txt string) error { domain := strings.TrimPrefix(hostname, "_acme-challenge.") c.credMu.Lock() token, ok := c.credentials[domain] c.credMu.Unlock() if !ok { return fmt.Errorf("domain %s not found in credentials, check your credentials map", domain) } data := url.Values{} data.Set("password", token) data.Set("hostname", hostname) data.Set("txt", txt) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(data.Encode())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rl, _ := c.rateLimiters.LoadOrStore(hostname, rate.NewLimiter(limit(defaultBurst), defaultBurst)) err = rl.(*rate.Limiter).Wait(ctx) if err != nil { return err } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } return evaluateBody(string(bytes.TrimSpace(raw)), hostname) } func evaluateBody(body, hostname string) error { code, _, _ := strings.Cut(body, " ") switch code { case codeGood: return nil case codeNoChg: log.Printf("%s: unchanged content written to TXT record %s", body, hostname) return nil case codeAbuse: return fmt.Errorf("%s: blocked hostname for abuse: %s", body, hostname) case codeBadAgent: return fmt.Errorf("%s: user agent not sent or HTTP method not recognized; open an issue on go-acme/lego on GitHub", body) case codeBadAuth: return fmt.Errorf("%s: wrong authentication token provided for TXT record %s", body, hostname) case codeInterval: return fmt.Errorf("%s: TXT records update exceeded API rate limit", body) case codeNoHost: return fmt.Errorf("%s: the record provided does not exist in this account: %s", body, hostname) case codeNotFqdn: return fmt.Errorf("%s: the record provided isn't an FQDN: %s", body, hostname) default: // This is basically only server errors. return fmt.Errorf("attempt to change TXT record %s returned %s", hostname, body) } } // limit computes the rate based on burst. // The API rate limit per-record is 10 reqs / 2 minutes. // // 10 reqs / 2 minutes = freq 1/12 (burst = 1) // 6 reqs / 2 minutes = freq 1/20 (burst = 5) // // https://github.com/go-acme/lego/issues/1415 func limit(burst int) rate.Limit { return 1 / rate.Limit(120/(10-burst+1)) } ================================================ FILE: providers/dns/hurricane/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient(map[string]string{"example.com": "secret"}) client.baseURL = server.URL client.HTTPClient = server.Client() return client, nil } func TestClient_UpdateTxtRecord(t *testing.T) { testCases := []struct { code string expected assert.ErrorAssertionFunc }{ { code: codeGood, expected: assert.NoError, }, { code: codeNoChg + ` "0123456789abcdef"`, expected: assert.NoError, }, { code: codeAbuse, expected: assert.Error, }, { code: codeBadAgent, expected: assert.Error, }, { code: codeBadAuth, expected: assert.Error, }, { code: codeNoHost, expected: assert.Error, }, { code: codeNotFqdn, expected: assert.Error, }, } for _, test := range testCases { t.Run(test.code, func(t *testing.T) { t.Parallel() client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()). Route("POST /", servermock.RawStringResponse(test.code), servermock.CheckForm().Strict(). With("hostname", "_acme-challenge.example.com"). With("password", "secret"). With("txt", "foo")). Build(t) err := client.UpdateTxtRecord(t.Context(), "_acme-challenge.example.com", "foo") test.expected(t, err) }) } } ================================================ FILE: providers/dns/hyperone/hyperone.go ================================================ // Package hyperone implements a DNS provider for solving the DNS-01 challenge using HyperOne. package hyperone import ( "context" "fmt" "net/http" "os" "path/filepath" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/hyperone/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // 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" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // 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 } client.HTTPClient = clientdebug.Wrap(client.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 { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", info.EffectiveFQDN, err) } recordset, err := d.client.FindRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN) if err != nil { return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", info.EffectiveFQDN, zone.ID, err) } if recordset == nil { _, err = d.client.CreateRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("hyperone: failed to create recordset: fqdn=%s, zone ID=%s, value=%s: %w", info.EffectiveFQDN, zone.ID, info.Value, err) } return nil } _, err = d.client.CreateRecord(ctx, zone.ID, recordset.ID, info.Value) if err != nil { return fmt.Errorf("hyperone: failed to create record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", info.EffectiveFQDN, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", info.EffectiveFQDN, err) } recordset, err := d.client.FindRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN) if err != nil { return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", info.EffectiveFQDN, zone.ID, err) } if recordset == nil { return fmt.Errorf("hyperone: recordset to remove not found: fqdn=%s", info.EffectiveFQDN) } records, err := d.client.GetRecords(ctx, zone.ID, recordset.ID) if err != nil { return fmt.Errorf("hyperone: %w", err) } if len(records) == 1 { if records[0].Content != info.Value { return fmt.Errorf("hyperone: record with content %s not found: fqdn=%s", info.Value, info.EffectiveFQDN) } err = d.client.DeleteRecordset(ctx, zone.ID, recordset.ID) if err != nil { return fmt.Errorf("hyperone: failed to delete record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", info.EffectiveFQDN, zone.ID, recordset.ID, err) } return nil } for _, record := range records { if record.Content == info.Value { err = d.client.DeleteRecord(ctx, 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", info.EffectiveFQDN, zone.ID, recordset.ID, record.ID, err) } return nil } } return fmt.Errorf("hyperone: fqdn=%s, failed to find record with given value", info.EffectiveFQDN) } // getHostedZone gets the hosted zone. func (d *DNSProvider) getHostedZone(ctx context.Context, fqdn string) (*internal.Zone, error) { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) } return d.client.FindZone(ctx, 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 } ================================================ FILE: providers/dns/hyperone/hyperone.toml ================================================ Name = "HyperOne" Description = '''''' URL = "https://www.hyperone.com" Code = "hyperone" Since = "v3.9.0" Example = ''' lego --dns hyperone -d '*.example.com' -d example.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 iam project sa credential generate --name my-passport --project --sa --passport-output-file ~/.h1/passport.json ``` ### Required permissions The application requires following permissions: - `dns/zone/list` - `dns/zone.recordset/list` - `dns/zone.recordset/create` - `dns/zone.recordset/delete` - `dns/zone.record/create` - `dns/zone.record/list` - `dns/zone.record/delete` All required permissions are available via platform role `tool.lego`. ''' [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 in seconds (Default: 120)" HYPERONE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2)" HYPERONE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" HYPERONE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.hyperone.com/v2/docs" ================================================ FILE: providers/dns/hyperone/hyperone_test.go ================================================ package hyperone import ( "testing" "github.com/go-acme/lego/v4/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 test.expected == "" { 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 test.expected == "" { 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) } ================================================ FILE: providers/dns/hyperone/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) 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 { passport *Passport signer signer baseURL *url.URL HTTPClient *http.Client } // 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 } if apiEndpoint == "" { apiEndpoint = defaultBaseURL } baseURL, err := url.Parse(apiEndpoint) if err != nil { return nil, err } tokenSigner := &TokenSigner{ PrivateKey: passport.PrivateKey, KeyID: passport.CertificateID, Audience: apiEndpoint, Issuer: passport.Issuer, Subject: passport.SubjectID, } if locationID == "" { locationID = defaultLocationID } client := &Client{ HTTPClient: &http.Client{Timeout: 5 * time.Second}, baseURL: baseURL.JoinPath("dns", locationID, "project", projectID), passport: passport, signer: tokenSigner, } 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(ctx context.Context, zoneID, recordType, name string) (*Recordset, error) { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, 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(ctx context.Context, zoneID, recordType, name, recordValue string, ttl int) (*Recordset, error) { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset") recordsetInput := Recordset{ RecordType: recordType, Name: name, TTL: ttl, Record: &Record{Content: recordValue}, } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, recordsetInput) 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(ctx context.Context, zoneID, recordsetID string) error { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId} endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, 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(ctx context.Context, zoneID, recordsetID string) ([]Record, error) { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, 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(ctx context.Context, zoneID, recordsetID, recordContent string) (*Record, error) { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, Record{Content: recordContent}) 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(ctx context.Context, zoneID, recordsetID, recordID string) error { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record/{recordId} endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, 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(ctx context.Context, name string) (*Zone, error) { zones, err := c.GetZones(ctx) 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(ctx context.Context) ([]Zone, error) { // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone endpoint := c.baseURL.JoinPath("zone") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, 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) do(req *http.Request, result any) error { jwt, err := c.signer.GetJWT() if err != nil { return fmt.Errorf("failed to sign the request: %w", err) } req.Header.Set("Authorization", "Bearer "+jwt) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if err = json.Unmarshal(raw, result); err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { 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 = "unknown error" } return fmt.Errorf("%s: %w", msg, errutils.NewUnexpectedResponseStatusCodeError(req, resp)) } ================================================ FILE: providers/dns/hyperone/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type signerMock struct{} func (s signerMock) GetJWT() (string, error) { return "", nil } func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { passport := &Passport{ SubjectID: "/iam/project/proj123/sa/xxxxxxx", } client, err := NewClient(server.URL, "loc123", passport) if err != nil { return nil, err } client.HTTPClient = server.Client() client.signer = signerMock{} return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer")) } func TestClient_FindRecordset(t *testing.T) { client := mockBuilder(). Route("GET /dns/loc123/project/proj123/zone/zone321/recordset", servermock.ResponseFromFixture("recordset.json")). Build(t) recordset, err := client.FindRecordset(t.Context(), "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 := mockBuilder(). Route("POST /dns/loc123/project/proj123/zone/zone123/recordset", servermock.ResponseFromFixture("createRecordset.json"), servermock.CheckRequestJSONBodyFromStruct(expectedReqBody)). Build(t) rs, err := client.CreateRecordset(t.Context(), "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 := mockBuilder(). Route("DELETE /dns/loc123/project/proj123/zone/zone321/recordset/rs322", nil). Build(t) err := client.DeleteRecordset(t.Context(), "zone321", "rs322") require.NoError(t, err) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/loc123/project/proj123/zone/321/recordset/322/record", servermock.ResponseFromFixture("record.json")). Build(t) records, err := client.GetRecords(t.Context(), "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 := mockBuilder(). Route("POST /dns/loc123/project/proj123/zone/z123/recordset/rs325/record", servermock.ResponseFromFixture("createRecord.json"), servermock.CheckRequestJSONBodyFromStruct(expectedReqBody)). Build(t) rs, err := client.CreateRecord(t.Context(), "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 := mockBuilder(). Route("DELETE /dns/loc123/project/proj123/zone/321/recordset/322/record/323", servermock.ResponseFromFixture("createRecord.json")). Build(t) err := client.DeleteRecord(t.Context(), "321", "322", "323") require.NoError(t, err) } func TestClient_FindZone(t *testing.T) { client := mockBuilder(). Route("GET /dns/loc123/project/proj123/zone", servermock.ResponseFromFixture("zones.json")). Build(t) zone, err := client.FindZone(t.Context(), "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 := mockBuilder(). Route("GET /dns/loc123/project/proj123/zone", servermock.ResponseFromFixture("zones.json")). Build(t) zones, err := client.GetZones(t.Context()) 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) } ================================================ FILE: providers/dns/hyperone/internal/fixtures/createRecord.json ================================================ { "id": "123321qwerqwewqerq", "content": "value", "enabled": true } ================================================ FILE: providers/dns/hyperone/internal/fixtures/createRecordset.json ================================================ { "id": "1234567890qwertyuiop", "name": "test.example.com.", "type": "TXT", "ttl": 3600 } ================================================ FILE: providers/dns/hyperone/internal/fixtures/invalidPassport.json ================================================ { "subject_id": "/iam/project/projectId/sa/serviceAccountId", "certificate_id": "certificateID", "issuer": "https://api.hyperone.com/v2/iam/project/projectId/sa/serviceAccountId" } ================================================ FILE: providers/dns/hyperone/internal/fixtures/record.json ================================================ [ { "id": "135128352183572dd", "content": "pns.hyperone.com. hostmaster.hyperone.com. 1 15 180 1209600 1800", "enabled": true } ] ================================================ FILE: providers/dns/hyperone/internal/fixtures/recordset.json ================================================ [ { "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 } ] ================================================ FILE: providers/dns/hyperone/internal/fixtures/validPassport.json ================================================ { "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" } ================================================ FILE: providers/dns/hyperone/internal/fixtures/zones.json ================================================ [ { "id": "zoneA", "name": "example.org", "dnsName": "example.org", "fqdn": "example.org.", "uri": "" }, { "id": "zoneB", "name": "example.com", "dnsName": "example.com", "fqdn": "example.com.", "uri": "" } ] ================================================ FILE: providers/dns/hyperone/internal/passport.go ================================================ 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 } ================================================ FILE: providers/dns/hyperone/internal/passport_test.go ================================================ 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) } ================================================ FILE: providers/dns/hyperone/internal/token.go ================================================ package internal import ( "crypto/rsa" "crypto/x509" "encoding/pem" "fmt" "time" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/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.Serialize() 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 } ================================================ FILE: providers/dns/hyperone/internal/token_test.go ================================================ package internal import ( "crypto/rand" "crypto/rsa" "encoding/base64" "encoding/json" "strings" "testing" "github.com/go-acme/lego/v4/certcrypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type Header struct { Algorithm string `json:"alg"` Type string `json:"typ"` KeyID string `json:"kid"` } func TestPayload_buildToken(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) signer, err := getRSASigner(string(certcrypto.PEMEncode(key)), "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) } ================================================ FILE: providers/dns/hyperone/internal/types.go ================================================ 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"` } ================================================ FILE: providers/dns/ibmcloud/ibmcloud.go ================================================ // Package ibmcloud implements a DNS provider for solving the DNS-01 challenge using IBM Cloud (SoftLayer). package ibmcloud import ( "errors" "fmt" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/ibmcloud/internal" "github.com/softlayer/softlayer-go/session" ) // Environment variables names. const ( envNamespace = "SOFTLAYER_" // EnvUsername the name must be the same as here: // https://github.com/softlayer/softlayer-go/blob/534185047ea683dd1e29fd23e445598295d94be4/session/session.go#L171 EnvUsername = envNamespace + "USERNAME" // EnvAPIKey the name must be the same as here: // https://github.com/softlayer/softlayer-go/blob/534185047ea683dd1e29fd23e445598295d94be4/session/session.go#L175 EnvAPIKey = envNamespace + "API_KEY" // EnvHTTPTimeout the name must be the same as here: // https://github.com/softlayer/softlayer-go/blob/534185047ea683dd1e29fd23e445598295d94be4/session/session.go#L182 EnvHTTPTimeout = envNamespace + "TIMEOUT" EnvDebug = envNamespace + "DEBUG" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout time.Duration Debug bool } // 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), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, session.DefaultTimeout), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config wrapper *internal.Wrapper } // NewDNSProvider returns a DNSProvider instance configured for IBM Cloud (SoftLayer). // Credentials must be passed in the environment variables: // SOFTLAYER_USERNAME, SOFTLAYER_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvAPIKey) if err != nil { return nil, fmt.Errorf("ibmcloud: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.APIKey = values[EnvAPIKey] config.Debug = env.GetOrDefaultBool(EnvDebug, false) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for IBM Cloud (SoftLayer). func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ibmcloud: the configuration of the DNS provider is nil") } if config.Username == "" { return nil, errors.New("ibmcloud: username is missing") } if config.APIKey == "" { return nil, errors.New("ibmcloud: API key is missing") } sess := session.New(config.Username, config.APIKey) sess.Timeout = config.HTTPTimeout sess.Debug = config.Debug return &DNSProvider{wrapper: internal.NewWrapper(sess), 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 { info := dns01.GetChallengeInfo(domain, keyAuth) // TODO(ldez) replace domain by FQDN to follow CNAME. err := d.wrapper.AddTXTRecord(info.EffectiveFQDN, domain, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("ibmcloud: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // TODO(ldez) replace domain by FQDN to follow CNAME. err := d.wrapper.CleanupTXTRecord(info.EffectiveFQDN, domain) if err != nil { return fmt.Errorf("ibmcloud: %w", err) } return nil } ================================================ FILE: providers/dns/ibmcloud/ibmcloud.toml ================================================ Name = "IBM Cloud (SoftLayer)" Description = '''''' URL = "https://www.ibm.com/cloud/" Code = "ibmcloud" Since = "v4.5.0" Example = ''' SOFTLAYER_USERNAME=xxxxx \ SOFTLAYER_API_KEY=yyyyy \ lego --dns ibmcloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SOFTLAYER_USERNAME = "Username (IBM Cloud is {accountID}_{emailAddress})" SOFTLAYER_API_KEY = "Classic Infrastructure API key" [Configuration.Additional] SOFTLAYER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" SOFTLAYER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" SOFTLAYER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SOFTLAYER_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://cloud.ibm.com/docs/dns?topic=dns-getting-started-with-the-dns-api" GoClient = "https://github.com/softlayer/softlayer-go" ================================================ FILE: providers/dns/ibmcloud/ibmcloud_test.go ================================================ package ibmcloud import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "123", EnvAPIKey: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvUsername: "", EnvAPIKey: "", }, expected: "ibmcloud: some credentials information are missing: SOFTLAYER_USERNAME,SOFTLAYER_API_KEY", }, { desc: "missing access token", envVars: map[string]string{ EnvUsername: "", EnvAPIKey: "456", }, expected: "ibmcloud: some credentials information are missing: SOFTLAYER_USERNAME", }, { desc: "missing token secret", envVars: map[string]string{ EnvUsername: "123", EnvAPIKey: "", }, expected: "ibmcloud: some credentials information are missing: SOFTLAYER_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.wrapper) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string apiKey string expected string }{ { desc: "success", username: "123", apiKey: "456", }, { desc: "missing credentials", expected: "ibmcloud: username is missing", }, { desc: "missing token", apiKey: "456", expected: "ibmcloud: username is missing", }, { desc: "missing secret", username: "123", expected: "ibmcloud: API key is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.wrapper) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/ibmcloud/internal/wrapper.go ================================================ package internal import ( "fmt" "strings" "github.com/softlayer/softlayer-go/datatypes" "github.com/softlayer/softlayer-go/services" "github.com/softlayer/softlayer-go/session" "github.com/softlayer/softlayer-go/sl" ) type Wrapper struct { session *session.Session } func NewWrapper(sess *session.Session) *Wrapper { return &Wrapper{session: sess} } func (w Wrapper) AddTXTRecord(fqdn, domain, value string, ttl int) error { service := services.GetDnsDomainService(w.session) domainID, err := getDomainID(service, domain) if err != nil { return fmt.Errorf("failed to get domain ID: %w", err) } service.Options.Id = domainID if _, err := service.CreateTxtRecord(sl.String(fqdn), sl.String(value), sl.Int(ttl)); err != nil { return fmt.Errorf("failed to create TXT record: %w", err) } return nil } func (w Wrapper) CleanupTXTRecord(fqdn, domain string) error { service := services.GetDnsDomainService(w.session) domainID, err := getDomainID(service, domain) if err != nil { return fmt.Errorf("failed to get domain ID: %w", err) } service.Options.Id = domainID records, err := findTxtRecords(service, fqdn) if err != nil { return fmt.Errorf("failed to find TXT records: %w", err) } return deleteResourceRecords(service, records) } func getDomainID(service services.Dns_Domain, domain string) (*int, error) { res, err := service.GetByDomainName(sl.String(domain)) if err != nil { return nil, err } for _, r := range res { if r.Id == nil || toString(r.Name) != domain { continue } return r.Id, nil } // The domain was not found by name. // For subdomains this is not unusual in softlayer. // So in case a subdomain like `sub.toplevel.tld` was used try again using the parent domain // (strip the first part in the domain string -> `toplevel.tld`). _, parent, found := strings.Cut(domain, ".") if !found || !strings.Contains(parent, ".") { return nil, fmt.Errorf("no data found for domain: %s", domain) } return getDomainID(service, parent) } func findTxtRecords(service services.Dns_Domain, fqdn string) ([]datatypes.Dns_Domain_ResourceRecord, error) { var results []datatypes.Dns_Domain_ResourceRecord records, err := service.GetResourceRecords() if err != nil { return nil, err } for _, record := range records { if toString(record.Host) == fqdn && toString(record.Type) == "txt" { results = append(results, record) } } if len(results) == 0 { return nil, fmt.Errorf("no data found of fqdn: %s", fqdn) } return results, nil } func deleteResourceRecords(service services.Dns_Domain, records []datatypes.Dns_Domain_ResourceRecord) error { resourceRecord := services.GetDnsDomainResourceRecordService(service.Session) // TODO maybe a bug: only the last record will be deleted for _, record := range records { resourceRecord.Options.Id = record.Id } _, err := resourceRecord.DeleteObject() if err != nil { return fmt.Errorf("no data found of fqdn: %w", err) } return nil } func toString(v *string) string { if v == nil { return "" } return *v } ================================================ FILE: providers/dns/iij/iij.go ================================================ // Package iij implements a DNS provider for solving the DNS-01 challenge using IIJ DNS. package iij import ( "errors" "fmt" "slices" "strconv" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/iij/doapi" "github.com/iij/doapi/protocol" "github.com/miekg/dns" ) // Environment variables names. const ( envNamespace = "IIJ_" EnvAPIAccessKey = envNamespace + "API_ACCESS_KEY" EnvAPISecretKey = envNamespace + "API_SECRET_KEY" EnvDoServiceCode = envNamespace + "DO_SERVICE_CODE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AccessKey string SecretKey string DoServiceCode string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { api *doapi.API config *Config } // NewDNSProvider returns a DNSProvider instance configured for IIJ DNS. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIAccessKey, EnvAPISecretKey, EnvDoServiceCode) if err != nil { return nil, fmt.Errorf("iij: %w", err) } config := NewDefaultConfig() config.AccessKey = values[EnvAPIAccessKey] config.SecretKey = values[EnvAPISecretKey] config.DoServiceCode = values[EnvDoServiceCode] return NewDNSProviderConfig(config) } // NewDNSProviderConfig takes a given config // and returns a custom configured DNSProvider instance. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.SecretKey == "" || config.AccessKey == "" || config.DoServiceCode == "" { return nil, errors.New("iij: credentials missing") } return &DNSProvider{ api: doapi.NewAPI(config.AccessKey, config.SecretKey), config: config, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // TODO(ldez) replace domain by FQDN to follow CNAME. err := d.addTxtRecord(domain, info.Value) if err != nil { return fmt.Errorf("iij: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // TODO(ldez) replace domain by FQDN to follow CNAME. err := d.deleteTxtRecord(domain, info.Value) if err != nil { return fmt.Errorf("iij: %w", err) } return nil } func (d *DNSProvider) addTxtRecord(domain, value string) error { zones, err := d.listZones() if err != nil { return err } // TODO(ldez) replace domain by FQDN to follow CNAME. owner, zone, err := splitDomain(domain, zones) if err != nil { return err } request := protocol.RecordAdd{ DoServiceCode: d.config.DoServiceCode, ZoneName: zone, Owner: owner, TTL: strconv.Itoa(d.config.TTL), RecordType: "TXT", RData: value, } response := &protocol.RecordAddResponse{} if err := doapi.Call(*d.api, request, response); err != nil { return err } return d.commit() } func (d *DNSProvider) deleteTxtRecord(domain, value string) error { zones, err := d.listZones() if err != nil { return err } owner, zone, err := splitDomain(domain, zones) if err != nil { return err } id, err := d.findTxtRecord(owner, zone, value) if err != nil { return err } request := protocol.RecordDelete{ DoServiceCode: d.config.DoServiceCode, ZoneName: zone, RecordID: id, } response := &protocol.RecordDeleteResponse{} if err := doapi.Call(*d.api, request, response); err != nil { return err } return d.commit() } func (d *DNSProvider) commit() error { request := protocol.Commit{ DoServiceCode: d.config.DoServiceCode, } response := &protocol.CommitResponse{} return doapi.Call(*d.api, request, response) } func (d *DNSProvider) findTxtRecord(owner, zone, value string) (string, error) { request := protocol.RecordListGet{ DoServiceCode: d.config.DoServiceCode, ZoneName: zone, } response := &protocol.RecordListGetResponse{} if err := doapi.Call(*d.api, request, response); err != nil { return "", err } var id string for _, record := range response.RecordList { if record.Owner == owner && record.RecordType == "TXT" && record.RData == "\""+value+"\"" { id = record.Id } } if id == "" { return "", fmt.Errorf("%s record in %s not found", owner, zone) } return id, nil } func (d *DNSProvider) listZones() ([]string, error) { request := protocol.ZoneListGet{ DoServiceCode: d.config.DoServiceCode, } response := &protocol.ZoneListGetResponse{} if err := doapi.Call(*d.api, request, response); err != nil { return nil, err } return response.ZoneList, nil } func splitDomain(domain string, zones []string) (string, string, error) { base := dns01.UnFqdn(domain) for _, index := range dns.Split(base) { zone := base[index:] if slices.Contains(zones, zone) { baseOwner := base[:index] if baseOwner != "" { baseOwner = "." + baseOwner } return "_acme-challenge" + dns01.UnFqdn(baseOwner), zone, nil } } return "", "", fmt.Errorf("%s not found", domain) } ================================================ FILE: providers/dns/iij/iij.toml ================================================ Name = "Internet Initiative Japan" Description = '''''' URL = "https://www.iij.ad.jp/en/" Code = "iij" Since = "v1.1.0" Example = ''' IIJ_API_ACCESS_KEY=xxxxxxxx \ IIJ_API_SECRET_KEY=yyyyyy \ IIJ_DO_SERVICE_CODE=zzzzzz \ lego --dns iij -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] IIJ_API_ACCESS_KEY = "API access key" IIJ_API_SECRET_KEY = "API secret key" IIJ_DO_SERVICE_CODE = "DO service code" [Configuration.Additional] IIJ_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" IIJ_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)" IIJ_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" [Links] API = "https://manual.iij.jp/p2/pubapi/" GoClient = "https://github.com/iij/doapi" ================================================ FILE: providers/dns/iij/iij_test.go ================================================ package iij import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "TESTDOMAIN" var envTest = tester.NewEnvTest( EnvAPIAccessKey, EnvAPISecretKey, EnvDoServiceCode). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIAccessKey: "A", EnvAPISecretKey: "B", EnvDoServiceCode: "C", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIAccessKey: "", EnvAPISecretKey: "", EnvDoServiceCode: "", }, expected: "iij: some credentials information are missing: IIJ_API_ACCESS_KEY,IIJ_API_SECRET_KEY,IIJ_DO_SERVICE_CODE", }, { desc: "missing api access key", envVars: map[string]string{ EnvAPIAccessKey: "", EnvAPISecretKey: "B", EnvDoServiceCode: "C", }, expected: "iij: some credentials information are missing: IIJ_API_ACCESS_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAPIAccessKey: "A", EnvAPISecretKey: "", EnvDoServiceCode: "C", }, expected: "iij: some credentials information are missing: IIJ_API_SECRET_KEY", }, { desc: "missing do service code", envVars: map[string]string{ EnvAPIAccessKey: "A", EnvAPISecretKey: "B", EnvDoServiceCode: "", }, expected: "iij: some credentials information are missing: IIJ_DO_SERVICE_CODE", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.api) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accessKey string secretKey string doServiceCode string expected string }{ { desc: "success", accessKey: "A", secretKey: "B", doServiceCode: "C", }, { desc: "missing credentials", expected: "iij: credentials missing", }, { desc: "missing access key", accessKey: "", secretKey: "B", doServiceCode: "C", expected: "iij: credentials missing", }, { desc: "missing secret key", accessKey: "A", secretKey: "", doServiceCode: "C", expected: "iij: credentials missing", }, { desc: "missing do service code", accessKey: "A", secretKey: "B", doServiceCode: "", expected: "iij: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessKey = test.accessKey config.SecretKey = test.secretKey config.DoServiceCode = test.doServiceCode p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.api) } else { require.EqualError(t, err, test.expected) } }) } } func TestSplitDomain(t *testing.T) { testCases := []struct { desc string domain string zones []string expectedOwner string expectedZone string }{ { desc: "domain equals zone", domain: "example.com", zones: []string{"example.com"}, expectedOwner: "_acme-challenge", expectedZone: "example.com", }, { desc: "with a subdomain", domain: "my.example.com", zones: []string{"example.com"}, expectedOwner: "_acme-challenge.my", expectedZone: "example.com", }, { desc: "with a subdomain in a zone", domain: "my.sub.example.com", zones: []string{"sub.example.com", "example.com"}, expectedOwner: "_acme-challenge.my", expectedZone: "sub.example.com", }, { desc: "with a sub-subdomain", domain: "my.sub.example.com", zones: []string{"domain1.com", "example.com"}, expectedOwner: "_acme-challenge.my.sub", expectedZone: "example.com", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() owner, zone, err := splitDomain(test.domain, test.zones) require.NoError(t, err) assert.Equal(t, test.expectedOwner, owner) assert.Equal(t, test.expectedZone, zone) }) } } func TestSplitDomain_error(t *testing.T) { testCases := []struct { desc string domain string zones []string expectedOwner string expectedZone string }{ { desc: "no zone", domain: "example.com", zones: nil, }, { desc: "domain does not contain zone", domain: "example.com", zones: []string{"example.org"}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() _, _, err := splitDomain(test.domain, test.zones) require.Error(t, err) }) } } 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) } ================================================ FILE: providers/dns/iijdpf/iijdpf.go ================================================ // Package iijdpf implements a DNS provider for solving the DNS-01 challenge using IIJ DNS Platform Service. package iijdpf import ( "context" "errors" "fmt" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/miekg/dns" dpfapi "github.com/mimuret/golang-iij-dpf/pkg/api" dpfapiutils "github.com/mimuret/golang-iij-dpf/pkg/apiutils" ) // Environment variables names. const ( envNamespace = "IIJ_DPF_" EnvAPIToken = envNamespace + "API_TOKEN" EnvServiceCode = envNamespace + "DPM_SERVICE_CODE" EnvAPIEndpoint = envNamespace + "API_ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string ServiceCode string Endpoint string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ Endpoint: env.GetOrDefaultString(EnvAPIEndpoint, dpfapi.DefaultEndpoint), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 660*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), TTL: env.GetOrDefaultInt(EnvTTL, 300), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client dpfapi.ClientInterface config *Config } // NewDNSProvider returns a DNSProvider instance configured for IIJ DNS. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken, EnvServiceCode) if err != nil { return nil, fmt.Errorf("iijdpf: %w", err) } config := NewDefaultConfig() config.Token = values[EnvAPIToken] config.ServiceCode = values[EnvServiceCode] return NewDNSProviderConfig(config) } // NewDNSProviderConfig takes a given config // and returns a custom configured DNSProvider instance. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.Token == "" { return nil, errors.New("iijdpf: API token missing") } if config.ServiceCode == "" { return nil, errors.New("iijdpf: Servicecode missing") } return &DNSProvider{ client: dpfapi.NewClient(config.Token, config.Endpoint, nil), 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 using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zoneID, err := dpfapiutils.GetZoneIdFromServiceCode(ctx, d.client, d.config.ServiceCode) if err != nil { return fmt.Errorf("iijdpf: failed to get zone id: %w", err) } err = d.addTxtRecord(ctx, zoneID, dns.CanonicalName(info.EffectiveFQDN), `"`+info.Value+`"`) if err != nil { return fmt.Errorf("iijdpf: %w", err) } err = d.commit(ctx, zoneID) if err != nil { return fmt.Errorf("iijdpf: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zoneID, err := dpfapiutils.GetZoneIdFromServiceCode(ctx, d.client, d.config.ServiceCode) if err != nil { return fmt.Errorf("iijdpf: failed to get zone id: %w", err) } err = d.deleteTxtRecord(ctx, zoneID, dns.CanonicalName(info.EffectiveFQDN), `"`+info.Value+`"`) if err != nil { return fmt.Errorf("iijdpf: %w", err) } err = d.commit(ctx, zoneID) if err != nil { return fmt.Errorf("iijdpf: %w", err) } return nil } ================================================ FILE: providers/dns/iijdpf/iijdpf.toml ================================================ Name = "IIJ DNS Platform Service" Description = '''''' URL = "https://www.iij.ad.jp/en/biz/dns-pfm/" Code = "iijdpf" Since = "v4.7.0" Example = ''' IIJ_DPF_API_TOKEN=xxxxxxxx \ IIJ_DPF_DPM_SERVICE_CODE=yyyyyy \ lego --dns iijdpf -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] IIJ_DPF_API_TOKEN = "API token" IIJ_DPF_DPM_SERVICE_CODE = "IIJ Managed DNS Service's service code" [Configuration.Additional] IIJ_DPF_API_ENDPOINT = "API endpoint URL, defaults to https://api.dns-platform.jp/dpf/v1" IIJ_DPF_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" IIJ_DPF_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 660)" IIJ_DPF_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" [Links] API = "https://manual.iij.jp/dpf/dpfapi/" GoClient = "https://github.com/mimuret/golang-iij-dpf" ================================================ FILE: providers/dns/iijdpf/iijdpf_test.go ================================================ package iijdpf import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "TESTDOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken, EnvServiceCode).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "A", EnvServiceCode: "dpmXXXXXX", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIToken: "A", }, expected: "iijdpf: some credentials information are missing: IIJ_DPF_DPM_SERVICE_CODE", }, { desc: "missing credentials", envVars: map[string]string{ EnvServiceCode: "dpmXXXXXX", }, expected: "iijdpf: some credentials information are missing: IIJ_DPF_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string servicecode string expected string }{ { desc: "success", token: "A", servicecode: "dpm00000", }, { desc: "missing credentials", servicecode: "dpm00000", expected: "iijdpf: API token missing", }, { desc: "missing credentials", token: "A", expected: "iijdpf: Servicecode missing", }, { desc: "missing credentials", expected: "iijdpf: API token missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token config.ServiceCode = test.servicecode p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/iijdpf/wrapper.go ================================================ package iijdpf import ( "context" "errors" "fmt" dpfzones "github.com/mimuret/golang-iij-dpf/pkg/apis/dpf/v1/zones" dpfapiutils "github.com/mimuret/golang-iij-dpf/pkg/apiutils" dpftypes "github.com/mimuret/golang-iij-dpf/pkg/types" ) func (d *DNSProvider) addTxtRecord(ctx context.Context, zoneID, fqdn, rdata string) error { r, err := dpfapiutils.GetRecordFromZoneID(ctx, d.client, zoneID, fqdn, dpfzones.TypeTXT) if err != nil && !errors.Is(err, dpfapiutils.ErrRecordNotFound) { return err } if r != nil { r.RData = append(r.RData, dpfzones.RecordRDATA{Value: rdata}) _, _, err = dpfapiutils.SyncUpdate(ctx, d.client, r, nil) if err != nil { return fmt.Errorf("failed to update record: %w", err) } return nil } record := &dpfzones.Record{ AttributeMeta: dpfzones.AttributeMeta{ZoneID: zoneID}, Name: fqdn, TTL: dpftypes.NullablePositiveInt32(d.config.TTL), RRType: dpfzones.TypeTXT, RData: dpfzones.RecordRDATASlice{dpfzones.RecordRDATA{Value: rdata}}, Description: "ACME", } _, _, err = dpfapiutils.SyncCreate(ctx, d.client, record, nil) if err != nil { return fmt.Errorf("failed to create record: %w", err) } return nil } func (d *DNSProvider) deleteTxtRecord(ctx context.Context, zoneID, fqdn, rdata string) error { r, err := dpfapiutils.GetRecordFromZoneID(ctx, d.client, zoneID, fqdn, dpfzones.TypeTXT) if err != nil { if errors.Is(err, dpfapiutils.ErrRecordNotFound) { // empty target rrset return nil } return err } if len(r.RData) == 1 { // delete rrset _, _, err = dpfapiutils.SyncDelete(ctx, d.client, r) if err != nil { return fmt.Errorf("failed to delete record: %w", err) } return nil } // delete rdata rdataSlice := dpfzones.RecordRDATASlice{} for _, v := range r.RData { if v.Value != rdata { rdataSlice = append(rdataSlice, v) } } r.RData = rdataSlice _, _, err = dpfapiutils.SyncUpdate(ctx, d.client, r, nil) if err != nil { return fmt.Errorf("failed to update record: %w", err) } return nil } func (d *DNSProvider) commit(ctx context.Context, zoneID string) error { apply := &dpfzones.ZoneApply{ AttributeMeta: dpfzones.AttributeMeta{ZoneID: zoneID}, Description: "ACME Processing", } _, _, err := dpfapiutils.SyncApply(ctx, d.client, apply, nil) if err != nil { return fmt.Errorf("failed to apply zone: %w", err) } return nil } ================================================ FILE: providers/dns/infoblox/infoblox.go ================================================ // Package infoblox implements a DNS provider for solving the DNS-01 challenge using on prem infoblox DNS. package infoblox import ( "errors" "fmt" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" infoblox "github.com/infobloxopen/infoblox-go-client/v2" ) // Environment variables names. const ( envNamespace = "INFOBLOX_" EnvHost = envNamespace + "HOST" EnvPort = envNamespace + "PORT" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvDNSView = envNamespace + "DNS_VIEW" EnvWApiVersion = envNamespace + "WAPI_VERSION" EnvSSLVerify = envNamespace + "SSL_VERIFY" EnvCACertificate = envNamespace + "CA_CERTIFICATE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultPoolConnections = 10 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { // Host is the URL of the grid manager. Host string // Port is the Port for the grid manager. Port string // Username the user for accessing API. Username string // Password the password for accessing API. Password string // DNSView is the dns view to put new records and search from. DNSView string // WapiVersion is the version of web api used. WapiVersion string // SSLVerify is whether or not to verify the ssl of the server being hit. SSLVerify bool // CACertificate is the path to the CA certificate (PEM encoded). CACertificate string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ DNSView: env.GetOrDefaultString(EnvDNSView, "External"), WapiVersion: env.GetOrDefaultString(EnvWApiVersion, "2.11"), Port: env.GetOrDefaultString(EnvPort, "443"), SSLVerify: env.GetOrDefaultBool(EnvSSLVerify, true), CACertificate: env.GetOrDefaultString(EnvCACertificate, ""), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPTimeout: env.GetOrDefaultInt(EnvHTTPTimeout, 30), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config transportConfig infoblox.TransportConfig ibConfig infoblox.HostConfig ibAuth infoblox.AuthConfig recordRefs map[string]string recordRefsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Infoblox. // Credentials must be passed in the environment variables: // INFOBLOX_USERNAME, INFOBLOX_PASSWORD // INFOBLOX_HOST, INFOBLOX_PORT // INFOBLOX_DNS_VIEW, INFOBLOX_WAPI_VERSION // INFOBLOX_SSL_VERIFY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvHost, EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("infoblox: %w", err) } config := NewDefaultConfig() config.Host = values[EnvHost] config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for HyperOne. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("infoblox: the configuration of the DNS provider is nil") } if config.Host == "" { return nil, errors.New("infoblox: missing host") } if config.Username == "" || config.Password == "" { return nil, errors.New("infoblox: missing credentials") } var sslVerify string if config.CACertificate != "" { sslVerify = config.CACertificate } else { sslVerify = strconv.FormatBool(config.SSLVerify) } return &DNSProvider{ config: config, transportConfig: infoblox.NewTransportConfig(sslVerify, config.HTTPTimeout, defaultPoolConnections), ibConfig: infoblox.HostConfig{ Host: config.Host, Version: config.WapiVersion, Port: config.Port, }, ibAuth: infoblox.AuthConfig{ Username: config.Username, Password: config.Password, }, recordRefs: make(map[string]string), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. 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 { info := dns01.GetChallengeInfo(domain, keyAuth) connector, err := infoblox.NewConnector(d.ibConfig, d.ibAuth, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{}) if err != nil { return fmt.Errorf("infoblox: %w", err) } defer func() { _ = connector.Logout() }() objectManager := infoblox.NewObjectManager(connector, useragent.Get(), "") record, err := objectManager.CreateTXTRecord(d.config.DNSView, dns01.UnFqdn(info.EffectiveFQDN), info.Value, uint32(d.config.TTL), true, "lego", nil) if err != nil { return fmt.Errorf("infoblox: could not create TXT record for %s: %w", domain, err) } d.recordRefsMu.Lock() d.recordRefs[token] = record.Ref d.recordRefsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) connector, err := infoblox.NewConnector(d.ibConfig, d.ibAuth, d.transportConfig, &infoblox.WapiRequestBuilder{}, &infoblox.WapiHttpRequestor{}) if err != nil { return fmt.Errorf("infoblox: %w", err) } defer func() { _ = connector.Logout() }() objectManager := infoblox.NewObjectManager(connector, useragent.Get(), "") // gets the record's unique ref from when we created it d.recordRefsMu.Lock() recordRef, ok := d.recordRefs[token] d.recordRefsMu.Unlock() if !ok { return fmt.Errorf("infoblox: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } _, err = objectManager.DeleteTXTRecord(recordRef) if err != nil { return fmt.Errorf("infoblox: could not delete TXT record for %s: %w", domain, err) } // Delete record ref from map d.recordRefsMu.Lock() delete(d.recordRefs, token) d.recordRefsMu.Unlock() return nil } ================================================ FILE: providers/dns/infoblox/infoblox.toml ================================================ Name = "Infoblox" Description = '''''' URL = "https://www.infoblox.com/" Code = "infoblox" Since = "v4.4.0" Example = ''' INFOBLOX_USERNAME=api-user-529 \ INFOBLOX_PASSWORD=b9841238feb177a84330febba8a83208921177bffe733 \ INFOBLOX_HOST=infoblox.example.org lego --dns infoblox -d '*.example.com' -d example.com run ''' Additional = ''' When creating an API's user ensure it has the proper permissions for the view you are working with. ''' [Configuration] [Configuration.Credentials] INFOBLOX_USERNAME = "Account Username" INFOBLOX_PASSWORD = "Account Password" INFOBLOX_HOST = "Host URI" [Configuration.Additional] INFOBLOX_DNS_VIEW = "The view for the TXT records (Default: External)" INFOBLOX_WAPI_VERSION = "The version of WAPI being used (Default: 2.11)" INFOBLOX_PORT = "The port for the infoblox grid manager (Default: 443)" INFOBLOX_SSL_VERIFY = "Whether or not to verify the TLS certificate (Default: true)" INFOBLOX_CA_CERTIFICATE = "The path to the CA certificate (PEM encoded)" INFOBLOX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" INFOBLOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" INFOBLOX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" INFOBLOX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://your.infoblox.server/wapidoc/" GoClient = "https://github.com/infobloxopen/infoblox-go-client" ================================================ FILE: providers/dns/infoblox/infoblox_test.go ================================================ package infoblox import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvHost, EnvPort, EnvUsername, EnvPassword, EnvSSLVerify, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvHost: "example.com", EnvUsername: "user", EnvPassword: "secret", EnvSSLVerify: "false", }, }, { desc: "missing host", envVars: map[string]string{ EnvHost: "", EnvUsername: "user", EnvPassword: "secret", EnvSSLVerify: "false", }, expected: "infoblox: some credentials information are missing: INFOBLOX_HOST", }, { desc: "missing username", envVars: map[string]string{ EnvHost: "example.com", EnvUsername: "", EnvPassword: "secret", EnvSSLVerify: "false", }, expected: "infoblox: some credentials information are missing: INFOBLOX_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvHost: "example.com", EnvUsername: "user", EnvPassword: "", EnvSSLVerify: "false", }, expected: "infoblox: some credentials information are missing: INFOBLOX_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string host string username string password string expected string }{ { desc: "success", host: "example.com", username: "user", password: "secret", }, { desc: "missing host", host: "", username: "user", password: "secret", expected: "infoblox: missing host", }, { desc: "missing username", host: "example.com", username: "", password: "secret", expected: "infoblox: missing credentials", }, { desc: "missing password", host: "example.com", username: "user", password: "", expected: "infoblox: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Host = test.host config.Username = test.username config.Password = test.password config.SSLVerify = false p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/infomaniak/infomaniak.go ================================================ // Package infomaniak implements a DNS provider for solving the DNS-01 challenge using Infomaniak DNS. package infomaniak import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/infomaniak/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Infomaniak API reference: https://api.infomaniak.com/doc // Create a Token: https://manager.infomaniak.com/v3/infomaniak-api // Environment variables names. const ( envNamespace = "INFOMANIAK_" EnvEndpoint = envNamespace + "ENDPOINT" EnvAccessToken = envNamespace + "ACCESS_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIEndpoint string AccessToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ APIEndpoint: env.GetOrDefaultString(EnvEndpoint, internal.DefaultBaseURL), TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex domainIDs map[string]uint64 domainIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Infomaniak. // Credentials must be passed in the environment variables: INFOMANIAK_ACCESS_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccessToken) if err != nil { return nil, fmt.Errorf("infomaniak: %w", err) } config := NewDefaultConfig() config.AccessToken = values[EnvAccessToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Infomaniak. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("infomaniak: the configuration of the DNS provider is nil") } if config.APIEndpoint == "" { return nil, errors.New("infomaniak: missing API endpoint") } if config.AccessToken == "" { return nil, errors.New("infomaniak: missing access token") } client, err := internal.New( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.AccessToken), ), config.APIEndpoint) if err != nil { return nil, fmt.Errorf("infomaniak: %w", err) } return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), domainIDs: make(map[string]uint64), }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() ikDomain, err := d.client.GetDomainByName(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("infomaniak: could not get domain %q: %w", info.EffectiveFQDN, err) } d.domainIDsMu.Lock() d.domainIDs[token] = ikDomain.ID d.domainIDsMu.Unlock() subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ikDomain.CustomerName) if err != nil { return fmt.Errorf("infomaniak: %w", err) } record := internal.Record{ Source: subDomain, Target: info.Value, Type: "TXT", TTL: d.config.TTL, } recordID, err := d.client.CreateDNSRecord(ctx, ikDomain, record) if err != nil { return fmt.Errorf("infomaniak: error when calling api to create DNS record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("infomaniak: unknown record ID for '%s'", info.EffectiveFQDN) } d.domainIDsMu.Lock() domainID, ok := d.domainIDs[token] d.domainIDsMu.Unlock() if !ok { return fmt.Errorf("infomaniak: unknown domain ID for '%s'", info.EffectiveFQDN) } err := d.client.DeleteDNSRecord(context.Background(), domainID, recordID) if err != nil { return fmt.Errorf("infomaniak: could not delete record %q: %w", dns01.UnFqdn(info.EffectiveFQDN), err) } // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() // Delete domain ID from map d.domainIDsMu.Lock() delete(d.domainIDs, token) d.domainIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/infomaniak/infomaniak.toml ================================================ Name = "Infomaniak" Description = '''''' URL = "https://www.infomaniak.com/" Code = "infomaniak" Since = "v4.1.0" Example = ''' INFOMANIAK_ACCESS_TOKEN=1234567898765432 \ lego --dns infomaniak -d '*.example.com' -d example.com run ''' Additional = ''' ## Access token Access token can be created at the url https://manager.infomaniak.com/v3/infomaniak-api. You will need domain scope. ''' [Configuration] [Configuration.Credentials] INFOMANIAK_ACCESS_TOKEN = "Access token" [Configuration.Additional] INFOMANIAK_ENDPOINT = "https://api.infomaniak.com" INFOMANIAK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" INFOMANIAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" INFOMANIAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" INFOMANIAK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.infomaniak.com/doc" ================================================ FILE: providers/dns/infomaniak/infomaniak_test.go ================================================ package infomaniak import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvEndpoint, EnvAccessToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessToken: "123", }, }, { desc: "missing access token", envVars: map[string]string{ EnvAccessToken: "", }, expected: "infomaniak: some credentials information are missing: INFOMANIAK_ACCESS_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accessToken string expected string }{ { desc: "success", accessToken: "123", }, { desc: "missing access token", accessToken: "", expected: "infomaniak: missing access token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessToken = test.accessToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/infomaniak/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) // DefaultBaseURL Default API endpoint. const DefaultBaseURL = "https://api.infomaniak.com" // Client the Infomaniak client. type Client struct { baseURL *url.URL httpClient *http.Client } // New Creates a new Infomaniak client. func New(hc *http.Client, apiEndpoint string) (*Client, error) { baseURL, err := url.Parse(apiEndpoint) if err != nil { return nil, err } if hc == nil { hc = &http.Client{Timeout: 5 * time.Second} } return &Client{baseURL: baseURL, httpClient: hc}, nil } func (c *Client) CreateDNSRecord(ctx context.Context, domain *DNSDomain, record Record) (string, error) { endpoint := c.baseURL.JoinPath("1", "domain", strconv.FormatUint(domain.ID, 10), "dns", "record") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } result := APIResponse[string]{} err = c.do(req, &result) if err != nil { return "", err } return result.Data, err } func (c *Client) DeleteDNSRecord(ctx context.Context, domainID uint64, recordID string) error { endpoint := c.baseURL.JoinPath("1", "domain", strconv.FormatUint(domainID, 10), "dns", "record", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } return c.do(req, &APIResponse[json.RawMessage]{}) } // GetDomainByName gets a Domain object from its name. func (c *Client) GetDomainByName(ctx context.Context, name string) (*DNSDomain, error) { name = dns01.UnFqdn(name) // Try to find the most specific domain // starts with the FQDN, then remove each left label until we have a match for { i := strings.Index(name, ".") if i == -1 { break } domain, err := c.getDomainByName(ctx, name) if err != nil { return nil, err } if domain != nil { return domain, nil } log.Infof("domain %q not found, trying with %q", name, name[i+1:]) name = name[i+1:] } return nil, fmt.Errorf("domain not found %s", name) } func (c *Client) getDomainByName(ctx context.Context, name string) (*DNSDomain, error) { endpoint := c.baseURL.JoinPath("1", "product") query := endpoint.Query() query.Add("service_name", "domain") query.Add("customer_name", name) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := APIResponse[[]DNSDomain]{} err = c.do(req, &result) if err != nil { return nil, err } for _, domain := range result.Data { if domain.CustomerName == name { return &domain, nil } } return nil, nil } func (c *Client) do(req *http.Request, result Response) error { resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if err := json.Unmarshal(raw, result); err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if result.GetResult() != "success" { return fmt.Errorf("%d: unexpected API result (%s): %w", resp.StatusCode, result.GetResult(), result.GetError()) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/infomaniak/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := New(OAuthStaticAccessToken(server.Client(), "token"), server.URL) if err != nil { return nil, err } return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer token")) } func TestClient_CreateDNSRecord(t *testing.T) { client := mockBuilder(). Route("POST /1/domain/666/dns/record", servermock.RawStringResponse(`{"result":"success","data": "123"}`), servermock.CheckRequestJSONBodyFromFixture("create_dns_record-request.json")). Build(t) domain := &DNSDomain{ ID: 666, CustomerName: "test", } record := Record{ Source: "foo", Target: "txtxtxttxt", Type: "TXT", TTL: 60, } recordID, err := client.CreateDNSRecord(t.Context(), domain, record) require.NoError(t, err) assert.Equal(t, "123", recordID) } func TestClient_GetDomainByName(t *testing.T) { client := mockBuilder(). Route("GET /1/product", servermock.ResponseFromFixture("get_domain_name.json"), servermock.CheckQueryParameter().Strict(). WithRegexp("customer_name", `.+\.example\.com`). With("service_name", "domain")). Build(t) domain, err := client.GetDomainByName(t.Context(), "one.two.three.example.com.") require.NoError(t, err) expected := &DNSDomain{ID: 123, CustomerName: "two.three.example.com"} assert.Equal(t, expected, domain) } func TestClient_DeleteDNSRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /1/domain/123/dns/record/456", servermock.RawStringResponse(`{"result":"success"}`)). Build(t) err := client.DeleteDNSRecord(t.Context(), 123, "456") require.NoError(t, err) } ================================================ FILE: providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json ================================================ { "source": "foo", "type": "TXT", "ttl": 60, "target": "txtxtxttxt" } ================================================ FILE: providers/dns/infomaniak/internal/fixtures/get_domain_name.json ================================================ { "result": "success", "data": [ { "id": 123, "customer_name": "two.three.example.com" }, { "id": 456, "customer_name": "three.example.com" } ] } ================================================ FILE: providers/dns/infomaniak/internal/types.go ================================================ package internal import ( "fmt" ) // Record a DNS record. type Record struct { ID string `json:"id,omitempty"` Source string `json:"source,omitempty"` Type string `json:"type,omitempty"` TTL int `json:"ttl,omitempty"` Target string `json:"target,omitempty"` } type DNSDomain struct { ID uint64 `json:"id,omitempty"` CustomerName string `json:"customer_name,omitempty"` } type Response interface { GetResult() string GetError() *APIErrorResponse } type APIResponse[T any] struct { Result string `json:"result"` Data T `json:"data,omitempty"` ErrResponse *APIErrorResponse `json:"error,omitempty"` } func (a APIResponse[T]) GetResult() string { return a.Result } func (a APIResponse[T]) GetError() *APIErrorResponse { return a.ErrResponse } type APIErrorResponse struct { Code string `json:"code"` Description string `json:"description,omitempty"` Context map[string]string `json:"context,omitempty"` Errors []APIErrorResponse `json:"errors,omitempty"` } func (a APIErrorResponse) Error() string { return fmt.Sprintf("code: %s, description: %s", a.Code, a.Description) } ================================================ FILE: providers/dns/internal/active24/internal/client.go ================================================ package internal import ( "bytes" "context" "crypto/hmac" "crypto/sha1" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://rest.%s" // Client the Active24 API client. type Client struct { apiKey string secret string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(baseAPIDomain, apiKey, secret string) (*Client, error) { if apiKey == "" || secret == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(fmt.Sprintf(defaultBaseURL, baseAPIDomain)) return &Client{ apiKey: apiKey, secret: secret, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // GetServices lists of all services. // https://rest.active24.cz/docs/v1.service#services func (c *Client) GetServices(ctx context.Context) ([]Service, error) { endpoint := c.baseURL.JoinPath("v1", "user", "self", "service") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result OldAPIResponse err = c.do(req, &result) if err != nil { return nil, err } return result.Items, err } // GetRecords lists of DNS records. // https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.record_f94908d4e0e48489468498fce87cb90b func (c *Client) GetRecords(ctx context.Context, service string, filter RecordFilter) ([]Record, error) { endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record") encodedFilter, err := json.Marshal(filter) if err != nil { return nil, fmt.Errorf("marshal records filter: %w", err) } query := endpoint.Query() query.Add("filters", string(encodedFilter)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result APIResponse err = c.do(req, &result) if err != nil { return nil, err } return result.Data, err } // CreateRecord creates a new DNS record. // https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.create-record_6773d572235be9a72646bf6c54863573 func (c *Client) CreateRecord(ctx context.Context, service string, record Record) error { endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } // DeleteRecord deletes a DNS record. // https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.delete-record_fc6603c14848e547f8d0b967842f0a2c func (c *Client) DeleteRecord(ctx context.Context, service, recordID string) error { endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { req.Header.Set("Accept-Language", "en_us") err := c.sign(req, time.Now()) if err != nil { return fmt.Errorf("sign request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } // sign creates and sets request signature and date. // https://rest.active24.cz/v2/docs/intro func (c *Client) sign(req *http.Request, now time.Time) error { if req.URL.Path == "" { req.URL.Path += "/" } canonicalRequest := fmt.Sprintf("%s %s %d", req.Method, req.URL.Path, now.Unix()) mac := hmac.New(sha1.New, []byte(c.secret)) _, err := mac.Write([]byte(canonicalRequest)) if err != nil { return err } hashed := mac.Sum(nil) signature := hex.EncodeToString(hashed) req.SetBasicAuth(c.apiKey, signature) req.Header.Set("Date", now.Format(time.RFC3339)) return nil } ================================================ FILE: providers/dns/internal/active24/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("example.com", "user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithRegexp("Authorization", `Basic .+`). WithRegexp("Date", `\d+-\d+-\d+T\d{2}:\d{2}:\d{2}.*`). With("Accept-Language", "en_us")) } func TestClient_GetServices(t *testing.T) { client := mockBuilder(). Route("GET /v1/user/self/service", servermock.ResponseFromFixture("services.json")). Build(t) services, err := client.GetServices(t.Context()) require.NoError(t, err) expected := []Service{ { ID: 1111, ServiceName: ".sk doména", Status: "active", Name: "mydomain.sk", CreateTime: 1374357600, ExpireTime: 1405914526, Price: 12.3, }, { ID: 2222, ServiceName: "The Hosting", Status: "active", Name: "myname_1", CreateTime: 1400145443, ExpireTime: 1431702371, Price: 55.2, }, } assert.Equal(t, expected, services) } func TestClient_GetServices_errors(t *testing.T) { client := mockBuilder(). Route("GET /v1/user/self/service", servermock.ResponseFromFixture("error_v1.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetServices(t.Context()) require.EqualError(t, err, "401: No username or password.") } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /v2/service/aaa/dns/record", servermock.ResponseFromFixture("records.json")). Build(t) filter := RecordFilter{ Name: "example.com", Type: []string{"TXT"}, Content: "txt", } records, err := client.GetRecords(t.Context(), "aaa", filter) require.NoError(t, err) expected := []Record{{ ID: 13, Name: "string", Content: "string", TTL: 120, Priority: 1, Port: 443, Weight: 50, }} assert.Equal(t, expected, records) } func TestClient_GetRecords_errors(t *testing.T) { client := mockBuilder(). Route("GET /v2/service/aaa/dns/record", servermock.ResponseFromFixture("error_403.json"). WithStatusCode(http.StatusForbidden)). Build(t) filter := RecordFilter{ Name: "example.com", Type: []string{"TXT"}, Content: "txt", } _, err := client.GetRecords(t.Context(), "aaa", filter) require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.") } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /v2/service/aaa/dns/record", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.CreateRecord(t.Context(), "aaa", Record{}) require.NoError(t, err) } func TestClient_CreateRecord_errors(t *testing.T) { client := mockBuilder(). Route("POST /v2/service/aaa/dns/record", servermock.ResponseFromFixture("error_403.json"). WithStatusCode(http.StatusForbidden)). Build(t) err := client.CreateRecord(t.Context(), "aaa", Record{}) require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /v2/service/aaa/dns/record/123", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.DeleteRecord(t.Context(), "aaa", "123") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /v2/service/aaa/dns/record/123", servermock.ResponseFromFixture("error_403.json"). WithStatusCode(http.StatusForbidden)). Build(t) err := client.DeleteRecord(t.Context(), "aaa", "123") require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.") } func TestClient_sign(t *testing.T) { client, err := NewClient("example.com", "user", "secret") require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "/v1/user/self/service", nil) require.NoError(t, err) err = client.sign(req, time.Date(2025, 6, 28, 1, 2, 3, 4, time.UTC)) require.NoError(t, err) username, password, ok := req.BasicAuth() require.True(t, ok) assert.Equal(t, "user", username) assert.Equal(t, "743e2257421b260ed561f3e7af4b035414636393", password) } ================================================ FILE: providers/dns/internal/active24/internal/fixtures/error_403.json ================================================ { "type": "/errors/httpException", "status": 403, "title": "This action is unauthorized." } ================================================ FILE: providers/dns/internal/active24/internal/fixtures/error_422.json ================================================ { "type": "/errors/validation", "status": 422, "title": "The given data was invalid.", "violations": [ { "propertyPath": "string", "errors": [ {} ] } ], "data": { "name": "Merlin" } } ================================================ FILE: providers/dns/internal/active24/internal/fixtures/error_v1.json ================================================ { "message": "No username or password.", "code": 401 } ================================================ FILE: providers/dns/internal/active24/internal/fixtures/records.json ================================================ { "currentPage": 0, "rowsPerPage": 0, "totalPages": 0, "totalRecords": 0, "actions": { "additionalProp1": { "additionalProp1": {} }, "additionalProp2": { "additionalProp1": {} }, "additionalProp3": { "additionalProp1": {} } }, "data": [ { "id": 13, "name": "string", "content": "string", "ttl": 120, "priority": 1, "port": 443, "weight": 50 } ] } ================================================ FILE: providers/dns/internal/active24/internal/fixtures/services.json ================================================ { "items": [ { "id": 1111, "serviceName": ".sk doména", "status": "active", "name": "mydomain.sk", "createTime": 1374357600, "expireTime": 1405914526, "price": 12.3, "autoExtend": false }, { "id": 2222, "serviceName": "The Hosting", "status": "active", "name": "myname_1", "createTime": 1400145443, "expireTime": 1431702371, "price": 55.2, "autoExtend": false } ], "pager": { "page": 1, "pagesize": null, "items": 2 } } ================================================ FILE: providers/dns/internal/active24/internal/types.go ================================================ package internal import "fmt" type APIError struct { // v2 error Type string `json:"type,omitempty"` Status int `json:"status,omitempty"` Title string `json:"title,omitempty"` // v1 error Message string `json:"message,omitempty"` Code int `json:"code,omitempty"` } func (a *APIError) Error() string { if a.Message != "" { return fmt.Sprintf("%d: %s", a.Code, a.Message) } return fmt.Sprintf("%d: %s: %s", a.Status, a.Type, a.Title) } type APIResponse struct { Data []Record `json:"data"` } type Record struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` Priority int `json:"priority,omitempty"` Port int `json:"port,omitempty"` Weight int `json:"weight,omitempty"` } type OldAPIResponse struct { Items []Service `json:"items"` } type Service struct { ID int `json:"id,omitempty"` ServiceName string `json:"serviceName,omitempty"` Status string `json:"status,omitempty"` Name string `json:"name,omitempty"` CreateTime int `json:"createTime,omitempty"` ExpireTime int `json:"expireTime,omitempty"` Price float64 `json:"price,omitempty"` AutoExtend bool `json:"autoExtend,omitempty"` } type RecordFilter struct { Name string `json:"name,omitempty"` Type []string `json:"type,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` Note string `json:"note,omitempty"` Priority int `json:"priority,omitempty"` Port int `json:"port,omitempty"` Weight int `json:"weight,omitempty"` Flags int `json:"flags,omitempty"` Tag []string `json:"tag,omitempty"` } ================================================ FILE: providers/dns/internal/active24/provider.go ================================================ // Package active24 implements a DNS provider for solving the DNS-01 challenge using Active24. package active24 import ( "context" "errors" "fmt" "net/http" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/active24/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string Secret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProviderConfig return a DNSProvider instance configured for Active24. func NewDNSProviderConfig(config *Config, baseAPIDomain string) (*DNSProvider, error) { if config == nil { return nil, errors.New("the configuration of the DNS provider is nil") } client, err := internal.NewClient(baseAPIDomain, config.APIKey, config.Secret) if err != nil { return nil, err } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return err } serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("find service ID: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Content: info.Value, TTL: d.config.TTL, } err = d.client.CreateRecord(ctx, strconv.Itoa(serviceID), record) if err != nil { return fmt.Errorf("create record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("could not find zone for domain %q: %w", domain, err) } serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("find service ID: %w", err) } recordID, err := d.findRecordID(ctx, strconv.Itoa(serviceID), info) if err != nil { return fmt.Errorf("find record ID: %w", err) } err = d.client.DeleteRecord(ctx, strconv.Itoa(serviceID), strconv.Itoa(recordID)) if err != nil { return fmt.Errorf("delete record %w", err) } return 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 } func (d *DNSProvider) findServiceID(ctx context.Context, domain string) (int, error) { services, err := d.client.GetServices(ctx) if err != nil { return 0, fmt.Errorf("get services: %w", err) } for _, service := range services { if service.ServiceName != "domain" { continue } if service.Name != domain { continue } return service.ID, nil } return 0, fmt.Errorf("service not found for domain: %s", domain) } func (d *DNSProvider) findRecordID(ctx context.Context, serviceID string, info dns01.ChallengeInfo) (int, error) { // NOTE(ldez): Despite the API documentation, the filter doesn't seem to work. filter := internal.RecordFilter{ Name: dns01.UnFqdn(info.EffectiveFQDN), Type: []string{"TXT"}, Content: info.Value, } records, err := d.client.GetRecords(ctx, serviceID, filter) if err != nil { return 0, fmt.Errorf("get records: %w", err) } for _, record := range records { if record.Type != "TXT" { continue } if record.Name != dns01.UnFqdn(info.EffectiveFQDN) { continue } if record.Content != info.Value { continue } return record.ID, nil } return 0, errors.New("no record found") } ================================================ FILE: providers/dns/internal/active24/provider_test.go ================================================ package active24 import ( "testing" "github.com/stretchr/testify/require" ) func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string secret string expected string }{ { desc: "success", apiKey: "user", secret: "secret", }, { desc: "missing API key", apiKey: "", secret: "secret", expected: "credentials missing", }, { desc: "missing secret", apiKey: "user", secret: "", expected: "credentials missing", }, { desc: "missing credentials", expected: "credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := &Config{} config.APIKey = test.apiKey config.Secret = test.secret p, err := NewDNSProviderConfig(config, "example.com") if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } ================================================ FILE: providers/dns/internal/clientdebug/.gitattributes ================================================ /testdata/** text eol=lf ================================================ FILE: providers/dns/internal/clientdebug/client.go ================================================ package clientdebug import ( "fmt" "io" "net/http" "net/http/httputil" "os" "regexp" "strconv" "strings" "github.com/go-acme/lego/v4/platform/config/env" ) const replacement = "***" type Option func(*DumpTransport) func WithEnvKeys(keys ...string) Option { return func(d *DumpTransport) { for _, key := range keys { v := strings.TrimSpace(env.GetOrFile(key)) if v == "" { continue } d.replacements = append(d.replacements, v, replacement) } } } func WithValues(values ...string) Option { return func(d *DumpTransport) { for _, value := range values { d.replacements = append(d.replacements, value, replacement) } } } func WithHeaders(keys ...string) Option { return func(d *DumpTransport) { d.regexps = append(d.regexps, regexp.MustCompile(fmt.Sprintf(`(?im)^(%s):.+$`, strings.Join(keys, "|")))) } } type DumpTransport struct { rt http.RoundTripper replacements []string replacer *strings.Replacer regexps []*regexp.Regexp writer io.Writer } func NewDumpTransport(rt http.RoundTripper, opts ...Option) *DumpTransport { if rt == nil { rt = http.DefaultTransport } d := &DumpTransport{ rt: rt, writer: os.Stdout, } for _, opt := range opts { opt(d) } d.regexps = append(d.regexps, regexp.MustCompile(`(?im)^(Authorization):.+$`), regexp.MustCompile(`(?im)^(Token|X-Token):.+$`), regexp.MustCompile(`(?im)^(Auth-Token|X-Auth-Token):.+$`), regexp.MustCompile(`(?im)^(Api-Key|X-Api-Key|X-Api-Secret):.+$`), ) if len(d.replacements) > 0 { d.replacer = strings.NewReplacer(d.replacements...) } return d } func (d *DumpTransport) RoundTrip(h *http.Request) (*http.Response, error) { data, _ := httputil.DumpRequestOut(h, true) _, _ = fmt.Fprintln(d.writer, "[HTTP Request]") _, _ = fmt.Fprintln(d.writer, d.redact(data)) resp, err := d.rt.RoundTrip(h) if err != nil { return nil, err } data, _ = httputil.DumpResponse(resp, true) _, _ = fmt.Fprintln(d.writer, "[HTTP Response]") _, _ = fmt.Fprintln(d.writer, d.redact(data)) return resp, err } func (d *DumpTransport) redact(content []byte) string { data := string(content) for _, r := range d.regexps { data = r.ReplaceAllString(data, "$1: "+replacement) } if d.replacer == nil { return data } return d.replacer.Replace(data) } // Wrap wraps an HTTP client Transport with the [DumpTransport]. func Wrap(client *http.Client, opts ...Option) *http.Client { val, found := os.LookupEnv("LEGO_DEBUG_DNS_API_HTTP_CLIENT") if !found { return client } if ok, _ := strconv.ParseBool(val); !ok { return client } client.Transport = NewDumpTransport(client.Transport, opts...) return client } ================================================ FILE: providers/dns/internal/clientdebug/client_test.go ================================================ package clientdebug import ( "bytes" "io" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "text/template" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWrap_redact_env_vars(t *testing.T) { t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true") t.Setenv("MY_VAR_01", "env-aaaa-aaaa") t.Setenv("MY_VAR_02", "query-aaaa-aaaa") t.Setenv("MY_VAR_03", "path-aaaa-aaaa") t.Setenv("MY_VAR_04", "request-body-aaaa-aaaa") t.Setenv("MY_VAR_05", "request-header-aaaa-aaaa") t.Setenv("MY_VAR_06", "response-body-aaaa-aaaa") buf := bytes.NewBufferString("") server, client, req := setupTest(t, buf, WithEnvKeys("MY_VAR_01", "MY_VAR_02", "MY_VAR_03", "MY_VAR_04", "MY_VAR_05", "MY_VAR_06"), ) now := time.Now() resp, err := client.Transport.RoundTrip(req) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) assertDump(t, now, server, buf, "env_vars.txt") } func TestWrap_redact_headers(t *testing.T) { t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true") buf := bytes.NewBufferString("") server, client, req := setupTest(t, buf, WithHeaders("Secret-Request-Header", "Super-Secret-Request-Header", "Secret-Response-Header"), ) now := time.Now() resp, err := client.Transport.RoundTrip(req) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) assertDump(t, now, server, buf, "headers.txt") } func TestWrap_redact_values(t *testing.T) { t.Setenv("LEGO_DEBUG_DNS_API_HTTP_CLIENT", "true") buf := bytes.NewBufferString("") server, client, req := setupTest(t, buf, WithValues("query-aaaa-aaaa", "path-aaaa-aaaa", "request-body-aaaa-aaaa"), ) now := time.Now() resp, err := client.Transport.RoundTrip(req) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) assertDump(t, now, server, buf, "values.txt") } func fakeRequest(t *testing.T, baseURL string) *http.Request { t.Helper() endpoint, err := url.Parse(baseURL) require.NoError(t, err) query := endpoint.Query() query.Set("foo", "query-aaaa-aaaa") endpoint.RawQuery = query.Encode() endpoint = endpoint.JoinPath("path-aaaa-aaaa") body := `{ "foo": "request-body-aaaa-aaaa" } ` req := httptest.NewRequest(http.MethodGet, endpoint.String(), bytes.NewBufferString(body)) req.Header.Set("X-Authorization", "not-redacted") req.Header.Set("Secret-Request-Header", "request-header-aaaa-aaaa") req.Header.Set("Super-Secret-Request-Header", "env-aaaa-aaaa") req.Header.Set("Authorization", "header-aaaa-0000") req.Header.Set("Token", "header-aaaa-0001") req.Header.Set("X-Token", "header-aaaa-0002") req.Header.Set("Auth-Token", "header-aaaa-0003") req.Header.Set("X-Auth-Token", "header-aaaa-0004") req.Header.Set("Api-Key", "header-aaaa-0006") req.Header.Set("X-Api-Key", "header-aaaa-0007") req.Header.Set("X-Api-Secret", "header-aaaa-0008") req.SetBasicAuth("user", "secret") return req } func fakeResponse() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Secret-Response-Header", "response-header-aaaa-aaaa") _, _ = w.Write([]byte(`{ "bar": "response-body-aaaa-aaaa" }`, )) } } func withWriter(w io.Writer) Option { return func(d *DumpTransport) { if w != nil { d.writer = w } } } func setupTest(t *testing.T, buf io.Writer, opts ...Option) (*httptest.Server, *http.Client, *http.Request) { t.Helper() server := httptest.NewServer(fakeResponse()) opts = append(opts, withWriter(buf)) client := Wrap(server.Client(), opts...) req := fakeRequest(t, server.URL) return server, client, req } func assertDump(t *testing.T, now time.Time, server *httptest.Server, actual *bytes.Buffer, filename string) { t.Helper() tmpl, err := template.New(filename).ParseFiles(filepath.Join("testdata", filename)) require.NoError(t, err) expected := bytes.NewBufferString("") location, err := time.LoadLocation("GMT") require.NoError(t, err) baseURL, err := url.Parse(server.URL) require.NoError(t, err) err = tmpl.Execute(expected, map[string]string{ "Host": baseURL.Host, "Date": now.In(location).Format(time.RFC1123), }) require.NoError(t, err) assert.Equal(t, expected.String(), strings.ReplaceAll(actual.String(), "\r", "")) } ================================================ FILE: providers/dns/internal/clientdebug/testdata/env_vars.txt ================================================ [HTTP Request] GET /***?foo=*** HTTP/1.1 Host: {{ .Host }} User-Agent: Go-http-client/1.1 Content-Length: 37 Api-Key: *** Auth-Token: *** Authorization: *** Secret-Request-Header: *** Super-Secret-Request-Header: *** Token: *** X-Api-Key: *** X-Api-Secret: *** X-Auth-Token: *** X-Authorization: not-redacted X-Token: *** Accept-Encoding: gzip { "foo": "***" } [HTTP Response] HTTP/1.1 200 OK Content-Length: 37 Content-Type: text/plain; charset=utf-8 Date: {{ .Date }} Secret-Response-Header: response-header-aaaa-aaaa { "bar": "***" } ================================================ FILE: providers/dns/internal/clientdebug/testdata/headers.txt ================================================ [HTTP Request] GET /path-aaaa-aaaa?foo=query-aaaa-aaaa HTTP/1.1 Host: {{ .Host }} User-Agent: Go-http-client/1.1 Content-Length: 37 Api-Key: *** Auth-Token: *** Authorization: *** Secret-Request-Header: *** Super-Secret-Request-Header: *** Token: *** X-Api-Key: *** X-Api-Secret: *** X-Auth-Token: *** X-Authorization: not-redacted X-Token: *** Accept-Encoding: gzip { "foo": "request-body-aaaa-aaaa" } [HTTP Response] HTTP/1.1 200 OK Content-Length: 37 Content-Type: text/plain; charset=utf-8 Date: {{ .Date }} Secret-Response-Header: *** { "bar": "response-body-aaaa-aaaa" } ================================================ FILE: providers/dns/internal/clientdebug/testdata/values.txt ================================================ [HTTP Request] GET /***?foo=*** HTTP/1.1 Host: {{ .Host }} User-Agent: Go-http-client/1.1 Content-Length: 37 Api-Key: *** Auth-Token: *** Authorization: *** Secret-Request-Header: request-header-aaaa-aaaa Super-Secret-Request-Header: env-aaaa-aaaa Token: *** X-Api-Key: *** X-Api-Secret: *** X-Auth-Token: *** X-Authorization: not-redacted X-Token: *** Accept-Encoding: gzip { "foo": "***" } [HTTP Response] HTTP/1.1 200 OK Content-Length: 37 Content-Type: text/plain; charset=utf-8 Date: {{ .Date }} Secret-Response-Header: response-header-aaaa-aaaa { "bar": "response-body-aaaa-aaaa" } ================================================ FILE: providers/dns/internal/errutils/client.go ================================================ package errutils import ( "bytes" "fmt" "io" "net/http" "os" "strconv" ) const legoDebugClientVerboseError = "LEGO_DEBUG_CLIENT_VERBOSE_ERROR" // HTTPDoError uses with `(http.Client).Do` error. type HTTPDoError struct { req *http.Request err error } // NewHTTPDoError creates a new HTTPDoError. func NewHTTPDoError(req *http.Request, err error) *HTTPDoError { return &HTTPDoError{req: req, err: err} } func (h HTTPDoError) Error() string { msg := "unable to communicate with the API server:" if ok, _ := strconv.ParseBool(os.Getenv(legoDebugClientVerboseError)); ok { msg += fmt.Sprintf(" [request: %s %s]", h.req.Method, h.req.URL) } if h.err == nil { return msg } return msg + fmt.Sprintf(" error: %v", h.err) } func (h HTTPDoError) Unwrap() error { return h.err } // ReadResponseError use with `io.ReadAll` when reading response body. type ReadResponseError struct { req *http.Request StatusCode int err error } // NewReadResponseError creates a new ReadResponseError. func NewReadResponseError(req *http.Request, statusCode int, err error) *ReadResponseError { return &ReadResponseError{req: req, StatusCode: statusCode, err: err} } func (r ReadResponseError) Error() string { msg := "unable to read response body:" if ok, _ := strconv.ParseBool(os.Getenv(legoDebugClientVerboseError)); ok { msg += fmt.Sprintf(" [request: %s %s]", r.req.Method, r.req.URL) } msg += fmt.Sprintf(" [status code: %d]", r.StatusCode) if r.err == nil { return msg } return msg + fmt.Sprintf(" error: %v", r.err) } func (r ReadResponseError) Unwrap() error { return r.err } // UnmarshalError uses with `json.Unmarshal` or `xml.Unmarshal` when reading response body. type UnmarshalError struct { req *http.Request StatusCode int Body []byte err error } // NewUnmarshalError creates a new UnmarshalError. func NewUnmarshalError(req *http.Request, statusCode int, body []byte, err error) *UnmarshalError { return &UnmarshalError{req: req, StatusCode: statusCode, Body: bytes.TrimSpace(body), err: err} } func (u UnmarshalError) Error() string { msg := "unable to unmarshal response:" if ok, _ := strconv.ParseBool(os.Getenv(legoDebugClientVerboseError)); ok { msg += fmt.Sprintf(" [request: %s %s]", u.req.Method, u.req.URL) } msg += fmt.Sprintf(" [status code: %d] body: %s", u.StatusCode, string(u.Body)) if u.err == nil { return msg } return msg + fmt.Sprintf(" error: %v", u.err) } func (u UnmarshalError) Unwrap() error { return u.err } // UnexpectedStatusCodeError use when the status of the response is unexpected but there is no API error type. type UnexpectedStatusCodeError struct { req *http.Request StatusCode int Body []byte } // NewUnexpectedStatusCodeError creates a new UnexpectedStatusCodeError. func NewUnexpectedStatusCodeError(req *http.Request, statusCode int, body []byte) *UnexpectedStatusCodeError { return &UnexpectedStatusCodeError{req: req, StatusCode: statusCode, Body: bytes.TrimSpace(body)} } func NewUnexpectedResponseStatusCodeError(req *http.Request, resp *http.Response) *UnexpectedStatusCodeError { raw, _ := io.ReadAll(resp.Body) return &UnexpectedStatusCodeError{req: req, StatusCode: resp.StatusCode, Body: bytes.TrimSpace(raw)} } func (u UnexpectedStatusCodeError) Error() string { msg := "unexpected status code:" if ok, _ := strconv.ParseBool(os.Getenv(legoDebugClientVerboseError)); ok { msg += fmt.Sprintf(" [request: %s %s]", u.req.Method, u.req.URL) } return msg + fmt.Sprintf(" [status code: %d] body: %s", u.StatusCode, string(u.Body)) } ================================================ FILE: providers/dns/internal/gcore/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.gcore.com/dns" const ( authorizationHeader = "Authorization" tokenTypeHeader = "APIKey" ) const txtRecordType = "TXT" // Client for DNS API. type Client struct { token string BaseURL *url.URL HTTPClient *http.Client } // NewClient constructor of Client. func NewClient(token string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // GetZone gets zone information. // https://api.gcore.com/docs/dns#tag/zones/operation/Zone func (c *Client) GetZone(ctx context.Context, name string) (Zone, error) { endpoint := c.BaseURL.JoinPath("v2", "zones", name) zone := Zone{} err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &zone) if err != nil { return Zone{}, fmt.Errorf("get zone %s: %w", name, err) } return zone, nil } // GetRRSet gets RRSet item. // https://api.gcore.com/docs/dns#tag/rrsets/operation/RRSet func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error) { endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType) var result RRSet err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &result) if err != nil { return RRSet{}, fmt.Errorf("get txt records %s -> %s: %w", zone, name, err) } return result, nil } // DeleteRRSet removes RRSet record. // https://api.gcore.com/docs/dns#tag/rrsets/operation/DeleteRRSet func (c *Client) DeleteRRSet(ctx context.Context, zone, name string) error { endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType) err := c.doRequest(ctx, http.MethodDelete, endpoint, nil, nil) if err != nil { // Support DELETE idempotence https://developer.mozilla.org/en-US/docs/Glossary/Idempotent statusErr := new(APIError) if errors.As(err, statusErr) && statusErr.StatusCode == http.StatusNotFound { return nil } return fmt.Errorf("delete record request: %w", err) } return nil } // AddRRSet adds TXT record (create or update). func (c *Client) AddRRSet(ctx context.Context, zone, recordName, value string, ttl int) error { record := RRSet{TTL: ttl, Records: []Records{{Content: []string{value}}}} txt, err := c.GetRRSet(ctx, zone, recordName) if err == nil && len(txt.Records) > 0 { record.Records = append(record.Records, txt.Records...) return c.updateRRSet(ctx, zone, recordName, record) } return c.createRRSet(ctx, zone, recordName, record) } // https://api.gcore.com/docs/dns#tag/rrsets/operation/CreateRRSet func (c *Client) createRRSet(ctx context.Context, zone, name string, record RRSet) error { endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType) return c.doRequest(ctx, http.MethodPost, endpoint, record, nil) } // https://api.gcore.com/docs/dns#tag/rrsets/operation/UpdateRRSet func (c *Client) updateRRSet(ctx context.Context, zone, name string, record RRSet) error { endpoint := c.BaseURL.JoinPath("v2", "zones", zone, name, txtRecordType) return c.doRequest(ctx, http.MethodPut, endpoint, record, nil) } func (c *Client) doRequest(ctx context.Context, method string, endpoint *url.URL, bodyParams, result any) error { req, err := newJSONRequest(ctx, method, endpoint, bodyParams) if err != nil { return fmt.Errorf("new request: %w", err) } req.Header.Set(authorizationHeader, fmt.Sprintf("%s %s", tokenTypeHeader, c.token)) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := APIError{StatusCode: resp.StatusCode} err := json.Unmarshal(raw, &errAPI) if err != nil { errAPI.Message = string(raw) } return errAPI } ================================================ FILE: providers/dns/internal/gcore/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testToken = "test" testRecordContent = "acme" testTTL = 10 ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(testToken) client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders()) } func TestClient_GetZone(t *testing.T) { expected := Zone{Name: "example.com"} client := mockBuilder(). Route("GET /v2/zones/example.com", servermock.JSONEncode(expected)). Build(t) zone, err := client.GetZone(t.Context(), "example.com") require.NoError(t, err) assert.Equal(t, expected, zone) } func TestClient_GetZone_error(t *testing.T) { client := mockBuilder(). Route("GET /v2/zones/example.com", servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). Build(t) _, err := client.GetZone(t.Context(), "example.com") require.EqualError(t, err, "get zone example.com: 500: oops") } func TestClient_GetRRSet(t *testing.T) { expected := RRSet{ TTL: testTTL, Records: []Records{ {Content: []string{testRecordContent}}, }, } client := mockBuilder(). Route("GET /v2/zones/example.com/foo.example.com/TXT", servermock.JSONEncode(expected)). Build(t) rrSet, err := client.GetRRSet(t.Context(), "example.com", "foo.example.com") require.NoError(t, err) assert.Equal(t, expected, rrSet) } func TestClient_GetRRSet_error(t *testing.T) { client := mockBuilder(). Route("GET /v2/zones/example.com/foo.example.com/TXT", servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). Build(t) _, err := client.GetRRSet(t.Context(), "example.com", "foo.example.com") require.EqualError(t, err, "get txt records example.com -> foo.example.com: 500: oops") } func TestClient_DeleteRRSet(t *testing.T) { client := mockBuilder(). Route("DELETE /v2/zones/test.example.com/my.test.example.com/TXT", nil). Build(t) err := client.DeleteRRSet(t.Context(), "test.example.com", "my.test.example.com.") require.NoError(t, err) } func TestClient_DeleteRRSet_error(t *testing.T) { client := mockBuilder(). Route("DELETE /v2/zones/test.example.com/my.test.example.com/TXT", servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). Build(t) err := client.DeleteRRSet(t.Context(), "test.example.com", "my.test.example.com.") require.NoError(t, err) } func TestClient_AddRRSet_add(t *testing.T) { client := mockBuilder(). // GetRRSet Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", servermock.JSONEncode(APIError{Message: "not found"}).WithStatusCode(http.StatusBadRequest)). // createRRSet Route("POST /v2/zones/test.example.com/my.test.example.com/TXT", servermock.JSONEncode([]Records{{Content: []string{testRecordContent}}}), servermock.CheckRequestJSONBody(`{"ttl":10,"resource_records":[{"content":["acme"]}]}`)). Build(t) err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) require.NoError(t, err) } func TestClient_AddRRSet_add_error(t *testing.T) { client := mockBuilder(). // GetRRSet Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", servermock.JSONEncode(APIError{Message: "not found"}).WithStatusCode(http.StatusBadRequest)). // createRRSet Route("POST /v2/zones/test.example.com/my.test.example.com/TXT", servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusBadRequest)). Build(t) err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) require.EqualError(t, err, "400: oops") } func TestClient_AddRRSet_update(t *testing.T) { client := mockBuilder(). // GetRRSet Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", servermock.JSONEncode(RRSet{ TTL: testTTL, Records: []Records{{Content: []string{"foo"}}}, })). // updateRRSet Route("PUT /v2/zones/test.example.com/my.test.example.com/TXT", nil, servermock.CheckRequestJSONBody(`{"ttl":10,"resource_records":[{"content":["acme"]},{"content":["foo"]}]}`)). Build(t) err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) require.NoError(t, err) } func TestClient_AddRRSet_update_error(t *testing.T) { client := mockBuilder(). // GetRRSet Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", servermock.JSONEncode(RRSet{ TTL: testTTL, Records: []Records{{Content: []string{"foo"}}}, })). // updateRRSet Route("PUT /v2/zones/test.example.com/my.test.example.com/TXT", servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusBadRequest)). Build(t) err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) require.EqualError(t, err, "400: oops") } ================================================ FILE: providers/dns/internal/gcore/internal/types.go ================================================ package internal import "fmt" type Zone struct { Name string `json:"name"` } type RRSet struct { TTL int `json:"ttl"` Records []Records `json:"resource_records"` } type Records struct { Content []string `json:"content"` } type APIError struct { StatusCode int `json:"-"` Message string `json:"error,omitempty"` } func (a APIError) Error() string { return fmt.Sprintf("%d: %s", a.StatusCode, a.Message) } ================================================ FILE: providers/dns/internal/gcore/provider.go ================================================ // Package gcore implements a DNS provider for solving the DNS-01 challenge using G-Core. package gcore import ( "context" "errors" "fmt" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/gcore/internal" ) const ( DefaultPropagationTimeout = 360 * time.Second DefaultPollingInterval = 20 * time.Second ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config for DNSProvider. type Config struct { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // DNSProvider an implementation of challenge.Provider contract. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProviderConfig return a DNSProvider instance configured for G-Core DNS API. func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { if config == nil { return nil, errors.New("the configuration of the DNS provider is nil") } if config.APIToken == "" { return nil, errors.New("incomplete credentials provided") } client := internal.NewClient(config.APIToken) if baseURL != "" { client.BaseURL, _ = url.Parse(baseURL) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.guessZone(ctx, info.EffectiveFQDN) if err != nil { return err } err = d.client.AddRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL) if err != nil { return fmt.Errorf("add txt record: %w", err) } return nil } // CleanUp removes the record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.guessZone(ctx, info.EffectiveFQDN) if err != nil { return err } err = d.client.DeleteRRSet(ctx, zone, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("remove txt record: %w", err) } return 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 } func (d *DNSProvider) guessZone(ctx context.Context, fqdn string) (string, error) { var lastErr error for zone := range dns01.UnFqdnDomainsSeq(fqdn) { dnsZone, err := d.client.GetZone(ctx, zone) if err != nil { lastErr = err continue } return dnsZone.Name, nil } return "", fmt.Errorf("zone %q not found: %w", fqdn, lastErr) } ================================================ FILE: providers/dns/internal/gcore/provider_test.go ================================================ package gcore import ( "testing" "github.com/stretchr/testify/require" ) func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "A", }, { desc: "missing credentials", expected: "incomplete credentials provided", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := &Config{} config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config, "") if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } ================================================ FILE: providers/dns/internal/hostingde/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://secure.hosting.de/api/dns/v1/json" // Client the API client for Hosting.de. type Client struct { apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates new Client. func NewClient(apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetZone gets a zone. func (c *Client) GetZone(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneConfig, error) { operation := func() (*ZoneConfig, error) { response, err := c.ListZoneConfigs(ctx, req) if err != nil { return nil, backoff.Permanent(err) } if response.Data[0].Status != "active" { return nil, fmt.Errorf("unexpected status: %q", response.Data[0].Status) } return &response.Data[0], nil } bo := backoff.NewExponentialBackOff() bo.InitialInterval = 3 * time.Second bo.MaxInterval = 10 * bo.InitialInterval // retry in case the zone was edited recently and is not yet active return backoff.Retry(ctx, operation, backoff.WithBackOff(bo), backoff.WithMaxElapsedTime(100*bo.InitialInterval)) } // ListZoneConfigs lists zone configuration. // https://www.hosting.de/api/?json#list-zoneconfigs func (c *Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneResponse, error) { endpoint := c.BaseURL.JoinPath("zoneConfigsFind") req.AuthToken = c.apiKey response := &BaseResponse[*ZoneResponse]{} rawResp, err := c.post(ctx, endpoint, req, response) if err != nil { return nil, err } if response.Status != "success" && response.Status != "pending" { return nil, fmt.Errorf("unexpected status: %q, %s", response.Status, string(rawResp)) } if response.Response == nil || len(response.Response.Data) == 0 { return nil, fmt.Errorf("no data, status: %q, %s", response.Status, string(rawResp)) } return response.Response, nil } // UpdateZone updates a zone. // https://www.hosting.de/api/?json#updating-zones func (c *Client) UpdateZone(ctx context.Context, req ZoneUpdateRequest) (*Zone, error) { endpoint := c.BaseURL.JoinPath("zoneUpdate") req.AuthToken = c.apiKey // but we'll need the ID later to delete the record response := &BaseResponse[*Zone]{} rawResp, err := c.post(ctx, endpoint, req, response) if err != nil { return nil, err } if response.Status != "success" && response.Status != "pending" { return nil, fmt.Errorf("unexpected status: %q, %s", response.Status, string(rawResp)) } return response.Response, nil } func (c *Client) post(ctx context.Context, endpoint *url.URL, request, result any) ([]byte, error) { body, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return raw, nil } ================================================ FILE: providers/dns/internal/hostingde/internal/client_test.go ================================================ package internal import ( "encoding/json" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil } func TestClient_ListZoneConfigs(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /zoneConfigsFind", servermock.ResponseFromFixture("zoneConfigsFind.json"), servermock.CheckRequestJSONBodyFromFixture("zoneConfigsFind-request.json")). Build(t) zonesFind := ZoneConfigsFindRequest{ Filter: Filter{Field: "zoneName", Value: "example.com"}, Limit: 1, Page: 1, } zoneResponse, err := client.ListZoneConfigs(t.Context(), zonesFind) require.NoError(t, err) expected := &ZoneResponse{ Limit: 10, Page: 1, TotalEntries: 15, TotalPages: 2, Type: "FindZoneConfigsResult", Data: []ZoneConfig{{ ID: "123", AccountID: "456", Status: "s", Name: "n", NameUnicode: "u", MasterIP: "m", Type: "t", EMailAddress: "e", ZoneTransferWhitelist: []string{"a", "b"}, LastChangeDate: "l", DNSServerGroupID: "g", DNSSecMode: "m", SOAValues: &SOAValues{ Refresh: 1, Retry: 2, Expire: 3, TTL: 4, NegativeTTL: 5, }, TemplateValues: json.RawMessage(nil), }}, } assert.Equal(t, expected, zoneResponse) } func TestClient_ListZoneConfigs_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /zoneConfigsFind", servermock.ResponseFromFixture("zoneConfigsFind_error.json")). Build(t) zonesFind := ZoneConfigsFindRequest{ Filter: Filter{Field: "zoneName", Value: "example.com"}, Limit: 1, Page: 1, } _, err := client.ListZoneConfigs(t.Context(), zonesFind) require.Error(t, err) } func TestClient_UpdateZone(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /zoneUpdate", servermock.ResponseFromFixture("zoneUpdate.json"), servermock.CheckRequestJSONBodyFromFixture("zoneUpdate-request.json")). Build(t) request := ZoneUpdateRequest{ ZoneConfig: ZoneConfig{ ID: "123", AccountID: "456", Status: "s", Name: "n", NameUnicode: "u", MasterIP: "m", Type: "t", EMailAddress: "e", ZoneTransferWhitelist: []string{"a", "b"}, LastChangeDate: "l", DNSServerGroupID: "g", DNSSecMode: "m", SOAValues: &SOAValues{ Refresh: 1, Retry: 2, Expire: 3, TTL: 4, NegativeTTL: 5, }, }, RecordsToDelete: []DNSRecord{{ Type: "TXT", Name: "_acme-challenge.example.com", Content: `"txt"`, }}, } response, err := client.UpdateZone(t.Context(), request) require.NoError(t, err) expected := &Zone{ Records: []DNSRecord{{ ID: "123", ZoneID: "456", RecordTemplateID: "789", Name: "n", Type: "TXT", Content: "txt", TTL: 120, Priority: 5, LastChangeDate: "d", }}, ZoneConfig: ZoneConfig{ ID: "123", AccountID: "456", Status: "s", Name: "n", NameUnicode: "u", MasterIP: "m", Type: "t", EMailAddress: "e", ZoneTransferWhitelist: []string{"a", "b"}, LastChangeDate: "l", DNSServerGroupID: "g", DNSSecMode: "m", SOAValues: &SOAValues{ Refresh: 1, Retry: 2, Expire: 3, TTL: 4, NegativeTTL: 5, }, }, } assert.Equal(t, expected, response) } func TestClient_UpdateZone_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /zoneUpdate", servermock.ResponseFromFixture("zoneUpdate_error.json")). Build(t) request := ZoneUpdateRequest{ ZoneConfig: ZoneConfig{ ID: "123", AccountID: "456", Status: "s", Name: "n", NameUnicode: "u", MasterIP: "m", Type: "t", EMailAddress: "e", ZoneTransferWhitelist: []string{"a", "b"}, LastChangeDate: "l", DNSServerGroupID: "g", DNSSecMode: "m", SOAValues: &SOAValues{ Refresh: 1, Retry: 2, Expire: 3, TTL: 4, NegativeTTL: 5, }, }, RecordsToDelete: []DNSRecord{{ Type: "TXT", Name: "_acme-challenge.example.com", Content: `"txt"`, }}, } _, err := client.UpdateZone(t.Context(), request) require.Error(t, err) } ================================================ FILE: providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind-request.json ================================================ { "authToken": "secret", "filter": { "field": "zoneName", "value": "example.com" }, "limit": 1, "page": 1 } ================================================ FILE: providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind.json ================================================ { "metadata": { "clientTransactionId": "1", "serverTransactionId": "2" }, "warnings": [ "aaa", "bbb" ], "status": "success", "response": { "limit": 10, "page": 1, "totalEntries": 15, "totalPages": 2, "type": "FindZoneConfigsResult", "data": [ { "id": "123", "accountId": "456", "status": "s", "name": "n", "nameUnicode": "u", "masterIp": "m", "type": "t", "emailAddress": "e", "zoneTransferWhitelist": [ "a", "b" ], "lastChangeDate": "l", "dnsServerGroupId": "g", "dnsSecMode": "m", "soaValues": { "refresh": 1, "retry": 2, "expire": 3, "ttl": 4, "negativeTtl": 5 } } ] } } ================================================ FILE: providers/dns/internal/hostingde/internal/fixtures/zoneConfigsFind_error.json ================================================ { "errors": [ { "code": 123, "contextObject": "o", "contextPath": "p", "details": [ "a", "b" ], "text": "t", "value": "v" } ], "metadata": { "clientTransactionId": "1", "serverTransactionId": "2" }, "warnings": [ "aaa", "bbb" ], "status": "error", "response": { "limit": 10, "page": 1, "totalEntries": 15, "totalPages": 2, "type": "FindZoneConfigsResult", "data": [ { "id": "123", "accountId": "456", "status": "s", "name": "n", "nameUnicode": "u", "masterIp": "m", "type": "t", "emailAddress": "e", "zoneTransferWhitelist": [ "a", "b" ], "lastChangeDate": "l", "dnsServerGroupId": "g", "dnsSecMode": "m", "soaValues": { "refresh": 1, "retry": 2, "expire": 3, "ttl": 4, "negativeTtl": 5 } } ] } } ================================================ FILE: providers/dns/internal/hostingde/internal/fixtures/zoneUpdate-request.json ================================================ { "authToken": "secret", "zoneConfig": { "id": "123", "accountId": "456", "status": "s", "name": "n", "nameUnicode": "u", "masterIp": "m", "type": "t", "emailAddress": "e", "zoneTransferWhitelist": [ "a", "b" ], "lastChangeDate": "l", "dnsServerGroupId": "g", "dnsSecMode": "m", "soaValues": { "refresh": 1, "retry": 2, "expire": 3, "ttl": 4, "negativeTtl": 5 } }, "recordsToAdd": null, "recordsToDelete": [ { "name": "_acme-challenge.example.com", "type": "TXT", "content": "\"txt\"" } ] } ================================================ FILE: providers/dns/internal/hostingde/internal/fixtures/zoneUpdate.json ================================================ { "metadata": { "clientTransactionId": "", "serverTransactionId": "" }, "warnings": [ "aaa", "bbb" ], "status": "success", "response": { "records": [ { "id": "123", "zoneId": "456", "recordTemplateId": "789", "name": "n", "type": "TXT", "content": "txt", "ttl": 120, "priority": 5, "lastChangeDate": "d" } ], "zoneConfig": { "id": "123", "accountId": "456", "status": "s", "name": "n", "nameUnicode": "u", "masterIp": "m", "type": "t", "emailAddress": "e", "zoneTransferWhitelist": [ "a", "b" ], "lastChangeDate": "l", "dnsServerGroupId": "g", "dnsSecMode": "m", "soaValues": { "refresh": 1, "retry": 2, "expire": 3, "ttl": 4, "negativeTtl": 5 } } } } ================================================ FILE: providers/dns/internal/hostingde/internal/fixtures/zoneUpdate_error.json ================================================ { "errors": [ { "code": 123, "contextObject": "o", "contextPath": "p", "details": [ "a", "b" ], "text": "t", "value": "v" } ], "metadata": { "clientTransactionId": "", "serverTransactionId": "" }, "warnings": [ "aaa", "bbb" ], "status": "error", "response": { "records": [ { "id": "123", "zoneId": "456", "recordTemplateId": "789", "name": "n", "type": "TXT", "content": "txt", "ttl": 120, "priority": 5, "lastChangeDate": "d" } ], "zoneConfig": { "id": "123", "accountId": "456", "status": "s", "name": "n", "nameUnicode": "u", "masterIp": "m", "type": "t", "emailAddress": "e", "zoneTransferWhitelist": [ "a", "b" ], "lastChangeDate": "l", "dnsServerGroupId": "g", "dnsSecMode": "m", "soaValues": { "refresh": 1, "retry": 2, "expire": 3, "ttl": 4, "negativeTtl": 5 } } } } ================================================ FILE: providers/dns/internal/hostingde/internal/types.go ================================================ package internal import "encoding/json" // APIError represents an error in an API response. // https://www.hosting.de/api/?json#warnings-and-errors type APIError struct { Code int `json:"code"` ContextObject string `json:"contextObject"` ContextPath string `json:"contextPath"` Details []string `json:"details"` Text string `json:"text"` Value string `json:"value"` } // Filter is used to filter FindRequests to the API. // https://www.hosting.de/api/?json#filter-object type Filter struct { Field string `json:"field"` Value string `json:"value"` } // Sort is used to sort FindRequests from the API. // https://www.hosting.de/api/?json#filtering-and-sorting type Sort struct { Field string `json:"zoneName"` Order string `json:"order"` } // Metadata represents the metadata in an API response. // https://www.hosting.de/api/?json#metadata-object type Metadata struct { ClientTransactionID string `json:"clientTransactionId"` ServerTransactionID string `json:"serverTransactionId"` } // ZoneConfig The ZoneConfig object defines a zone. // https://www.hosting.de/api/?json#the-zoneconfig-object type ZoneConfig struct { ID string `json:"id"` AccountID string `json:"accountId"` Status string `json:"status"` Name string `json:"name"` NameUnicode string `json:"nameUnicode"` MasterIP string `json:"masterIp"` Type string `json:"type"` EMailAddress string `json:"emailAddress"` ZoneTransferWhitelist []string `json:"zoneTransferWhitelist"` LastChangeDate string `json:"lastChangeDate"` DNSServerGroupID string `json:"dnsServerGroupId"` DNSSecMode string `json:"dnsSecMode"` SOAValues *SOAValues `json:"soaValues,omitempty"` TemplateValues json.RawMessage `json:"templateValues,omitempty"` } // SOAValues The SOA values object contains the time (seconds) used in a zone’s SOA record. // https://www.hosting.de/api/?json#the-soa-values-object type SOAValues struct { Refresh int `json:"refresh"` Retry int `json:"retry"` Expire int `json:"expire"` TTL int `json:"ttl"` NegativeTTL int `json:"negativeTtl"` } // DNSRecord The DNS Record object is part of a zone. It is used to manage DNS resource records. // https://www.hosting.de/api/?json#the-record-object type DNSRecord struct { ID string `json:"id,omitempty"` ZoneID string `json:"zoneId,omitempty"` RecordTemplateID string `json:"recordTemplateId,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` Priority int `json:"priority,omitempty"` LastChangeDate string `json:"lastChangeDate,omitempty"` } // Zone The Zone Object. // https://www.hosting.de/api/?json#the-zone-object type Zone struct { Records []DNSRecord `json:"records"` ZoneConfig ZoneConfig `json:"zoneConfig"` } // ZoneUpdateRequest represents a API ZoneUpdate request. // https://www.hosting.de/api/?json#updating-zones type ZoneUpdateRequest struct { BaseRequest ZoneConfig `json:"zoneConfig"` RecordsToAdd []DNSRecord `json:"recordsToAdd"` RecordsToDelete []DNSRecord `json:"recordsToDelete"` } // ZoneConfigsFindRequest represents a API ZonesFind request. // https://www.hosting.de/api/?json#list-zoneconfigs type ZoneConfigsFindRequest struct { BaseRequest Filter Filter `json:"filter"` Limit int `json:"limit"` Page int `json:"page"` Sort *Sort `json:"sort,omitempty"` } type ZoneResponse struct { Limit int `json:"limit"` Page int `json:"page"` TotalEntries int `json:"totalEntries"` TotalPages int `json:"totalPages"` Type string `json:"type"` Data []ZoneConfig `json:"data"` } // BaseResponse Common response struct. // base: https://www.hosting.de/api/?json#responses // ZoneConfigsFind: https://www.hosting.de/api/?json#list-zoneconfigs // ZoneUpdate: https://www.hosting.de/api/?json#updating-zones type BaseResponse[T any] struct { Errors []APIError `json:"errors"` Metadata Metadata `json:"metadata"` Warnings []string `json:"warnings"` Status string `json:"status"` Response T `json:"response"` } // BaseRequest Common request struct. type BaseRequest struct { AuthToken string `json:"authToken"` } ================================================ FILE: providers/dns/internal/hostingde/provider.go ================================================ // Package hostingde implements a DNS provider for solving the DNS-01 challenge using hosting.de. package hostingde import ( "context" "errors" "fmt" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/hostingde/internal" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string ZoneName string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProviderConfig return a DNSProvider instance configured for hosting.de. func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { if config == nil { return nil, errors.New("the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("API key missing") } client := internal.NewClient(config.APIKey) if baseURL != "" { client.BaseURL, _ = url.Parse(baseURL) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("could not find zone for domain %q: %w", domain, err) } ctx := context.Background() // get the ZoneConfig for that domain zonesFind := internal.ZoneConfigsFindRequest{ Filter: internal.Filter{Field: "zoneName", Value: zoneName}, Limit: 1, Page: 1, } zoneConfig, err := d.client.GetZone(ctx, zonesFind) if err != nil { return err } zoneConfig.Name = zoneName rec := []internal.DNSRecord{{ Type: "TXT", Name: dns01.UnFqdn(info.EffectiveFQDN), Content: info.Value, TTL: d.config.TTL, }} req := internal.ZoneUpdateRequest{ ZoneConfig: *zoneConfig, RecordsToAdd: rec, } response, err := d.client.UpdateZone(ctx, req) if err != nil { return err } for _, record := range response.Records { if record.Name == dns01.UnFqdn(info.EffectiveFQDN) && record.Content == fmt.Sprintf(`%q`, info.Value) { d.recordIDsMu.Lock() d.recordIDs[info.EffectiveFQDN] = record.ID d.recordIDsMu.Unlock() } } if d.recordIDs[info.EffectiveFQDN] == "" { return fmt.Errorf("error getting ID of just created record, for domain %s", domain) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("could not find zone for domain %q: %w", domain, err) } ctx := context.Background() // get the ZoneConfig for that domain zonesFind := internal.ZoneConfigsFindRequest{ Filter: internal.Filter{Field: "zoneName", Value: zoneName}, Limit: 1, Page: 1, } zoneConfig, err := d.client.GetZone(ctx, zonesFind) if err != nil { return err } zoneConfig.Name = zoneName rec := []internal.DNSRecord{{ Type: "TXT", Name: dns01.UnFqdn(info.EffectiveFQDN), Content: `"` + info.Value + `"`, }} req := internal.ZoneUpdateRequest{ ZoneConfig: *zoneConfig, RecordsToDelete: rec, } _, err = d.client.UpdateZone(ctx, req) if err != nil { return err } // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, info.EffectiveFQDN) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) getZoneName(fqdn string) (string, error) { if d.config.ZoneName != "" { return d.config.ZoneName, nil } zoneName, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) } if zoneName == "" { return "", errors.New("empty zone name") } return dns01.UnFqdn(zoneName), nil } ================================================ FILE: providers/dns/internal/hostingde/provider_test.go ================================================ package hostingde import ( "testing" "github.com/stretchr/testify/require" ) func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string zoneName string expected string }{ { desc: "success", apiKey: "123", zoneName: "example.org", }, { desc: "missing credentials", expected: "API key missing", }, { desc: "missing api key", zoneName: "456", expected: "API key missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := &Config{} config.APIKey = test.apiKey config.ZoneName = test.zoneName p, err := NewDNSProviderConfig(config, "") if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } ================================================ FILE: providers/dns/internal/ionos/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://api.hosting.ionos.com/dns" // APIKeyHeader API key header. const APIKeyHeader = "X-Api-Key" // Client Ionos API client. type Client struct { apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey string) (*Client, error) { baseURL, err := url.Parse(defaultBaseURL) if err != nil { return nil, err } return &Client{ apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, }, nil } // ListZones gets all zones. func (c *Client) ListZones(ctx context.Context) ([]Zone, error) { endpoint := c.BaseURL.JoinPath("v1", "zones") req, err := makeJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } var zones []Zone err = c.do(req, &zones) if err != nil { return nil, fmt.Errorf("failed to call API: %w", err) } return zones, nil } // ReplaceRecords replaces some records of a zones. func (c *Client) ReplaceRecords(ctx context.Context, zoneID string, records []Record) error { endpoint := c.BaseURL.JoinPath("v1", "zones", zoneID) req, err := makeJSONRequest(ctx, http.MethodPatch, endpoint, records) if err != nil { return fmt.Errorf("failed to create request: %w", err) } err = c.do(req, nil) if err != nil { return fmt.Errorf("failed to call API: %w", err) } return nil } // GetRecords gets the records of a zones. func (c *Client) GetRecords(ctx context.Context, zoneID string, filter *RecordsFilter) ([]Record, error) { endpoint := c.BaseURL.JoinPath("v1", "zones", zoneID) req, err := makeJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } if filter != nil { v, errQ := querystring.Values(filter) if errQ != nil { return nil, errQ } req.URL.RawQuery = v.Encode() } var zone CustomerZone err = c.do(req, &zone) if err != nil { return nil, fmt.Errorf("failed to call API: %w", err) } return zone.Records, nil } // RemoveRecord removes a record. func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.BaseURL.JoinPath("v1", "zones", zoneID, "records", recordID) req, err := makeJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } err = c.do(req, nil) if err != nil { return fmt.Errorf("failed to call API: %w", err) } return nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(APIKeyHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func makeJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errClient := &ClientError{StatusCode: resp.StatusCode} err := json.Unmarshal(raw, &errClient.errors) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errClient } ================================================ FILE: providers/dns/internal/ionos/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(), servermock.CheckHeader().With(APIKeyHeader, "secret")) } func TestClient_ListZones(t *testing.T) { client := mockBuilder(). Route("GET /v1/zones", servermock.ResponseFromFixture("list_zones.json")). Build(t) zones, err := client.ListZones(t.Context()) require.NoError(t, err) expected := []Zone{{ ID: "11af3414-ebba-11e9-8df5-66fbe8a334b4", Name: "test.com", Type: "NATIVE", }} assert.Equal(t, expected, zones) } func TestClient_ListZones_error(t *testing.T) { client := mockBuilder(). Route("GET /v1/zones", servermock.ResponseFromFixture("list_zones_error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) zones, err := client.ListZones(t.Context()) require.Error(t, err) assert.Nil(t, zones) var cErr *ClientError assert.ErrorAs(t, err, &cErr) assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /v1/zones/azone01", servermock.ResponseFromFixture("get_records.json")). Build(t) records, err := client.GetRecords(t.Context(), "azone01", nil) require.NoError(t, err) expected := []Record{{ ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", Name: "string", Content: "string", Type: "A", }} assert.Equal(t, expected, records) } func TestClient_GetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /v1/zones/azone01", servermock.ResponseFromFixture("get_records_error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) records, err := client.GetRecords(t.Context(), "azone01", nil) require.Error(t, err) assert.Nil(t, records) var cErr *ClientError assert.ErrorAs(t, err, &cErr) assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode) } func TestClient_RemoveRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/zones/azone01/records/arecord01", nil). Build(t) err := client.RemoveRecord(t.Context(), "azone01", "arecord01") require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/zones/azone01/records/arecord01", servermock.ResponseFromFixture("remove_record_error.json"). WithStatusCode(http.StatusInternalServerError)). Build(t) err := client.RemoveRecord(t.Context(), "azone01", "arecord01") require.Error(t, err) var cErr *ClientError assert.ErrorAs(t, err, &cErr) assert.Equal(t, http.StatusInternalServerError, cErr.StatusCode) } func TestClient_ReplaceRecords(t *testing.T) { client := mockBuilder(). Route("PATCH /v1/zones/azone01", nil). Build(t) records := []Record{{ ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", Name: "string", Content: "string", Type: "A", }} err := client.ReplaceRecords(t.Context(), "azone01", records) require.NoError(t, err) } func TestClient_ReplaceRecords_error(t *testing.T) { client := mockBuilder(). Route("PATCH /v1/zones/azone01", servermock.ResponseFromFixture("replace_records_error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) records := []Record{{ ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", Name: "string", Content: "string", Type: "A", }} err := client.ReplaceRecords(t.Context(), "azone01", records) require.Error(t, err) var cErr *ClientError assert.ErrorAs(t, err, &cErr) assert.Equal(t, http.StatusBadRequest, cErr.StatusCode) } ================================================ FILE: providers/dns/internal/ionos/internal/fixtures/get_records.json ================================================ { "id": "11af3414-ebba-11e9-8df5-66fbe8a334b4", "name": "example-zone.de", "type": "NATIVE", "records": [ { "id": "22af3414-abbe-9e11-5df5-66fbe8e334b4", "name": "string", "rootName": "string", "type": "A", "content": "string", "changeDate": "string", "ttl": 0, "prio": 0, "disabled": false } ] } ================================================ FILE: providers/dns/internal/ionos/internal/fixtures/get_records_error.json ================================================ [ { "code": "UNAUTHORIZED", "message": "The customer is not authorized to do this operation." } ] ================================================ FILE: providers/dns/internal/ionos/internal/fixtures/list_zones.json ================================================ [ { "id": "11af3414-ebba-11e9-8df5-66fbe8a334b4", "name": "test.com", "type": "NATIVE" } ] ================================================ FILE: providers/dns/internal/ionos/internal/fixtures/list_zones_error.json ================================================ [ { "code": "UNAUTHORIZED", "message": "The customer is not authorized to do this operation." } ] ================================================ FILE: providers/dns/internal/ionos/internal/fixtures/remove_record_error.json ================================================ [ { "code": "UNAUTHORIZED", "message": "The customer is not authorized to do this operation." }, { "code": "RECORD_NOT_FOUND", "message": "Record does not exist." }, { "code": "INTERNAL_SERVER_ERROR" } ] ================================================ FILE: providers/dns/internal/ionos/internal/fixtures/replace_records_error.json ================================================ [ { "code": "INVALID_RECORD", "message": "string", "parameters": { "errorRecord": { "id": "string", "name": "string", "disabled": false, "rootName": "string", "changeDate": "string", "type": "A", "content": "string", "ttl": 0, "prio": 0 }, "requiredFields": [ "string" ], "invalid": [ "string" ], "invalidFields": [ "string" ] } }, { "code": "UNAUTHORIZED", "message": "The customer is not authorized to do this operation." }, { "code": "INTERNAL_SERVER_ERROR" } ] ================================================ FILE: providers/dns/internal/ionos/internal/types.go ================================================ package internal import ( "fmt" "strconv" "strings" ) // ClientError a detailed error. type ClientError struct { errors []Error StatusCode int message string } func (f ClientError) Error() string { var msg strings.Builder msg.WriteString(strconv.Itoa(f.StatusCode) + ": ") if f.message != "" { msg.WriteString(f.message + ": ") } for i, e := range f.errors { if i != 0 { msg.WriteString(", ") } msg.WriteString(e.Error()) } return msg.String() } func (f ClientError) Unwrap() error { if len(f.errors) == 0 { return nil } return &f.errors[0] } // Error defines model for error. type Error struct { // The error code. Code string `json:"code,omitempty"` // The error message. Message string `json:"message,omitempty"` } func (e Error) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Message) } // Zone defines model for zone. type Zone struct { // The zone id. ID string `json:"id,omitempty"` // The zone name. Name string `json:"name,omitempty"` // Represents the possible zone types. Type string `json:"type,omitempty"` } // CustomerZone defines model for customer-zone. type CustomerZone struct { // The zone id. ID string `json:"id,omitempty"` // The zone name Name string `json:"name,omitempty"` Records []Record `json:"records,omitempty"` // Represents the possible zone types. Type string `json:"type,omitempty"` } // Record defines model for record. type Record struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Content string `json:"content,omitempty"` // Time to live for the record, recommended 3600. TTL int `json:"ttl,omitempty"` // Holds supported dns record types. Type string `json:"type,omitempty"` Priority int `json:"prio,omitempty"` // When is true, the record is not visible for lookup. Disabled bool `json:"disabled,omitempty"` } type RecordsFilter struct { // The FQDN used to filter all the record names that end with it. Suffix string `url:"suffix,omitempty"` // The record names that should be included (same as name field of Record) RecordName string `url:"recordName,omitempty"` // A comma-separated list of record types that should be included RecordType string `url:"recordType,omitempty"` } ================================================ FILE: providers/dns/internal/ionos/provider.go ================================================ package ionos import ( "context" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ionos "github.com/go-acme/lego/v4/providers/dns/internal/ionos/internal" ) const MinTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *ionos.Client } // NewDNSProviderConfig return a DNSProvider instance configured for Ionos. func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { if config == nil { return nil, errors.New("the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("credentials missing") } if config.TTL < MinTTL { return nil, fmt.Errorf("invalid TTL, TTL (%d) must be greater than %d", config.TTL, MinTTL) } client, err := ionos.NewClient(config.APIKey) if err != nil { return nil, err } if baseURL != "" { client.BaseURL, _ = url.Parse(baseURL) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zones, err := d.client.ListZones(ctx) if err != nil { return fmt.Errorf("failed to get zones: %w", err) } name := dns01.UnFqdn(info.EffectiveFQDN) zone := findZone(zones, name) if zone == nil { return errors.New("no matching zone found for domain") } filter := &ionos.RecordsFilter{ Suffix: name, RecordType: "TXT", } records, err := d.client.GetRecords(ctx, zone.ID, filter) if err != nil { return fmt.Errorf("failed to get records (zone=%s): %w", zone.ID, err) } records = append(records, ionos.Record{ Name: name, Content: info.Value, TTL: d.config.TTL, Type: "TXT", }) err = d.client.ReplaceRecords(ctx, zone.ID, records) if err != nil { return fmt.Errorf("failed to create/update records (zone=%s): %w", zone.ID, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zones, err := d.client.ListZones(ctx) if err != nil { return fmt.Errorf("failed to get zones: %w", err) } name := dns01.UnFqdn(info.EffectiveFQDN) zone := findZone(zones, name) if zone == nil { return errors.New("no matching zone found for domain") } filter := &ionos.RecordsFilter{ Suffix: name, RecordType: "TXT", } records, err := d.client.GetRecords(ctx, zone.ID, filter) if err != nil { return fmt.Errorf("failed to get records (zone=%s): %w", zone.ID, err) } for _, record := range records { if record.Name == name && record.Content == strconv.Quote(info.Value) { err = d.client.RemoveRecord(ctx, zone.ID, record.ID) if err != nil { return fmt.Errorf("failed to remove record (zone=%s, record=%s): %w", zone.ID, record.ID, err) } return nil } } return fmt.Errorf("failed to remove record, record not found (zone=%s, domain=%s, fqdn=%s, value=%s)", zone.ID, domain, info.EffectiveFQDN, info.Value) } func findZone(zones []ionos.Zone, domain string) *ionos.Zone { var result *ionos.Zone for _, zone := range zones { if zone.Name != "" && strings.HasSuffix(domain, zone.Name) { if result == nil || len(zone.Name) > len(result.Name) { result = &zone } } } return result } ================================================ FILE: providers/dns/internal/ionos/provider_test.go ================================================ package ionos import ( "testing" "github.com/stretchr/testify/require" ) func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string tll int expected string }{ { desc: "success", apiKey: "123", tll: MinTTL, }, { desc: "missing credentials", tll: MinTTL, expected: "credentials missing", }, { desc: "invalid TTL", apiKey: "123", tll: 30, expected: "invalid TTL, TTL (30) must be greater than 300", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := &Config{} config.APIKey = test.apiKey config.TTL = test.tll p, err := NewDNSProviderConfig(config, "") if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } ================================================ FILE: providers/dns/internal/ptr/types.go ================================================ package ptr func Deref[T any](v *T) T { if v == nil { var zero T return zero } return *v } func Pointer[T any](v T) *T { return &v } ================================================ FILE: providers/dns/internal/rimuhosting/internal/client.go ================================================ package internal import ( "context" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "regexp" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://rimuhosting.com/dns/dyndns.jsp" // Action names. const ( SetAction = "SET" QueryAction = "QUERY" DeleteAction = "DELETE" ) // Client the RimuHosting/Zonomi client. type Client struct { apiKey string HTTPClient *http.Client BaseURL string } // NewClient Creates a RimuHosting/Zonomi client. func NewClient(apiKey string) *Client { return &Client{ apiKey: apiKey, BaseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // FindTXTRecords Finds TXT records. // ex: // - https://zonomi.com/app/dns/dyndns.jsp?action=QUERY&name=example.com&api_key=apikeyvaluehere // - https://zonomi.com/app/dns/dyndns.jsp?action=QUERY&name=**.example.com&api_key=apikeyvaluehere func (c *Client) FindTXTRecords(ctx context.Context, domain string) ([]Record, error) { action := ActionParameter{ Action: QueryAction, Name: domain, Type: "TXT", } resp, err := c.DoActions(ctx, action) if err != nil { return nil, err } return resp.Actions.Action.Records, nil } // DoActions performs actions. func (c *Client) DoActions(ctx context.Context, actions ...ActionParameter) (*DNSAPIResult, error) { if len(actions) == 0 { return nil, errors.New("no action") } resp := &DNSAPIResult{} if len(actions) == 1 { action := actionParameter{ ActionParameter: actions[0], APIKey: c.apiKey, } err := c.do(ctx, action, resp) if err != nil { return nil, err } return resp, nil } multi := c.toMultiParameters(actions) err := c.do(ctx, multi, resp) if err != nil { return nil, err } return resp, nil } func (c *Client) toMultiParameters(params []ActionParameter) multiActionParameter { multi := multiActionParameter{ APIKey: c.apiKey, } for _, parameters := range params { multi.Action = append(multi.Action, parameters.Action) multi.Name = append(multi.Name, parameters.Name) multi.Type = append(multi.Type, parameters.Type) multi.Value = append(multi.Value, parameters.Value) multi.TTL = append(multi.TTL, parameters.TTL) } return multi } func (c *Client) do(ctx context.Context, params, result any) error { baseURL, err := url.Parse(c.BaseURL) if err != nil { return err } v, err := querystring.Values(params) if err != nil { return err } exp := regexp.MustCompile(`(%5B)(%5D)(\d+)=`) baseURL.RawQuery = exp.ReplaceAllString(v.Encode(), "${1}${3}${2}=") req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = xml.Unmarshal(raw, result) if err != nil { return fmt.Errorf("unmarshaling %T error: %w: %s", result, err, string(raw)) } return nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := APIError{} err := xml.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errAPI } // NewAddRecordAction helper to create an action to add a TXT record. func NewAddRecordAction(domain, content string, ttl int) ActionParameter { return ActionParameter{ Action: SetAction, Name: domain, Type: "TXT", Value: content, TTL: ttl, } } // NewDeleteRecordAction helper to create an action to delete a TXT record. func NewDeleteRecordAction(domain, content string) ActionParameter { return ActionParameter{ Action: DeleteAction, Name: domain, Type: "TXT", Value: content, } } ================================================ FILE: providers/dns/internal/rimuhosting/internal/client_test.go ================================================ package internal import ( "encoding/xml" "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("apikeyvaluehere") client.BaseURL = server.URL client.HTTPClient = server.Client() return client, nil } func TestClient_FindTXTRecords(t *testing.T) { testCases := []struct { desc string domain string response string query url.Values expected []Record }{ { desc: "simple", domain: "example.com", response: "find_records.xml", query: url.Values{ "name": []string{"example.com"}, "type": []string{"TXT"}, "action": []string{"QUERY"}, "api_key": []string{"apikeyvaluehere"}, }, expected: []Record{ { Name: "example.org", Type: "TXT", Content: "txttxtx", TTL: "3600 seconds", Priority: "0", }, }, }, { desc: "pattern", domain: "**.example.com", response: "find_records_pattern.xml", query: url.Values{ "name": []string{"**.example.com"}, "type": []string{"TXT"}, "action": []string{"QUERY"}, "api_key": []string{"apikeyvaluehere"}, }, expected: []Record{ { Name: "_test.example.org", Type: "TXT", Content: "txttxtx", TTL: "3600 seconds", Priority: "0", }, { Name: "example.org", Type: "TXT", Content: "txttxtx", TTL: "3600 seconds", Priority: "0", }, }, }, { desc: "empty", domain: "empty.com", response: "find_records_empty.xml", query: url.Values{ "name": []string{"empty.com"}, "type": []string{"TXT"}, "action": []string{"QUERY"}, "api_key": []string{"apikeyvaluehere"}, }, expected: nil, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.ResponseFromFixture(test.response), servermock.CheckQueryParameter().Strict(). WithValues(test.query)). Build(t) records, err := client.FindTXTRecords(t.Context(), test.domain) require.NoError(t, err) assert.Equal(t, test.expected, records) }) } } func TestClient_DoActions(t *testing.T) { testCases := []struct { desc string actions []ActionParameter query url.Values response string expected *DNSAPIResult }{ { desc: "SET simple", actions: []ActionParameter{ NewAddRecordAction("example.org", "txttxtx", 0), }, response: "add_record.xml", query: url.Values{ "action": []string{"SET"}, "name": []string{"example.org"}, "type": []string{"TXT"}, "value": []string{"txttxtx"}, "api_key": []string{"apikeyvaluehere"}, }, expected: &DNSAPIResult{ XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, IsOk: "OK:", ResultCounts: ResultCounts{Added: "1", Changed: "0", Unchanged: "0", Deleted: "0"}, Actions: Actions{ Action: Action{ Action: "SET", Host: "example.org", Type: "TXT", Records: []Record{{ Name: "example.org", Type: "TXT", Content: "txttxtx", TTL: "3600 seconds", Priority: "0", }}, }, }, }, }, { desc: "SET multiple values", actions: []ActionParameter{ NewAddRecordAction("example.org", "txttxtx", 0), NewAddRecordAction("example.org", "sample", 0), }, response: "add_record_same_domain.xml", query: url.Values{ "api_key": []string{"apikeyvaluehere"}, "action[0]": []string{"SET"}, "name[0]": []string{"example.org"}, "ttl[0]": []string{"0"}, "type[0]": []string{"TXT"}, "value[0]": []string{"txttxtx"}, "action[1]": []string{"SET"}, "name[1]": []string{"example.org"}, "ttl[1]": []string{"0"}, "type[1]": []string{"TXT"}, "value[1]": []string{"sample"}, }, expected: &DNSAPIResult{ XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, IsOk: "OK:", ResultCounts: ResultCounts{Added: "2", Changed: "0", Unchanged: "0", Deleted: "0"}, Actions: Actions{ Action: Action{ Action: "SET", Host: "example.org", Type: "TXT", Records: []Record{ { Name: "example.org", Type: "TXT", Content: "txttxtx", TTL: "0 seconds", Priority: "0", }, { Name: "example.org", Type: "TXT", Content: "sample", TTL: "0 seconds", Priority: "0", }, }, }, }, }, }, { desc: "DELETE nothing", actions: []ActionParameter{ NewDeleteRecordAction("example.org", "nothing"), }, response: "delete_record_nothing.xml", query: url.Values{ "action": []string{"DELETE"}, "name": []string{"example.org"}, "type": []string{"TXT"}, "value": []string{"nothing"}, "api_key": []string{"apikeyvaluehere"}, }, expected: &DNSAPIResult{ XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, IsOk: "OK:", ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "0"}, Actions: Actions{ Action: Action{ Action: "DELETE", Host: "example.org", Type: "TXT", Records: nil, }, }, }, }, { desc: "DELETE simple", actions: []ActionParameter{ NewDeleteRecordAction("example.org", "txttxtx"), }, response: "delete_record.xml", query: url.Values{ "action": []string{"DELETE"}, "name": []string{"example.org"}, "type": []string{"TXT"}, "value": []string{"txttxtx"}, "api_key": []string{"apikeyvaluehere"}, }, expected: &DNSAPIResult{ XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, IsOk: "OK:", ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "1"}, Actions: Actions{ Action: Action{ Action: "DELETE", Host: "example.org", Type: "TXT", Records: []Record{{ Name: "example.org", Type: "TXT", Content: "txttxtx", TTL: "3600 seconds", Priority: "0", }}, }, }, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.ResponseFromFixture(test.response), servermock.CheckQueryParameter().Strict(). WithValues(test.query)). Build(t) resp, err := client.DoActions(t.Context(), test.actions...) require.NoError(t, err) assert.Equal(t, test.expected, resp) }) } } func TestClient_DoActions_error(t *testing.T) { testCases := []struct { desc string actions []ActionParameter query url.Values response string expected string }{ { desc: "SET error", actions: []ActionParameter{ NewAddRecordAction("example.com", "txttxtx", 0), }, response: "add_record_error.xml", query: url.Values{ "action": []string{"SET"}, "name": []string{"example.com"}, "type": []string{"TXT"}, "value": []string{"txttxtx"}, "api_key": []string{"apikeyvaluehere"}, }, expected: "ERROR: No zone found for example.com", }, { desc: "DELETE error", actions: []ActionParameter{ NewDeleteRecordAction("example.com", "txttxtx"), }, response: "delete_record_error.xml", query: url.Values{ "action": []string{"DELETE"}, "name": []string{"example.com"}, "type": []string{"TXT"}, "value": []string{"txttxtx"}, "api_key": []string{"apikeyvaluehere"}, }, expected: "ERROR: No zone found for example.com", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.ResponseFromFixture(test.response). WithStatusCode(http.StatusInternalServerError), servermock.CheckQueryParameter().Strict(). WithValues(test.query)). Build(t) _, err := client.DoActions(t.Context(), test.actions...) require.EqualError(t, err, test.expected) }) } } ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/add_record.xml ================================================ ]>OK: ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/add_record_error.xml ================================================ ]> ERROR: No zone found for example.com ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/add_record_same_domain.xml ================================================ ]>OK: ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/delete_record.xml ================================================ ]>OK: ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/delete_record_error.xml ================================================ ]> ERROR: No zone found for example.com ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/delete_record_nothing.xml ================================================ ]>OK: ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/find_records.xml ================================================ ]>OK: ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/find_records_empty.xml ================================================ ]>OK: ================================================ FILE: providers/dns/internal/rimuhosting/internal/fixtures/find_records_pattern.xml ================================================ ]>OK: ================================================ FILE: providers/dns/internal/rimuhosting/internal/types.go ================================================ package internal import "encoding/xml" type ActionParameter struct { Action string `url:"action,omitempty"` Name string `url:"name,omitempty"` Type string `url:"type,omitempty"` Value string `url:"value,omitempty"` TTL int `url:"ttl,omitempty"` Priority int `url:"prio,omitempty"` } type actionParameter struct { ActionParameter APIKey string `url:"api_key,omitempty"` } type multiActionParameter struct { APIKey string `url:"api_key,omitempty"` Action []string `url:"action,brackets,numbered,omitempty"` Name []string `url:"name,brackets,numbered,omitempty"` Type []string `url:"type,brackets,numbered,omitempty"` Value []string `url:"value,brackets,numbered,omitempty"` TTL []int `url:"ttl,brackets,numbered,omitempty"` Priority []int `url:"prio,brackets,numbered,omitempty"` } type APIError struct { XMLName xml.Name `xml:"error"` Text string `xml:",chardata"` } func (a APIError) Error() string { return a.Text } type DNSAPIResult struct { XMLName xml.Name `xml:"dnsapi_result"` IsOk string `xml:"is_ok"` ResultCounts ResultCounts `xml:"result_counts"` Actions Actions `xml:"actions"` } type ResultCounts struct { Added string `xml:"added,attr"` Changed string `xml:"changed,attr"` Unchanged string `xml:"unchanged,attr"` Deleted string `xml:"deleted,attr"` } type Actions struct { Action Action `xml:"action"` } type Action struct { Action string `xml:"action,attr"` Host string `xml:"host,attr"` Type string `xml:"type,attr"` Records []Record `xml:"record"` } type Record struct { Name string `xml:"name,attr"` Type string `xml:"type,attr"` Content string `xml:"content,attr"` TTL string `xml:"ttl,attr"` Priority string `xml:"prio,attr"` } ================================================ FILE: providers/dns/internal/rimuhosting/provider.go ================================================ // Package rimuhosting implements a DNS provider for solving the DNS-01 challenge using RimuHosting DNS. package rimuhosting import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/rimuhosting/internal" ) const DefaultTTL = 3600 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProviderConfig return a DNSProvider instance configured for RimuHosting. func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { if config == nil { return nil, errors.New("the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("incomplete credentials, missing API key") } client := internal.NewClient(config.APIKey) if baseURL != "" { client.BaseURL = baseURL } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("failed to find record(s) for %s: %w", domain, err) } actions := []internal.ActionParameter{ internal.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL), } for _, record := range records { actions = append(actions, internal.NewAddRecordAction(record.Name, record.Content, d.config.TTL)) } _, err = d.client.DoActions(ctx, actions...) if err != nil { return fmt.Errorf("failed to add record(s) for %s: %w", domain, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) action := internal.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value) _, err := d.client.DoActions(context.Background(), action) if err != nil { return fmt.Errorf("failed to delete record for %s: %w", domain, err) } return nil } ================================================ FILE: providers/dns/internal/rimuhosting/provider_test.go ================================================ package rimuhosting import ( "testing" "github.com/stretchr/testify/require" ) func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string apiKey string secretKey string }{ { desc: "success", apiKey: "api_key", secretKey: "api_secret", }, { desc: "missing api key", apiKey: "", secretKey: "api_secret", expected: "incomplete credentials, missing API key", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := &Config{} config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config, "") if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } ================================================ FILE: providers/dns/internal/selectel/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.selectel.ru/domains/v1" const tokenHeader = "X-Token" // Client represents the DNS client. type Client struct { token string BaseURL *url.URL HTTPClient *http.Client } // NewClient returns a client instance. func NewClient(token string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetDomainByName gets Domain object by its name. If `domainName` level > 2 and there is // no such domain on the account - it'll recursively search for the first // which is exists in Selectel Domain API. func (c *Client) GetDomainByName(ctx context.Context, domainName string) (*Domain, error) { req, err := newJSONRequest(ctx, http.MethodGet, c.BaseURL.JoinPath(domainName), nil) if err != nil { return nil, err } domain := &Domain{} statusCode, err := c.do(req, domain) if err != nil { if statusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1 { // Look up for the next subdomain _, after, _ := strings.Cut(domainName, ".") return c.GetDomainByName(ctx, after) } return nil, err } return domain, nil } // AddRecord adds Record for given domain. func (c *Client) AddRecord(ctx context.Context, domainID int, body Record) (*Record, error) { req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL.JoinPath(strconv.Itoa(domainID), "records", "/"), body) if err != nil { return nil, err } record := &Record{} _, err = c.do(req, record) if err != nil { return nil, err } return record, nil } // ListRecords returns list records for specific domain. func (c *Client) ListRecords(ctx context.Context, domainID int) ([]Record, error) { req, err := newJSONRequest(ctx, http.MethodGet, c.BaseURL.JoinPath(strconv.Itoa(domainID), "records", "/"), nil) if err != nil { return nil, err } var records []Record _, err = c.do(req, &records) if err != nil { return nil, err } return records, nil } // DeleteRecord deletes specific record. func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error { endpoint := c.BaseURL.JoinPath(strconv.Itoa(domainID), "records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } _, err = c.do(req, nil) return err } func (c *Client) do(req *http.Request, result any) (int, error) { req.Header.Set(tokenHeader, c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return 0, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return resp.StatusCode, parseError(req, resp) } if result == nil { return resp.StatusCode, nil } raw, err := io.ReadAll(resp.Body) if err != nil { return resp.StatusCode, errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return resp.StatusCode, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return resp.StatusCode, nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIError{} err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("request failed with status code %d: %w", resp.StatusCode, errAPI) } ================================================ FILE: providers/dns/internal/selectel/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("token") client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil } func TestClient_ListRecords(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). Route("GET /123/records/", servermock.ResponseFromFixture("list_records.json")). Build(t) records, err := client.ListRecords(t.Context(), 123) require.NoError(t, err) expected := []Record{ {ID: 123, Name: "example.com", Type: "TXT", TTL: 60, Email: "email@example.com", Content: "txttxttxtA"}, {ID: 1234, Name: "example.org", Type: "TXT", TTL: 60, Email: "email@example.org", Content: "txttxttxtB"}, {ID: 12345, Name: "example.net", Type: "TXT", TTL: 60, Email: "email@example.net", Content: "txttxttxtC"}, } assert.Equal(t, expected, records) } func TestClient_ListRecords_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). With(tokenHeader, "token")). Route("GET /123/records/", servermock.ResponseFromFixture("error.json").WithStatusCode(http.StatusUnauthorized)). Build(t) records, err := client.ListRecords(t.Context(), 123) require.EqualError(t, err, "request failed with status code 401: API error: 400 - error description - field that the error occurred in") assert.Nil(t, records) } func TestClient_GetDomainByName(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). With(tokenHeader, "token")). Route("GET /sub.sub.example.org", servermock.Noop().WithStatusCode(http.StatusNotFound)). Route("GET /sub.example.org", servermock.Noop().WithStatusCode(http.StatusNotFound)). Route("GET /example.org", servermock.ResponseFromFixture("domains.json")). Build(t) domain, err := client.GetDomainByName(t.Context(), "sub.sub.example.org") require.NoError(t, err) expected := &Domain{ ID: 123, Name: "example.org", } assert.Equal(t, expected, domain) } func TestClient_AddRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). With(tokenHeader, "token")). Route("POST /123/records/", servermock.ResponseFromFixture("add_record.json"), servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")). Build(t) record, err := client.AddRecord(t.Context(), 123, Record{ Name: "example.org", Type: "TXT", TTL: 60, Email: "email@example.org", Content: "txttxttxttxt", }) require.NoError(t, err) expected := &Record{ ID: 456, Name: "example.org", Type: "TXT", TTL: 60, Email: "email@example.org", Content: "txttxttxttxt", } assert.Equal(t, expected, record) } func TestClient_DeleteRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). With(tokenHeader, "token")). Route("DELETE /123/records/456", nil). Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } ================================================ FILE: providers/dns/internal/selectel/internal/fixtures/add_record-request.json ================================================ { "name": "example.org", "type": "TXT", "ttl": 60, "email": "email@example.org", "content": "txttxttxttxt" } ================================================ FILE: providers/dns/internal/selectel/internal/fixtures/add_record.json ================================================ { "id": 456, "name": "example.org", "type": "TXT", "ttl": 60, "email": "email@example.org", "content": "txttxttxttxt" } ================================================ FILE: providers/dns/internal/selectel/internal/fixtures/domains.json ================================================ { "id": 123, "name": "example.org" } ================================================ FILE: providers/dns/internal/selectel/internal/fixtures/error.json ================================================ { "error": "error description", "code": 400, "field": "field that the error occurred in" } ================================================ FILE: providers/dns/internal/selectel/internal/fixtures/list_records.json ================================================ [ { "id": 123, "name": "example.com", "type": "TXT", "ttl": 60, "email": "email@example.com", "content": "txttxttxtA" }, { "id": 1234, "name": "example.org", "type": "TXT", "ttl": 60, "email": "email@example.org", "content": "txttxttxtB" }, { "id": 12345, "name": "example.net", "type": "TXT", "ttl": 60, "email": "email@example.net", "content": "txttxttxtC" } ] ================================================ FILE: providers/dns/internal/selectel/internal/types.go ================================================ package internal import "fmt" // Domain represents domain name. type Domain struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` } // Record represents DNS record. type Record struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` // Record type (SOA, NS, A/AAAA, CNAME, SRV, MX, TXT, SPF) TTL int `json:"ttl,omitempty"` Email string `json:"email,omitempty"` // Email of domain's admin (only for SOA records) Content string `json:"content,omitempty"` // Record content (not for SRV) } // APIError API error message. type APIError struct { Description string `json:"error"` Code int `json:"code"` Field string `json:"field"` } func (a APIError) Error() string { return fmt.Sprintf("API error: %d - %s - %s", a.Code, a.Description, a.Field) } ================================================ FILE: providers/dns/internal/selectel/provider.go ================================================ // Package selectel implements a DNS provider for solving the DNS-01 challenge using Selectel Domains API. package selectel import ( "context" "errors" "fmt" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/selectel/internal" ) const MinTTL = 60 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client // TODO(ldez): remove in v5? BaseURL string } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProviderConfig return a DNSProvider instance configured for selectel. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("credentials missing") } if config.TTL < MinTTL { return nil, fmt.Errorf("invalid TTL, TTL (%d) must be greater than %d", config.TTL, MinTTL) } client := internal.NewClient(config.Token) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) var err error client.BaseURL, err = url.Parse(config.BaseURL) if err != nil { return nil, fmt.Errorf("%w", err) } return &DNSProvider{config: config, client: client}, nil } // Timeout returns the Timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill DNS-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() // TODO(ldez) replace domain by FQDN to follow CNAME. domainObj, err := d.client.GetDomainByName(ctx, domain) if err != nil { return fmt.Errorf("get domain by name: %w", err) } txtRecord := internal.Record{ Type: "TXT", TTL: d.config.TTL, Name: info.EffectiveFQDN, Content: info.Value, } _, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord) if err != nil { return fmt.Errorf("add record: %w", err) } return nil } // CleanUp removes a TXT record used for DNS-01 challenge. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) recordName := dns01.UnFqdn(info.EffectiveFQDN) ctx := context.Background() // TODO(ldez) replace domain by FQDN to follow CNAME. domainObj, err := d.client.GetDomainByName(ctx, domain) if err != nil { return fmt.Errorf("%w", err) } records, err := d.client.ListRecords(ctx, domainObj.ID) if err != nil { return fmt.Errorf("list records: %w", err) } // Delete records with specific FQDN var lastErr error for _, record := range records { if record.Name == recordName { err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID) if err != nil { lastErr = fmt.Errorf("delete record: %w", err) } } } return lastErr } ================================================ FILE: providers/dns/internal/selectel/provider_test.go ================================================ package selectel import ( "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string ttl int expected string }{ { desc: "success", token: "123", ttl: 60, }, { desc: "missing api key", token: "", ttl: 60, expected: "credentials missing", }, { desc: "bad TTL value", token: "123", ttl: 59, expected: fmt.Sprintf("invalid TTL, TTL (59) must be greater than %d", MinTTL), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := &Config{} config.TTL = test.ttl config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } ================================================ FILE: providers/dns/internal/tecnocratica/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) // defaultBaseURL is the default API endpoint. const defaultBaseURL = "https://api.neodigit.net/v1" // Client is a Tecnocrática API client. type Client struct { token string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(token string) (*Client, error) { if token == "" { return nil, errors.New("credentials missing: token") } baseURL, err := url.Parse(defaultBaseURL) if err != nil { return nil, err } return &Client{ token: token, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 30 * time.Second}, }, nil } // GetZones lists all DNS zones. func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { endpoint := c.BaseURL.JoinPath("dns", "zones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var zones []Zone err = c.do(req, &zones) if err != nil { return nil, err } return zones, nil } // GetRecords lists all records in a zone. func (c *Client) GetRecords(ctx context.Context, zoneID int, recordType string) ([]Record, error) { endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records") if recordType != "" { query := endpoint.Query() query.Set("type", recordType) endpoint.RawQuery = query.Encode() } req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var records []Record err = c.do(req, &records) if err != nil { return nil, err } return records, nil } // CreateRecord creates a new DNS record. func (c *Client) CreateRecord(ctx context.Context, zoneID int, record Record) (*Record, error) { endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records") payload := RecordRequest{Record: record} req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return nil, err } var result Record err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } // DeleteRecord deletes a DNS record. func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID int) error { endpoint := c.BaseURL.JoinPath("dns", "zones", strconv.Itoa(zoneID), "records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) req.Header.Set("X-TCpanel-Token", c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/internal/tecnocratica/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With("X-TCpanel-Token", "secret")) } func TestClient_GetZones(t *testing.T) { client := mockBuilder(). Route("GET /dns/zones", servermock.ResponseFromFixture("get_zones.json")). Build(t) zones, err := client.GetZones(t.Context()) require.NoError(t, err) expected := []Zone{ { ID: 6, Name: "example.com", HumanName: "example.com", }, { ID: 7, Name: "example.org", HumanName: "example.org", }, } assert.Equal(t, expected, zones) } func TestClient_GetZones_error(t *testing.T) { client := mockBuilder(). Route("GET /dns/zones", servermock.RawStringResponse(`{"error": "unauthorized"}`). WithStatusCode(http.StatusUnauthorized)). Build(t) zones, err := client.GetZones(t.Context()) require.Error(t, err) assert.Nil(t, zones) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/zones/6/records", servermock.ResponseFromFixture("get_records.json")). Build(t) records, err := client.GetRecords(t.Context(), 6, "") require.NoError(t, err) expected := []Record{ { ID: 98, Name: "", Type: "SOA", Content: "ns1.example.org dns.example.org 2015092102 7200 7200 1209600 1800", TTL: 7200, }, { ID: 99, Name: "", Type: "NS", Content: "ns1.example.org", TTL: 7200, }, { ID: 100, Name: "_acme-challenge", Type: "TXT", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, }, } assert.Equal(t, expected, records) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/zones/6/records", servermock.ResponseFromFixture("create_record.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := Record{ Name: "_acme-challenge", Type: "TXT", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, } result, err := client.CreateRecord(t.Context(), 6, record) require.NoError(t, err) expected := &Record{ ID: 101, Name: "_acme-challenge", Type: "TXT", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, } assert.Equal(t, expected, result) } func TestClient_CreateRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /dns/zones/6/records", servermock.RawStringResponse(`{"error": "bad request"}`). WithStatusCode(http.StatusBadRequest)). Build(t) record := Record{ Name: "_acme-challenge", Type: "TXT", Content: "test-value", TTL: 120, } result, err := client.CreateRecord(t.Context(), 6, record) require.Error(t, err) assert.Nil(t, result) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/zones/6/records/101", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.DeleteRecord(t.Context(), 6, 101) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/zones/6/records/999", servermock.RawStringResponse(`{"error": "not found"}`). WithStatusCode(http.StatusNotFound)). Build(t) err := client.DeleteRecord(t.Context(), 6, 999) require.Error(t, err) } ================================================ FILE: providers/dns/internal/tecnocratica/internal/fixtures/create_record-request.json ================================================ { "record": { "name": "_acme-challenge", "type": "TXT", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 120 } } ================================================ FILE: providers/dns/internal/tecnocratica/internal/fixtures/create_record.json ================================================ { "id": 101, "name": "_acme-challenge", "type": "TXT", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 120, "prio": null, "created_at": "2015-09-21T14:40:27.127+02:00", "updated_at": "2015-09-21T14:40:27.127+02:00" } ================================================ FILE: providers/dns/internal/tecnocratica/internal/fixtures/get_records.json ================================================ [ { "id": 98, "name": "", "type": "SOA", "content": "ns1.example.org dns.example.org 2015092102 7200 7200 1209600 1800", "ttl": 7200, "prio": null }, { "id": 99, "name": "", "type": "NS", "content": "ns1.example.org", "ttl": 7200, "prio": null }, { "id": 100, "name": "_acme-challenge", "type": "TXT", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 120, "prio": null } ] ================================================ FILE: providers/dns/internal/tecnocratica/internal/fixtures/get_zones.json ================================================ [ { "id": 6, "name": "example.com", "created_at": "2015-09-21T12:19:04.000+02:00", "updated_at": "2015-09-21T12:19:04.000+02:00", "human_name": "example.com" }, { "id": 7, "name": "example.org", "created_at": "2015-09-22T10:00:00.000+02:00", "updated_at": "2015-09-22T10:00:00.000+02:00", "human_name": "example.org" } ] ================================================ FILE: providers/dns/internal/tecnocratica/internal/types.go ================================================ package internal // Zone represents a DNS zone. type Zone struct { ID int `json:"id"` Name string `json:"name"` HumanName string `json:"human_name"` } // Record represents a DNS record. type Record struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` Priority int `json:"prio,omitempty"` } // RecordRequest is the request body for creating/updating a record. type RecordRequest struct { Record Record `json:"record"` } ================================================ FILE: providers/dns/internal/tecnocratica/provider.go ================================================ // Package tecnocratica implements a DNS provider for solving the DNS-01 challenge using Tecnocrática. package tecnocratica import ( "context" "errors" "fmt" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica/internal" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client zoneIDs map[string]int recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProviderConfig return a DNSProvider instance configured for Tecnocrática. func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { if config == nil { return nil, errors.New("the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("missing credentials") } client, err := internal.NewClient(config.Token) if err != nil { return nil, fmt.Errorf("create client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } if baseURL != "" { client.BaseURL, err = url.Parse(baseURL) if err != nil { return nil, err } } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, zoneIDs: make(map[string]int), recordIDs: make(map[string]int), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) zone, err := d.findZone(ctx, authZone) if err != nil { return fmt.Errorf("%w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("%w", err) } record := internal.Record{ Name: subDomain, Type: "TXT", Content: info.Value, TTL: d.config.TTL, } newRecord, err := d.client.CreateRecord(ctx, zone.ID, record) if err != nil { return fmt.Errorf("create record: %w", err) } d.recordIDsMu.Lock() d.zoneIDs[token] = zone.ID d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() zoneID, zoneOK := d.zoneIDs[token] recordID, recordOK := d.recordIDs[token] d.recordIDsMu.Unlock() if !zoneOK || !recordOK { return fmt.Errorf("unknown record ID or zone ID for '%s' '%s'", info.EffectiveFQDN, token) } err := d.client.DeleteRecord(context.Background(), zoneID, recordID) if err != nil { return fmt.Errorf("delete record: fqdn=%s, zoneID=%d, recordID=%d: %w", info.EffectiveFQDN, zoneID, recordID, err) } d.recordIDsMu.Lock() delete(d.zoneIDs, token) delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) findZone(ctx context.Context, zoneName string) (*internal.Zone, error) { zones, err := d.client.GetZones(ctx) if err != nil { return nil, fmt.Errorf("get zones: %w", err) } for _, zone := range zones { if zone.Name == zoneName || zone.HumanName == zoneName { return &zone, nil } } return nil, fmt.Errorf("zone not found: %s", zoneName) } ================================================ FILE: providers/dns/internal/tecnocratica/provider_test.go ================================================ package tecnocratica import ( "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "secret", }, { desc: "missing token", expected: "missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := &Config{} config.Token = test.token p, err := NewDNSProviderConfig(config, "") if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := &Config{ Token: "secret", PropagationTimeout: 10 * time.Second, PollingInterval: 1 * time.Second, TTL: 120, HTTPClient: server.Client(), } p, err := NewDNSProviderConfig(config, server.URL) if err != nil { return nil, err } return p, nil }, servermock.CheckHeader().WithJSONHeaders(). With("X-TCpanel-Token", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /dns/zones", servermock.ResponseFromInternal("get_zones.json")). Route("POST /dns/zones/6/records", servermock.ResponseFromInternal("create_record.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /dns/zones/456/records/123", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) token := "abc" provider.recordIDs[token] = 123 provider.zoneIDs[token] = 456 err := provider.CleanUp("example.com", token, "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/internal/useragent/useragent.go ================================================ // Code generated by 'internal/releaser'; DO NOT EDIT. package useragent import ( "fmt" "net/http" "runtime" ) const ( // ourUserAgent is the User-Agent of this underlying library package. ourUserAgent = "goacme-lego/4.33.0" // ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package. // values: detach|release // NOTE: Update this with each tagged release. ourUserAgentComment = "detach" ) // Get builds and returns the User-Agent string. func Get() string { return fmt.Sprintf("%s (%s; %s; %s)", ourUserAgent, ourUserAgentComment, runtime.GOOS, runtime.GOARCH) } // SetHeader sets the User-Agent header. func SetHeader(h http.Header) { h.Set("User-Agent", Get()) } ================================================ FILE: providers/dns/internal/westcn/internal/client.go ================================================ package internal import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "errors" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" "golang.org/x/text/encoding" "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/transform" ) const defaultBaseURL = "https://api.west.cn/api/v2" // Client the West.cn API client. type Client struct { username string password string encoder *encoding.Encoder BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(username, password string) (*Client, error) { if username == "" || password == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ username: username, password: password, encoder: simplifiedchinese.GBK.NewEncoder(), BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // AddRecord adds a record. // https://www.west.cn/CustomerCenter/doc/domain_v2.html#37u3001u6dfbu52a0u57dfu540du89e3u67900a3ca20id3d37u3001u6dfbu52a0u57dfu540du89e3u67903e203ca3e func (c *Client) AddRecord(ctx context.Context, record Record) (int, error) { values, err := querystring.Values(record) if err != nil { return 0, err } req, err := c.newRequest(ctx, "domain", "adddnsrecord", values) if err != nil { return 0, err } results := &APIResponse[RecordID]{} err = c.do(req, results) if err != nil { return 0, err } if results.Result != http.StatusOK { return 0, results } return results.Data.ID, nil } // DeleteRecord deleted a record. // https://www.west.cn/CustomerCenter/doc/domain_v2.html#39u3001u5220u9664u57dfu540du89e3u67900a3ca20id3d39u3001u5220u9664u57dfu540du89e3u67903e203ca3e func (c *Client) DeleteRecord(ctx context.Context, domain string, recordID int) error { values := url.Values{} values.Set("domain", domain) values.Set("id", strconv.Itoa(recordID)) req, err := c.newRequest(ctx, "domain", "deldnsrecord", values) if err != nil { return err } results := &APIResponse[any]{} err = c.do(req, results) if err != nil { return err } if results.Result != http.StatusOK { return results } return nil } func (c *Client) newRequest(ctx context.Context, p, act string, form url.Values) (*http.Request, error) { if form == nil { form = url.Values{} } c.sign(form, time.Now()) values, err := c.convertURLValues(form) if err != nil { return nil, err } endpoint := c.BaseURL.JoinPath(p, "/") query := endpoint.Query() query.Set("act", act) endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(values.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req, nil } func (c *Client) sign(form url.Values, now time.Time) { timestamp := strconv.FormatInt(now.UnixMilli(), 10) sum := md5.Sum([]byte(c.username + c.password + timestamp)) form.Set("token", hex.EncodeToString(sum[:])) form.Set("username", c.username) form.Set("time", timestamp) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = gbkDecoder(raw).Decode(result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) convertURLValues(values url.Values) (url.Values, error) { results := make(url.Values) for key, vs := range values { encKey, err := c.encoder.String(key) if err != nil { return nil, err } for _, value := range vs { encValue, err := c.encoder.String(value) if err != nil { return nil, err } results.Add(encKey, encValue) } } return results, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) result := &APIResponse[any]{} err := gbkDecoder(raw).Decode(result) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return result } func gbkDecoder(raw []byte) *json.Decoder { return json.NewDecoder(transform.NewReader(bytes.NewBuffer(raw), simplifiedchinese.GBK.NewDecoder())) } ================================================ FILE: providers/dns/internal/westcn/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/encoding/simplifiedchinese" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func TestClientAddRecord(t *testing.T) { client := mockBuilder(). Route("POST /domain/", servermock.ResponseFromFixture("adddnsrecord.json"). WithHeader("Content-Type", "application/json", "Charset=gb2312"), servermock.CheckQueryParameter().Strict(). With("act", "adddnsrecord"), servermock.CheckForm().UsePostForm().Strict(). With("domain", "example.com"). With("host", "@"). With("ttl", "60"). With("type", "TXT"). With("value", "txtTXTtxt"). // With("act", "adddnsrecord"). With("username", "user"). WithRegexp("time", `\d+`). WithRegexp("token", `[a-z0-9]{32}`), ). Build(t) record := Record{ Domain: "example.com", Host: "@", Type: "TXT", Value: "txtTXTtxt", TTL: 60, } id, err := client.AddRecord(t.Context(), record) require.NoError(t, err) assert.Equal(t, 123456, id) } func TestClientAddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domain/", servermock.ResponseFromFixture("error.json"). WithHeader("Content-Type", "application/json", "Charset=gb2312"), servermock.CheckQueryParameter().Strict(). With("act", "adddnsrecord"), ). Build(t) record := Record{ Domain: "example.com", Host: "@", Type: "TXT", Value: "txtTXTtxt", TTL: 60, } _, err := client.AddRecord(t.Context(), record) require.Error(t, err) require.EqualError(t, err, "10000: username,time,token必传 (500)") } func TestClientDeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /domain/", servermock.ResponseFromFixture("deldnsrecord.json"). WithHeader("Content-Type", "application/json", "Charset=gb2312"), servermock.CheckQueryParameter().Strict(). With("act", "deldnsrecord"), servermock.CheckForm().UsePostForm().Strict(). With("id", "123"). With("domain", "example.com"). With("username", "user"). WithRegexp("time", `\d+`). WithRegexp("token", `[a-z0-9]{32}`), ). Build(t) err := client.DeleteRecord(t.Context(), "example.com", 123) require.NoError(t, err) } func TestClientDeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domain/", servermock.ResponseFromFixture("error.json"). WithHeader("Content-Type", "application/json", "Charset=gb2312"), servermock.CheckQueryParameter().Strict(). With("act", "deldnsrecord"), ). Build(t) err := client.DeleteRecord(t.Context(), "example.com", 123) require.Error(t, err) require.EqualError(t, err, "10000: username,time,token必传 (500)") } func Test_convertURLValues(t *testing.T) { client, err := NewClient("user", "secret") require.NoError(t, err) key := "你好abc" value := "世界def" form := url.Values{} form.Set(key, value) values, err := client.convertURLValues(form) require.NoError(t, err) encoder := simplifiedchinese.GBK.NewEncoder() k, err := encoder.String(key) require.NoError(t, err) v, err := encoder.String(value) require.NoError(t, err) assert.Equal(t, v, values.Get(k)) decoder := simplifiedchinese.GBK.NewDecoder() decValue, err := decoder.String(values.Get(k)) require.NoError(t, err) assert.Equal(t, value, decValue) } func TestClient_sign(t *testing.T) { client, err := NewClient("zhangsan", "5dh232kfg!*") require.NoError(t, err) form := url.Values{} client.sign(form, time.UnixMilli(1554691950854)) assert.Equal(t, "zhangsan", form.Get("username")) assert.Equal(t, "1554691950854", form.Get("time")) assert.Equal(t, "f17581fb2535b2a7ee4468eb3f96a2a9", form.Get("token")) } ================================================ FILE: providers/dns/internal/westcn/internal/fixtures/adddnsrecord.json ================================================ { "result": 200, "clientid": "54880064508339547956", "data": { "id": 123456 } } ================================================ FILE: providers/dns/internal/westcn/internal/fixtures/deldnsrecord.json ================================================ { "result": 200, "clientid": "54880064508339547956" } ================================================ FILE: providers/dns/internal/westcn/internal/fixtures/error.json ================================================ { "result": 500, "clientid": "54880064508339547956", "msg": "username,time,tokenش", "errcode": 10000 } ================================================ FILE: providers/dns/internal/westcn/internal/types.go ================================================ package internal import "fmt" type APIResponse[T any] struct { Result int `json:"result,omitempty"` ClientID string `json:"clientid,omitempty"` Message string `json:"msg,omitempty"` ErrorCode int `json:"errcode,omitempty"` Data T `json:"data,omitempty"` } func (a APIResponse[T]) Error() string { return fmt.Sprintf("%d: %s (%d)", a.ErrorCode, a.Message, a.Result) } type Record struct { Domain string `url:"domain,omitempty"` Host string `url:"host,omitempty"` Type string `url:"type,omitempty"` Value string `url:"value,omitempty"` TTL int `url:"ttl,omitempty"` // 60~86400 seconds Priority int `url:"level,omitempty"` } type RecordID struct { ID int `json:"id,omitempty"` } ================================================ FILE: providers/dns/internal/westcn/provider.go ================================================ package westcn import ( "context" "errors" "fmt" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/westcn/internal" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProviderConfig return a DNSProvider instance configured for West.cn/西部数码. func NewDNSProviderConfig(config *Config, baseURL string) (*DNSProvider, error) { if config == nil { return nil, errors.New("the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.Username, config.Password) if err != nil { return nil, fmt.Errorf("%w", err) } if baseURL != "" { client.BaseURL, err = url.Parse(baseURL) if err != nil { return nil, err } } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("%w", err) } record := internal.Record{ Domain: dns01.UnFqdn(authZone), Host: subDomain, Type: "TXT", Value: info.Value, TTL: d.config.TTL, } recordID, err := d.client.AddRecord(context.Background(), record) if err != nil { return fmt.Errorf("add record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("could not find zone for domain %q: %w", domain, err) } // gets the record's unique ID d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) if err != nil { return fmt.Errorf("delete record: %w", err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/internal/westcn/provider_test.go ================================================ package westcn import ( "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "user", password: "secret", }, { desc: "missing username", password: "secret", expected: "credentials missing", }, { desc: "missing password", username: "user", expected: "credentials missing", }, { desc: "missing credentials", expected: "credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := &Config{} config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config, "") if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := &Config{ Username: "user", Password: "secret", PropagationTimeout: 10 * time.Second, PollingInterval: 1 * time.Second, TTL: 120, HTTPClient: server.Client(), } p, err := NewDNSProviderConfig(config, server.URL) if err != nil { return nil, err } return p, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /domain/", servermock.ResponseFromInternal("adddnsrecord.json"). WithHeader("Content-Type", "application/json", "Charset=gb2312"), servermock.CheckQueryParameter().Strict(). With("act", "adddnsrecord"), servermock.CheckForm().UsePostForm().Strict(). With("domain", "example.com"). With("host", "_acme-challenge"). With("ttl", "120"). With("type", "TXT"). With("value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). // With("act", "adddnsrecord"). With("username", "user"). WithRegexp("time", `\d+`). WithRegexp("token", `[a-z0-9]{32}`), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("POST /domain/", servermock.ResponseFromInternal("deldnsrecord.json"). WithHeader("Content-Type", "application/json", "Charset=gb2312"), servermock.CheckQueryParameter().Strict(). With("act", "deldnsrecord"), servermock.CheckForm().UsePostForm().Strict(). With("id", "123"). With("domain", "example.com"). With("username", "user"). WithRegexp("time", `\d+`). WithRegexp("token", `[a-z0-9]{32}`), ). Build(t) provider.recordIDs["abc"] = 123 err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/internetbs/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" "unicode" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const baseURL = "https://api.internet.bs" // status SUCCESS, PENDING, FAILURE. const statusSuccess = "SUCCESS" // Client is the API client. type Client struct { apiKey string password string debug bool baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey, password string) *Client { baseURL, _ := url.Parse(baseURL) return &Client{ apiKey: apiKey, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // AddRecord The command is intended to add a new DNS record to a specific zone (domain). func (c *Client) AddRecord(ctx context.Context, query RecordQuery) error { var r APIResponse err := c.doRequest(ctx, "Add", query, &r) if err != nil { return err } if r.Status != statusSuccess { return r } return nil } // RemoveRecord The command is intended to remove a DNS record from a specific zone. func (c *Client) RemoveRecord(ctx context.Context, query RecordQuery) error { var r APIResponse err := c.doRequest(ctx, "Remove", query, &r) if err != nil { return err } if r.Status != statusSuccess { return r } return nil } // ListRecords The command is intended to retrieve the list of DNS records for a specific domain. func (c *Client) ListRecords(ctx context.Context, query ListRecordQuery) ([]Record, error) { var l ListResponse err := c.doRequest(ctx, "List", query, &l) if err != nil { return nil, err } if l.Status != statusSuccess { return nil, l.APIResponse } return l.Records, nil } func (c *Client) doRequest(ctx context.Context, action string, params, result any) error { endpoint := c.baseURL.JoinPath("Domain", "DnsRecord", action) values, err := querystring.Values(params) if err != nil { return fmt.Errorf("parse query parameters: %w", err) } values.Set("apiKey", c.apiKey) values.Set("password", c.password) values.Set("ResponseFormat", "JSON") req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(values.Encode())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if c.debug { return dump(endpoint, resp, result) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func dump(endpoint *url.URL, resp *http.Response, response any) error { raw, err := io.ReadAll(resp.Body) if err != nil { return err } fields := strings.FieldsFunc(endpoint.Path, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }) err = os.WriteFile(filepath.Join("fixtures", strings.Join(fields, "_")+".json"), raw, 0o666) if err != nil { return err } return json.Unmarshal(raw, response) } ================================================ FILE: providers/dns/internetbs/internal/client_test.go ================================================ package internal import ( "fmt" "net/http/httptest" "net/url" "os" "strconv" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testBaseURL = "https://testapi.internet.bs" const ( testAPIKey = "testapi" testPassword = "testpass" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(testAPIKey, testPassword) client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded(), ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /Domain/DnsRecord/Add", servermock.ResponseFromFixture("Domain_DnsRecord_Add_SUCCESS.json"), servermock.CheckForm().Strict(). With("fullrecordname", "www.example.com"). With("ttl", "36000"). With("type", "TXT"). With("value", "xxx"). With("password", testPassword). With("apiKey", testAPIKey). With("ResponseFormat", "JSON")). Build(t) query := RecordQuery{ FullRecordName: "www.example.com", Type: "TXT", Value: "xxx", TTL: 36000, } err := client.AddRecord(t.Context(), query) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /Domain/DnsRecord/Add", servermock.ResponseFromFixture("Domain_DnsRecord_Add_FAILURE.json")). Build(t) query := RecordQuery{ FullRecordName: "www.example.com.", Type: "TXT", Value: "xxx", TTL: 36000, } err := client.AddRecord(t.Context(), query) require.Error(t, err) } func TestClient_AddRecord_integration(t *testing.T) { env, ok := os.LookupEnv("INTERNET_BS_DEBUG") if !ok { t.Skip("skip integration test") } client := NewClient(testAPIKey, testPassword) client.baseURL, _ = url.Parse(testBaseURL) client.debug, _ = strconv.ParseBool(env) query := RecordQuery{ FullRecordName: "www.example.com", Type: "TXT", Value: "xxx", TTL: 36000, } err := client.AddRecord(t.Context(), query) require.NoError(t, err) query = RecordQuery{ FullRecordName: "www.example.com", Type: "TXT", Value: "yyy", TTL: 36000, } err = client.AddRecord(t.Context(), query) require.NoError(t, err) } func TestClient_RemoveRecord(t *testing.T) { client := mockBuilder(). Route("POST /Domain/DnsRecord/Remove", servermock.ResponseFromFixture("Domain_DnsRecord_Remove_SUCCESS.json"), servermock.CheckForm().Strict(). With("fullrecordname", "www.example.com"). With("type", "TXT"). With("password", testPassword). With("apiKey", testAPIKey). With("ResponseFormat", "JSON")). Build(t) query := RecordQuery{ FullRecordName: "www.example.com", Type: "TXT", Value: "", } err := client.RemoveRecord(t.Context(), query) require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /Domain/DnsRecord/Remove", servermock.ResponseFromFixture("Domain_DnsRecord_Remove_FAILURE.json")). Build(t) query := RecordQuery{ FullRecordName: "www.example.com.", Type: "TXT", Value: "", } err := client.RemoveRecord(t.Context(), query) require.Error(t, err) } func TestClient_RemoveRecord_integration(t *testing.T) { env, ok := os.LookupEnv("INTERNET_BS_DEBUG") if !ok { t.Skip("skip integration test") } client := NewClient(testAPIKey, testPassword) client.baseURL, _ = url.Parse(testBaseURL) client.debug, _ = strconv.ParseBool(env) query := RecordQuery{ FullRecordName: "www.example.com", Type: "TXT", Value: "", } err := client.RemoveRecord(t.Context(), query) require.NoError(t, err) } func TestClient_ListRecords(t *testing.T) { client := mockBuilder(). Route("POST /Domain/DnsRecord/List", servermock.ResponseFromFixture("Domain_DnsRecord_List_SUCCESS.json"), servermock.CheckForm().Strict(). With("Domain", "example.com"). With("password", testPassword). With("apiKey", testAPIKey). With("ResponseFormat", "JSON")). Build(t) query := ListRecordQuery{ Domain: "example.com", } records, err := client.ListRecords(t.Context(), query) require.NoError(t, err) expected := []Record{ { Name: "example.com", Value: "ns-hongkong.internet.bs", TTL: 3600, Type: "NS", }, { Name: "example.com", Value: "ns-toronto.internet.bs", TTL: 3600, Type: "NS", }, { Name: "example.com", Value: "ns-london.internet.bs", TTL: 3600, Type: "NS", }, { Name: "test.example.com", Value: "example1.com", TTL: 3600, Type: "CNAME", }, { Name: "www.example.com", Value: "xxx", TTL: 36000, Type: "TXT", }, { Name: "www.example.com", Value: "yyy", TTL: 36000, Type: "TXT", }, } assert.Equal(t, expected, records) } func TestClient_ListRecords_error(t *testing.T) { client := mockBuilder(). Route("POST /Domain/DnsRecord/List", servermock.ResponseFromFixture("Domain_DnsRecord_List_FAILURE.json")). Build(t) query := ListRecordQuery{ Domain: "www.example.com", } _, err := client.ListRecords(t.Context(), query) require.Error(t, err) } func TestClient_ListRecords_integration(t *testing.T) { env, ok := os.LookupEnv("INTERNET_BS_DEBUG") if !ok { t.Skip("skip integration test") } client := NewClient(testAPIKey, testPassword) client.baseURL, _ = url.Parse(testBaseURL) client.debug, _ = strconv.ParseBool(env) query := ListRecordQuery{ Domain: "example.com", } records, err := client.ListRecords(t.Context(), query) require.NoError(t, err) for _, record := range records { fmt.Println(record) } } ================================================ FILE: providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_FAILURE.json ================================================ { "transactid": "67e4689073df2f153e7184aeb47a98f9", "status": "FAILURE", "message": "Invalid value \"www.example.com.\" for parameter \"fullrecordname\"!", "code": 100002 } ================================================ FILE: providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Add_SUCCESS.json ================================================ { "transactid": "548e3298130b492de23258634fd74481", "status": "SUCCESS" } ================================================ FILE: providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_FAILURE.json ================================================ { "transactid": "5d554e0a5d145feb316b1805aae50706", "status": "FAILURE", "message": "The domain www.example.com does not have a supported extension!", "code": 100004 } ================================================ FILE: providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_List_SUCCESS.json ================================================ { "transactid": "3d161c37da7c824c8b3463b25f461df0", "status": "SUCCESS", "total_records": 6, "records": [ { "name": "example.com", "value": "ns-hongkong.internet.bs", "ttl": 3600, "type": "NS" }, { "name": "example.com", "value": "ns-toronto.internet.bs", "ttl": 3600, "type": "NS" }, { "name": "example.com", "value": "ns-london.internet.bs", "ttl": 3600, "type": "NS" }, { "name": "test.example.com", "value": "example1.com", "ttl": 3600, "type": "CNAME" }, { "name": "www.example.com", "value": "xxx", "ttl": 36000, "type": "TXT" }, { "name": "www.example.com", "value": "yyy", "ttl": 36000, "type": "TXT" } ] } ================================================ FILE: providers/dns/internetbs/internal/fixtures/Domain_DnsRecord_Remove_SUCCESS.json ================================================ { "transactid": "221a0fe572f0505194214405f395a847", "status": "SUCCESS" } ================================================ FILE: providers/dns/internetbs/internal/fixtures/auth_error.json ================================================ { "transactid": "d46d812569acdb8b39c3933ec4351e79", "status": "FAILURE", "message": "Invalid API key and\/or Password", "code": 107002 } ================================================ FILE: providers/dns/internetbs/internal/types.go ================================================ package internal import "fmt" type APIResponse struct { TransactID string `json:"transactid"` Status string `json:"status"` Message string `json:"message,omitempty"` Code int `json:"code,omitempty"` } func (a APIResponse) Error() string { return fmt.Sprintf("%s(%d): %s (%s)", a.Status, a.Code, a.Message, a.TransactID) } type ListResponse struct { APIResponse TotalRecords int `json:"total_records,omitempty"` Records []Record `json:"records,omitempty"` } type Record struct { Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` } type RecordQuery struct { FullRecordName string `url:"fullrecordname"` Type string `url:"type"` Value string `url:"value,omitempty"` TTL int `url:"ttl,omitempty"` } type ListRecordQuery struct { Domain string `url:"Domain"` FilterType string `url:"FilterType,omitempty"` } ================================================ FILE: providers/dns/internetbs/internetbs.go ================================================ // Package internetbs implements a DNS provider for solving the DNS-01 challenge using internet.bs. package internetbs import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internetbs/internal" ) // Environment variables names. const ( envNamespace = "INTERNET_BS_" EnvAPIKey = envNamespace + "API_KEY" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for internet.bs. // Credentials must be passed in the environment variables: INTERNET_BS_API_KEY, INTERNET_BS_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvPassword) if err != nil { return nil, fmt.Errorf("internetbs: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for internet.bs. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("internetbs: the configuration of the DNS provider is nil") } if config.APIKey == "" || config.Password == "" { return nil, errors.New("internetbs: missing credentials") } client := internal.NewClient(config.APIKey, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) query := internal.RecordQuery{ FullRecordName: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", Value: info.Value, TTL: d.config.TTL, } err := d.client.AddRecord(context.Background(), query) if err != nil { return fmt.Errorf("internetbs: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) query := internal.RecordQuery{ FullRecordName: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", Value: info.Value, TTL: d.config.TTL, } err := d.client.RemoveRecord(context.Background(), query) if err != nil { return fmt.Errorf("internetbs: %w", err) } return nil } ================================================ FILE: providers/dns/internetbs/internetbs.toml ================================================ Name = "Internet.bs" Description = '''''' URL = "https://internetbs.net" Code = "internetbs" Since = "v4.5.0" Example = ''' INTERNET_BS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx \ INTERNET_BS_PASSWORD=yyyyyyyyyyyyyyyyyyyyyyyyyy \ lego --dns internetbs -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] INTERNET_BS_API_KEY = "API key" INTERNET_BS_PASSWORD = "API password" [Configuration.Additional] INTERNET_BS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" INTERNET_BS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" INTERNET_BS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" INTERNET_BS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://internetbs.net/internet-bs-api.pdf" ================================================ FILE: providers/dns/internetbs/internetbs_test.go ================================================ package internetbs import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "user", EnvPassword: "secret", }, }, { desc: "missing API key", envVars: map[string]string{ EnvPassword: "secret", }, expected: "internetbs: some credentials information are missing: INTERNET_BS_API_KEY", }, { desc: "missing password", envVars: map[string]string{ EnvAPIKey: "user", }, expected: "internetbs: some credentials information are missing: INTERNET_BS_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "internetbs: some credentials information are missing: INTERNET_BS_API_KEY,INTERNET_BS_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string password string expected string }{ { desc: "success", apiKey: "user", password: "secret", }, { desc: "missing API key", expected: "internetbs: missing credentials", password: "secret", }, { desc: "missing password", expected: "internetbs: missing credentials", apiKey: "user", }, { desc: "missing credentials", expected: "internetbs: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/inwx/inwx.go ================================================ // Package inwx implements a DNS provider for solving the DNS-01 challenge using inwx dom robot package inwx import ( "errors" "fmt" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/nrdcg/goinwx" "github.com/pquerna/otp/totp" ) // Environment variables names. const ( envNamespace = "INWX_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvSharedSecret = envNamespace + "SHARED_SECRET" EnvSandbox = envNamespace + "SANDBOX" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string SharedSecret string Sandbox bool PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), // INWX has rather unstable propagation delays, thus using a larger default value PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), Sandbox: env.GetOrDefaultBool(EnvSandbox, false), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *goinwx.Client previousUnlock time.Time } // NewDNSProvider returns a DNSProvider instance configured for Dyn DNS. // Credentials must be passed in the environment variables: // INWX_USERNAME, INWX_PASSWORD, and INWX_SHARED_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("inwx: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.SharedSecret = env.GetOrFile(EnvSharedSecret) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Dyn DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("inwx: the configuration of the DNS provider is nil") } if config.Username == "" || config.Password == "" { return nil, errors.New("inwx: credentials missing") } if config.Sandbox { log.Infof("inwx: sandbox mode is enabled") } client := goinwx.NewClient(config.Username, config.Password, &goinwx.ClientOptions{Sandbox: config.Sandbox}) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) } login, err := d.client.Account.Login() if err != nil { return fmt.Errorf("inwx: %w", err) } defer func() { errL := d.client.Account.Logout() if errL != nil { log.Infof("inwx: failed to log out: %v", errL) } }() err = d.twoFactorAuth(login) if err != nil { return fmt.Errorf("inwx: %w", err) } request := &goinwx.NameserverRecordRequest{ Domain: dns01.UnFqdn(authZone), Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", Content: info.Value, TTL: d.config.TTL, } _, err = d.client.Nameservers.CreateRecord(request) if err != nil { var er *goinwx.ErrorResponse if errors.As(err, &er) && er.Message == "Object exists" { return nil } return fmt.Errorf("inwx: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err) } login, err := d.client.Account.Login() if err != nil { return fmt.Errorf("inwx: %w", err) } defer func() { errL := d.client.Account.Logout() if errL != nil { log.Infof("inwx: failed to log out: %v", errL) } }() err = d.twoFactorAuth(login) if err != nil { return fmt.Errorf("inwx: %w", err) } response, err := d.client.Nameservers.Info(&goinwx.NameserverInfoRequest{ Domain: dns01.UnFqdn(authZone), Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", }) if err != nil { return fmt.Errorf("inwx: %w", err) } var recordID string for _, record := range response.Records { if record.Content != info.Value { continue } recordID = record.ID break } if recordID == "" { return errors.New("inwx: TXT record not found") } err = d.client.Nameservers.DeleteRecord(recordID) if err != nil { return fmt.Errorf("inwx: %w", err) } return 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 } func (d *DNSProvider) twoFactorAuth(info *goinwx.LoginResponse) error { if info.TFA != "GOOGLE-AUTH" { return nil } if d.config.SharedSecret == "" { return errors.New("two-factor authentication but no shared secret is given") } // INWX forbids re-authentication with a previously used TAN. // To avoid using the same TAN twice, we wait until the next TOTP period. sleep := d.computeSleep(time.Now()) if sleep != 0 { log.Infof("inwx: waiting %s for next TOTP token", sleep) time.Sleep(sleep) } now := time.Now() tan, err := totp.GenerateCode(d.config.SharedSecret, now) if err != nil { return err } d.previousUnlock = now.Truncate(30 * time.Second) return d.client.Account.Unlock(tan) } func (d *DNSProvider) computeSleep(now time.Time) time.Duration { if d.previousUnlock.IsZero() { return 0 } endPeriod := d.previousUnlock.Add(30 * time.Second) if endPeriod.After(now) { return endPeriod.Sub(now) } return 0 } ================================================ FILE: providers/dns/inwx/inwx.toml ================================================ Name = "INWX" Description = '''''' URL = "https://www.inwx.de/en" Code = "inwx" Since = "v2.0.0" Example = ''' INWX_USERNAME=xxxxxxxxxx \ INWX_PASSWORD=yyyyyyyyyy \ lego --dns inwx -d '*.example.com' -d example.com run # 2FA INWX_USERNAME=xxxxxxxxxx \ INWX_PASSWORD=yyyyyyyyyy \ INWX_SHARED_SECRET=zzzzzzzzzz \ lego --dns inwx -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] INWX_USERNAME = "Username" INWX_PASSWORD = "Password" [Configuration.Additional] INWX_SHARED_SECRET = "shared secret related to 2FA" INWX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" INWX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" INWX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" INWX_SANDBOX = "Activate the sandbox (boolean)" [Links] API = "https://www.inwx.de/en/help/apidoc" GoClient = "https://github.com/nrdcg/goinwx" ================================================ FILE: providers/dns/inwx/inwx_test.go ================================================ package inwx import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvPassword, EnvSharedSecret, EnvSandbox, EnvTTL). WithDomain(envDomain). WithLiveTestRequirements(EnvUsername, EnvPassword, envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvUsername: "", EnvPassword: "", }, expected: "inwx: some credentials information are missing: INWX_USERNAME,INWX_PASSWORD", }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "456", }, expected: "inwx: some credentials information are missing: INWX_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "", }, expected: "inwx: some credentials information are missing: INWX_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "123", password: "456", }, { desc: "missing credentials", expected: "inwx: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresentAndCleanup(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() envTest.Apply(map[string]string{ EnvSandbox: "true", EnvTTL: "3600", // In sandbox mode, the minimum allowed TTL is 3600 }) defer envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) // Verify that no error is thrown if record already exists err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func Test_computeSleep(t *testing.T) { testCases := []struct { desc string now string expected time.Duration }{ { desc: "after 30s", now: "2024-01-01T06:30:30Z", expected: 0 * time.Second, }, { desc: "0s", now: "2024-01-01T06:30:00Z", expected: 0 * time.Second, }, { desc: "before 30s", now: "2024-01-01T06:29:40Z", // 10 s expected: 20 * time.Second, }, } previous, err := time.Parse(time.RFC3339, "2024-01-01T06:29:30Z") require.NoError(t, err) for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() now, err := time.Parse(time.RFC3339, test.now) require.NoError(t, err) d := &DNSProvider{previousUnlock: previous} sleep := d.computeSleep(now) assert.Equal(t, test.expected, sleep) }) } } ================================================ FILE: providers/dns/ionos/ionos.go ================================================ // Package ionos implements a DNS provider for solving the DNS-01 challenge using Ionos/1&1. package ionos import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/ionos" ) // Environment variables names. const ( envNamespace = "IONOS_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = ionos.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, ionos.MinTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute), 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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Ionos. // Credentials must be passed in the environment variables: IONOS_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("ionos: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Ionos. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ionos: the configuration of the DNS provider is nil") } provider, err := ionos.NewDNSProviderConfig(config, "") if err != nil { return nil, fmt.Errorf("ionos: %w", err) } return &DNSProvider{prv: provider}, 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.prv.Timeout() } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("ionos: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("ionos: %w", err) } return nil } ================================================ FILE: providers/dns/ionos/ionos.toml ================================================ Name = "Ionos" Description = '''''' URL = "https://ionos.com" Code = "ionos" Since = "v4.2.0" Example = ''' IONOS_API_KEY=xxxxxxxx \ lego --dns ionos -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] IONOS_API_KEY = "API key `.` https://developer.hosting.ionos.com/docs/getstarted" [Configuration.Additional] IONOS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" IONOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)" IONOS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" IONOS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.hosting.ionos.com/docs/dns" ================================================ FILE: providers/dns/ionos/ionos_test.go ================================================ package ionos import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", }, expected: "ionos: some credentials information are missing: IONOS_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string tll int expected string }{ { desc: "success", apiKey: "123", tll: minTTL, }, { desc: "missing credentials", tll: minTTL, expected: "ionos: credentials missing", }, { desc: "invalid TTL", apiKey: "123", tll: 30, expected: "ionos: invalid TTL, TTL (30) must be greater than 300", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.TTL = test.tll p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/ionoscloud/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://dns.de-fra.ionos.com" const authorizationHeader = "Authorization" // Client the Ionos Cloud API client. type Client struct { apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey string) (*Client, error) { if apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // RetrieveZones returns a list of the DNS zones. // https://api.ionos.com/docs/dns/v1/#tag/Zones/operation/zonesGet func (c *Client) RetrieveZones(ctx context.Context, zoneName string) ([]Zone, error) { endpoint := c.BaseURL.JoinPath("zones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } query := req.URL.Query() query.Add("filter.zoneName", zoneName) req.URL.RawQuery = query.Encode() result := ZonesResponse{} if err := c.do(req, &result); err != nil { return nil, err } return result.Items, nil } // CreateRecord creates a new record for the DNS zone. // https://api.ionos.com/docs/dns/v1/#tag/Records/operation/zonesRecordsPost func (c *Client) CreateRecord(ctx context.Context, zoneID string, record RecordProperties) (*RecordResponse, error) { endpoint := c.BaseURL.JoinPath("zones", zoneID, "records") payload := map[string]RecordProperties{ "properties": record, } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return nil, err } result := &RecordResponse{} if err := c.do(req, result); err != nil { return nil, err } return result, nil } // DeleteRecord deletes a specified record from the DNS zone. // https://api.ionos.com/docs/dns/v1/#tag/Records/operation/zonesRecordsDelete func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.BaseURL.JoinPath("zones", zoneID, "records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) req.Header.Set(authorizationHeader, "Bearer "+c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/ionoscloud/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestClient_RetrieveZones(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("zones.json"), servermock.CheckQueryParameter().Strict(). With("filter.zoneName", "example.com")). Build(t) zones, err := client.RetrieveZones(t.Context(), "example.com") require.NoError(t, err) expected := []Zone{{ ID: "e74d0d15-f567-4b7b-9069-26ee1f93bae3", Type: "zone", Metadata: ZoneMetadata{ CreatedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC), CreatedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", CreatedByUserID: "87f9a82e-b28d-49ed-9d04-fba2c0459cd3", LastModifiedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC), LastModifiedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", LastModifiedByUserID: "63cef532-26fe-4a64-a4e0-de7c8a506c90", ResourceURN: "ionos::::", State: "PROVISIONING", Nameservers: []string{"ns-ic.ui-dns.com", "ns-ic.ui-dns.de", "ns-ic.ui-dns.org", "ns-ic.ui-dns.biz"}, }, Properties: ZoneProperties{ ZoneName: "example.com", Description: "The hosted zone is used for example.com", Enabled: true, }, }} assert.Equal(t, expected, zones) } func TestClient_RetrieveZones_error(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.RetrieveZones(t.Context(), "example.com") require.EqualError(t, err, "401: paas-auth-1: Unauthorized, wrong or no api key provided to process this request") } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /zones/abc/records", servermock.ResponseFromFixture("create_record.json"), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := RecordProperties{ Name: "_acme-challenge", Type: "TXT", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 120, } result, err := client.CreateRecord(t.Context(), "abc", record) require.NoError(t, err) expected := &RecordResponse{ ID: "90d81ac0-3a30-44d4-95a5-12959effa6ee", Type: "record", Metadata: RecordMetadata{ CreatedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC), CreatedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", CreatedByUserID: "87f9a82e-b28d-49ed-9d04-fba2c0459cd3", LastModifiedDate: time.Date(2022, time.August, 21, 15, 52, 53, 0, time.UTC), LastModifiedBy: "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", LastModifiedByUserID: "63cef532-26fe-4a64-a4e0-de7c8a506c90", ResourceURN: "ionos::::", State: "PROVISIONING", Fqdn: "app.example.com", ZoneID: "a363f30c-4c0c-4552-9a07-298d87f219bf", }, Properties: RecordProperties{ Name: "app", Type: "A", Content: "1.2.3.4", TTL: 3600, Priority: 3600, Enabled: true, }, } assert.Equal(t, expected, result) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/abc/records/def", servermock.Noop(). WithStatusCode(http.StatusAccepted)). Build(t) err := client.DeleteRecord(t.Context(), "abc", "def") require.NoError(t, err) } ================================================ FILE: providers/dns/ionoscloud/internal/fixtures/create_record-request.json ================================================ { "properties": { "name": "_acme-challenge", "type": "TXT", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 120 } } ================================================ FILE: providers/dns/ionoscloud/internal/fixtures/create_record.json ================================================ { "id": "90d81ac0-3a30-44d4-95a5-12959effa6ee", "type": "record", "href": "", "metadata": { "createdDate": "2022-08-21T15:52:53Z", "createdBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", "createdByUserId": "87f9a82e-b28d-49ed-9d04-fba2c0459cd3", "lastModifiedDate": "2022-08-21T15:52:53Z", "lastModifiedBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", "lastModifiedByUserId": "63cef532-26fe-4a64-a4e0-de7c8a506c90", "resourceURN": "ionos::::", "state": "PROVISIONING", "fqdn": "app.example.com", "zoneId": "a363f30c-4c0c-4552-9a07-298d87f219bf" }, "properties": { "name": "app", "type": "A", "content": "1.2.3.4", "ttl": 3600, "priority": 3600, "enabled": true } } ================================================ FILE: providers/dns/ionoscloud/internal/fixtures/error.json ================================================ { "httpStatus": 401, "messages": [ { "errorCode": "paas-auth-1", "message": "Unauthorized, wrong or no api key provided to process this request" } ] } ================================================ FILE: providers/dns/ionoscloud/internal/fixtures/zones.json ================================================ { "id": "e74d0d15-f567-4b7b-9069-26ee1f93bae3", "type": "collection", "href": "", "offset": 0, "limit": 1000, "_links": { "prev": "http://PREVIOUS-PAGE-URI", "self": "http://THIS-PAGE-URI", "next": "http://NEXT-PAGE-URI" }, "items": [ { "id": "e74d0d15-f567-4b7b-9069-26ee1f93bae3", "type": "zone", "href": "", "metadata": { "createdDate": "2022-08-21T15:52:53Z", "createdBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", "createdByUserId": "87f9a82e-b28d-49ed-9d04-fba2c0459cd3", "lastModifiedDate": "2022-08-21T15:52:53Z", "lastModifiedBy": "ionos:iam:cloud:31960002:users/87f9a82e-b28d-49ed-9d04-fba2c0459cd3", "lastModifiedByUserId": "63cef532-26fe-4a64-a4e0-de7c8a506c90", "resourceURN": "ionos::::", "state": "PROVISIONING", "nameservers": [ "ns-ic.ui-dns.com", "ns-ic.ui-dns.de", "ns-ic.ui-dns.org", "ns-ic.ui-dns.biz" ] }, "properties": { "zoneName": "example.com", "description": "The hosted zone is used for example.com", "enabled": true } } ] } ================================================ FILE: providers/dns/ionoscloud/internal/types.go ================================================ package internal import ( "fmt" "strconv" "strings" "time" ) type APIError struct { HTTPStatus int `json:"httpStatus"` Messages []ErrorMessage `json:"messages"` } func (a *APIError) Error() string { var msg strings.Builder msg.WriteString(strconv.Itoa(a.HTTPStatus)) for _, m := range a.Messages { msg.WriteString(": ") msg.WriteString(m.String()) } return msg.String() } type ErrorMessage struct { ErrorCode string `json:"errorCode"` Message string `json:"message"` } func (e ErrorMessage) String() string { return fmt.Sprintf("%s: %s", e.ErrorCode, e.Message) } type ZonesResponse struct { ID string `json:"id"` Type string `json:"type"` Offset int `json:"offset"` Limit int `json:"limit"` Items []Zone `json:"items"` } type Zone struct { ID string `json:"id"` Type string `json:"type"` Metadata ZoneMetadata `json:"metadata"` Properties ZoneProperties `json:"properties"` } type ZoneMetadata struct { CreatedDate time.Time `json:"createdDate"` CreatedBy string `json:"createdBy"` CreatedByUserID string `json:"createdByUserId"` LastModifiedDate time.Time `json:"lastModifiedDate"` LastModifiedBy string `json:"lastModifiedBy"` LastModifiedByUserID string `json:"lastModifiedByUserId"` ResourceURN string `json:"resourceURN"` State string `json:"state"` Nameservers []string `json:"nameservers"` } type ZoneProperties struct { ZoneName string `json:"zoneName"` Description string `json:"description"` Enabled bool `json:"enabled"` } type RecordResponse struct { ID string `json:"id"` Type string `json:"type"` Metadata RecordMetadata `json:"metadata"` Properties RecordProperties `json:"properties"` } type RecordMetadata struct { CreatedDate time.Time `json:"createdDate"` CreatedBy string `json:"createdBy"` CreatedByUserID string `json:"createdByUserId"` LastModifiedDate time.Time `json:"lastModifiedDate"` LastModifiedBy string `json:"lastModifiedBy"` LastModifiedByUserID string `json:"lastModifiedByUserId"` ResourceURN string `json:"resourceURN"` State string `json:"state"` Fqdn string `json:"fqdn"` ZoneID string `json:"zoneId"` } type RecordProperties struct { Name string `json:"name"` Type string `json:"type,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` Priority int `json:"priority,omitempty"` Enabled bool `json:"enabled,omitempty"` } ================================================ FILE: providers/dns/ionoscloud/ionoscloud.go ================================================ // Package ionoscloud implements a DNS provider for solving the DNS-01 challenge using Ionos Cloud. package ionoscloud import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/ionoscloud/internal" ) // Environment variables names. const ( envNamespace = "IONOSCLOUD_" EnvAPIToken = envNamespace + "API_TOKEN" 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 { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client zoneIDs map[string]string recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Ionos Cloud. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("ionoscloud: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Ionos Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ionoscloud: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIToken) if err != nil { return nil, fmt.Errorf("ionoscloud: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, zoneIDs: make(map[string]string), recordIDs: make(map[string]string), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ionoscloud: could not find zone for domain %q: %w", domain, err) } zones, err := d.client.RetrieveZones(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("ionoscloud: retrieve zones: %w", err) } if len(zones) != 1 { return fmt.Errorf("ionoscloud: zone ID not found for domain %q", domain) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("ionoscloud: %w", err) } zoneID := zones[0].ID request := internal.RecordProperties{ Name: subDomain, Type: "TXT", Content: info.Value, TTL: d.config.TTL, } record, err := d.client.CreateRecord(ctx, zoneID, request) if err != nil { return fmt.Errorf("ionoscloud: create record: %w", err) } d.recordIDsMu.Lock() d.zoneIDs[token] = zoneID d.recordIDs[token] = record.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() zoneID, ok := d.zoneIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("ionoscloud: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token) } d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("ionoscloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err := d.client.DeleteRecord(context.Background(), zoneID, recordID) if err != nil { return fmt.Errorf("ionoscloud: delete record: %w", err) } d.recordIDsMu.Lock() delete(d.zoneIDs, token) delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/ionoscloud/ionoscloud.toml ================================================ Name = "Ionos Cloud" Description = '''''' URL = "https://cloud.ionos.de/network/cloud-dns" Code = "ionoscloud" Since = "v4.30.0" Example = ''' IONOSCLOUD_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns ionoscloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] IONOSCLOUD_API_TOKEN = "API token" [Configuration.Additional] IONOSCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" IONOSCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" IONOSCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" IONOSCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.ionos.com/docs/dns/v1/" ================================================ FILE: providers/dns/ionoscloud/ionoscloud_test.go ================================================ package ionoscloud import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "ionoscloud: some credentials information are missing: IONOSCLOUD_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "secret", }, { desc: "missing credentials", expected: "ionoscloud: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIToken = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /zones", servermock.ResponseFromInternal("zones.json"), servermock.CheckQueryParameter().Strict(). With("filter.zoneName", "example.com")). Route("POST /zones/e74d0d15-f567-4b7b-9069-26ee1f93bae3/records", servermock.ResponseFromInternal("create_record.json"), servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /zones/e74d0d15-f567-4b7b-9069-26ee1f93bae3/records/90d81ac0-3a30-44d4-95a5-12959effa6ee", servermock.Noop(). WithStatusCode(http.StatusAccepted)). Build(t) token := "abc" provider.zoneIDs[token] = "e74d0d15-f567-4b7b-9069-26ee1f93bae3" provider.recordIDs[token] = "90d81ac0-3a30-44d4-95a5-12959effa6ee" err := provider.CleanUp("example.com", token, "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/ipv64/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) const defaultBaseURL = "https://ipv64.net" type Client struct { baseURL *url.URL HTTPClient *http.Client } func NewClient(hc *http.Client) *Client { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 15 * time.Second} } return &Client{ baseURL: baseURL, HTTPClient: hc, } } func (c *Client) GetDomains(ctx context.Context) (*Domains, error) { endpoint := c.baseURL.JoinPath("api") query := endpoint.Query() query.Set("get_domains", "") endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } results := &Domains{} err = c.do(req, results) if err != nil { return nil, err } return results, nil } func (c *Client) AddRecord(ctx context.Context, domain, prefix, recordType, content string) error { endpoint := c.baseURL.JoinPath("api") data := make(url.Values) data.Set("add_record", domain) data.Set("praefix", prefix) data.Set("type", recordType) data.Set("content", content) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(data.Encode())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } return c.do(req, nil) } func (c *Client) DeleteRecord(ctx context.Context, domain, prefix, recordType, content string) error { endpoint := c.baseURL.JoinPath("api") data := make(url.Values) data.Set("del_record", domain) data.Set("praefix", prefix) data.Set("type", recordType) data.Set("content", content) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint.String(), strings.NewReader(data.Encode())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { if req.Method != http.MethodGet { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if string(raw) == "null" { return fmt.Errorf("unexpected response: %s", string(raw)) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIError{} err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errAPI } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 15 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/ipv64/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testAPIKey = "secret" func setupClient(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil } func TestClient_GetDomains(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /api", servermock.ResponseFromFixture("get_domains.json"), servermock.CheckQueryParameter().Strict(). With("get_domains", "")). Build(t) domains, err := client.GetDomains(t.Context()) require.NoError(t, err) expected := &Domains{ APIResponse: APIResponse{ Status: "200 OK", Info: "success", }, APICall: "get_domains", Subdomains: map[string]Subdomain{ "lego.home64.net": { Updates: 0, Wildcard: 1, DomainUpdateHash: "Dr4l6jFVgkXITqZPEyMHLNsGAfwoSu9v", Records: []Record{ { RecordID: 50665, Content: "2606:2800:220:1:248:1893:25c8:1946", TTL: 60, Type: "AAAA", Prefix: "", LastUpdate: "2023-07-19 13:18:59", RecordKey: "MTA0YzdmMWVjYTFiNDBmZjYwMTU0OGUy", }, }, }, "lego.ipv64.net": { Updates: 0, Wildcard: 1, DomainUpdateHash: "Dr4l6jFVgkXITqZPEyMHLNsGAfwoSu9v", Records: []Record{ { RecordID: 50664, Content: "2606:2800:220:1:248:1893:25c8:1946", TTL: 60, Type: "AAAA", Prefix: "", LastUpdate: "2023-07-19 13:18:59", RecordKey: "ZDMxOWUxMjZjOTk5MmQ3N2M3ODc4NjJj", }, }, }, }, } assert.Equal(t, expected, domains) } func TestClient_GetDomains_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /api", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) domains, err := client.GetDomains(t.Context()) require.Error(t, err) require.Nil(t, domains) } func TestClient_AddRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()). Route("POST /api", servermock.ResponseFromFixture("add_record.json"). WithStatusCode(http.StatusCreated), servermock.CheckForm().Strict(). With("add_record", "lego.ipv64.net"). With("content", "value"). With("praefix", "_acme-challenge"). With("type", "TXT"), ). Build(t) err := client.AddRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /api", servermock.ResponseFromFixture("add_record-error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) err := client.AddRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.Error(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()). Route("DELETE /api", // the query parameters can be checked because the Go server ignores the body of a DELETE request. servermock.ResponseFromFixture("del_record.json"). WithStatusCode(http.StatusAccepted)). Build(t) err := client.DeleteRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("DELETE /api", servermock.ResponseFromFixture("del_record-error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) err := client.DeleteRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.Error(t, err) } ================================================ FILE: providers/dns/ipv64/internal/fixtures/add_record-error.json ================================================ { "info": "error", "status": "400 Bad Request", "add_record": "dns record already there" } ================================================ FILE: providers/dns/ipv64/internal/fixtures/add_record.json ================================================ { "info": "success", "status": "201 Created", "add_record": "lego.ipv64.net" } ================================================ FILE: providers/dns/ipv64/internal/fixtures/del_record-error.json ================================================ { "info": "error", "status": "403 Forbidden", "del_record": "del_record" } ================================================ FILE: providers/dns/ipv64/internal/fixtures/del_record.json ================================================ { "info": "success", "status": "202 Accepted", "del_record": "del_record" } ================================================ FILE: providers/dns/ipv64/internal/fixtures/error.json ================================================ { "status": "401 Unauthorized", "info": "Unauthorized" } ================================================ FILE: providers/dns/ipv64/internal/fixtures/get_domains.json ================================================ { "subdomains": { "lego.ipv64.net": { "updates": 0, "wildcard": 1, "domain_update_hash": "Dr4l6jFVgkXITqZPEyMHLNsGAfwoSu9v", "records": [ { "record_id": 50664, "content": "2606:2800:220:1:248:1893:25c8:1946", "ttl": 60, "type": "AAAA", "praefix": "", "last_update": "2023-07-19 13:18:59", "record_key": "ZDMxOWUxMjZjOTk5MmQ3N2M3ODc4NjJj" } ] }, "lego.home64.net": { "updates": 0, "wildcard": 1, "domain_update_hash": "Dr4l6jFVgkXITqZPEyMHLNsGAfwoSu9v", "records": [ { "record_id": 50665, "content": "2606:2800:220:1:248:1893:25c8:1946", "ttl": 60, "type": "AAAA", "praefix": "", "last_update": "2023-07-19 13:18:59", "record_key": "MTA0YzdmMWVjYTFiNDBmZjYwMTU0OGUy" } ] } }, "info": "success", "status": "200 OK", "add_domain": "get_domains" } ================================================ FILE: providers/dns/ipv64/internal/types.go ================================================ package internal import "fmt" type APIResponse struct { Status string `json:"status"` Info string `json:"info"` } // error type APIError struct { APIResponse AddRecordMessage string `json:"add_record"` DelRecordMessage string `json:"del_record"` AddDomainMessage string `json:"add_domain"` DelDomainMessage string `json:"del_domain"` } func (a APIError) Error() string { msg := a.Info switch { case a.AddRecordMessage != "": msg = a.AddRecordMessage case a.DelRecordMessage != "": msg = a.DelRecordMessage case a.AddDomainMessage != "": msg = a.AddDomainMessage case a.DelDomainMessage != "": msg = a.DelDomainMessage } if msg == "" { return fmt.Sprintf("%s: %s", a.Status, a.Info) } return fmt.Sprintf("%s (%s): %s", a.Info, a.Status, msg) } // get_domains type Domains struct { APIResponse APICall string `json:"add_domain"` Subdomains map[string]Subdomain `json:"subdomains"` } type Subdomain struct { Updates int `json:"updates"` Wildcard int `json:"wildcard"` DomainUpdateHash string `json:"domain_update_hash"` Records []Record `json:"records"` } type Record struct { RecordID int `json:"record_id"` Content string `json:"content"` TTL int `json:"ttl"` Type string `json:"type"` Prefix string `json:"praefix"` LastUpdate string `json:"last_update"` RecordKey string `json:"record_key"` } ================================================ FILE: providers/dns/ipv64/ipv64.go ================================================ // Package ipv64 implements a DNS provider for solving the DNS-01 challenge using IPv64. // See https://ipv64.net/healthcheck_updater_api for more info on updating TXT records. package ipv64 import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/ipv64/internal" "github.com/miekg/dns" ) // Environment variables names. const ( envNamespace = "IPV64_" EnvAPIKey = envNamespace + "API_KEY" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client SequenceInterval time.Duration // Deprecated: unused, will be removed in v5. } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a new DNS provider using // environment variable IPV64_TOKEN for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("ipv64: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for IPv64. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ipv64: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("ipv64: credentials missing") } client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.APIKey)) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) sub, root, err := splitDomain(dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("ipv64: %w", err) } err = d.client.AddRecord(context.Background(), root, sub, "TXT", info.Value) if err != nil { return fmt.Errorf("ipv64: %w", err) } return nil } // CleanUp clears IPv64 TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) sub, root, err := splitDomain(dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("ipv64: %w", err) } err = d.client.DeleteRecord(context.Background(), root, sub, "TXT", info.Value) if err != nil { return fmt.Errorf("ipv64: %w", err) } return 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 } func splitDomain(full string) (string, string, error) { split := dns.Split(full) if len(split) < 3 { return "", "", fmt.Errorf("unsupported domain: %s", full) } if len(split) == 3 { return "", full, nil } domain := full[split[len(split)-3]:] subDomain := full[:split[len(split)-3]-1] return subDomain, domain, nil } ================================================ FILE: providers/dns/ipv64/ipv64.toml ================================================ Name = "IPv64" Description = '''''' URL = "https://ipv64.net/" Code = "ipv64" Since = "v4.13.0" Example = ''' IPV64_API_KEY=xxxxxx \ lego --dns ipv64 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] IPV64_API_KEY = "Account API Key" [Configuration.Additional] IPV64_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" IPV64_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" IPV64_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://ipv64.net/dyndns_updater_api" ================================================ FILE: providers/dns/ipv64/ipv64_test.go ================================================ package ipv64 import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func Test_splitDomain(t *testing.T) { type expected struct { root string sub string requireErr require.ErrorAssertionFunc } testCases := []struct { desc string domain string expected expected }{ { desc: "empty", domain: "", expected: expected{ requireErr: require.Error, }, }, { desc: "2 levels", domain: "example.com", expected: expected{ requireErr: require.Error, }, }, { desc: "3 levels", domain: "_acme-challenge.example.com", expected: expected{ root: "_acme-challenge.example.com", sub: "", requireErr: require.NoError, }, }, { desc: "4 levels", domain: "_acme-challenge.sub.example.com", expected: expected{ root: "sub.example.com", sub: "_acme-challenge", requireErr: require.NoError, }, }, { desc: "5 levels", domain: "_acme-challenge.my.sub.example.com", expected: expected{ root: "sub.example.com", sub: "_acme-challenge.my", requireErr: require.NoError, }, }, { desc: "6 levels", domain: "_acme-challenge.my.sub.sub.example.com", expected: expected{ root: "sub.example.com", sub: "_acme-challenge.my.sub", requireErr: require.NoError, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() sub, root, err := splitDomain(test.domain) test.expected.requireErr(t, err) assert.Equal(t, test.expected.root, root) assert.Equal(t, test.expected.sub, sub) }) } } func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "ipv64: some credentials information are missing: IPV64_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "123", }, { desc: "missing credentials", expected: "ipv64: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/ispconfig/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) type Client struct { serverURL string HTTPClient *http.Client } func NewClient(serverURL string) (*Client, error) { _, err := url.Parse(serverURL) if err != nil { return nil, fmt.Errorf("server URL: %w", err) } return &Client{ serverURL: serverURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) Login(ctx context.Context, username, password string) (string, error) { payload := LoginRequest{ Username: username, Password: password, ClientLogin: false, } endpoint, err := url.Parse(c.serverURL) if err != nil { return "", err } endpoint.RawQuery = "login" req, err := newJSONRequest(ctx, endpoint, payload) if err != nil { return "", err } var response APIResponse err = c.do(req, &response) if err != nil { return "", err } return extractResponse[string](response) } func (c *Client) GetClientID(ctx context.Context, sessionID, sysUserID string) (int, error) { payload := ClientIDRequest{ SessionID: sessionID, SysUserID: sysUserID, } endpoint, err := url.Parse(c.serverURL) if err != nil { return 0, err } endpoint.RawQuery = "client_get_id" req, err := newJSONRequest(ctx, endpoint, payload) if err != nil { return 0, err } var response APIResponse err = c.do(req, &response) if err != nil { return 0, err } return extractResponse[int](response) } // GetZoneID returns the zone ID for the given name. func (c *Client) GetZoneID(ctx context.Context, sessionID, name string) (int, error) { payload := map[string]any{ "session_id": sessionID, "origin": name, } endpoint, err := url.Parse(c.serverURL) if err != nil { return 0, err } endpoint.RawQuery = "dns_zone_get_id" req, err := newJSONRequest(ctx, endpoint, payload) if err != nil { return 0, err } var response APIResponse err = c.do(req, &response) if err != nil { return 0, err } return extractResponse[int](response) } // GetZone returns the zone information for the zone ID. func (c *Client) GetZone(ctx context.Context, sessionID, zoneID string) (*Zone, error) { payload := map[string]any{ "session_id": sessionID, "primary_id": zoneID, } endpoint, err := url.Parse(c.serverURL) if err != nil { return nil, err } endpoint.RawQuery = "dns_zone_get" req, err := newJSONRequest(ctx, endpoint, payload) if err != nil { return nil, err } var response APIResponse err = c.do(req, &response) if err != nil { return nil, err } return extractResponse[*Zone](response) } // GetTXT returns the TXT record for the given name. // `name` must be a fully qualified domain name, e.g. "example.com.". func (c *Client) GetTXT(ctx context.Context, sessionID, name string) (*Record, error) { payload := GetTXTRequest{ SessionID: sessionID, PrimaryID: struct { Name string `json:"name"` Type string `json:"type"` }{ Name: name, Type: "txt", }, } endpoint, err := url.Parse(c.serverURL) if err != nil { return nil, err } endpoint.RawQuery = "dns_txt_get" req, err := newJSONRequest(ctx, endpoint, payload) if err != nil { return nil, err } var response APIResponse err = c.do(req, &response) if err != nil { return nil, err } return extractResponse[*Record](response) } // AddTXT adds a TXT record. // It returns the ID of the newly created record. func (c *Client) AddTXT(ctx context.Context, sessionID, clientID string, params RecordParams) (string, error) { payload := AddTXTRequest{ SessionID: sessionID, ClientID: clientID, Params: ¶ms, UpdateSerial: true, } endpoint, err := url.Parse(c.serverURL) if err != nil { return "", err } endpoint.RawQuery = "dns_txt_add" req, err := newJSONRequest(ctx, endpoint, payload) if err != nil { return "", err } var response APIResponse err = c.do(req, &response) if err != nil { return "", err } return extractResponse[string](response) } // DeleteTXT deletes a TXT record. // It returns the number of deleted records. func (c *Client) DeleteTXT(ctx context.Context, sessionID, recordID string) (int, error) { payload := DeleteTXTRequest{ SessionID: sessionID, PrimaryID: recordID, UpdateSerial: true, } endpoint, err := url.Parse(c.serverURL) if err != nil { return 0, err } endpoint.RawQuery = "dns_txt_delete" req, err := newJSONRequest(ctx, endpoint, payload) if err != nil { return 0, err } var response APIResponse err = c.do(req, &response) if err != nil { return 0, err } return extractResponse[int](response) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func extractResponse[T any](response APIResponse) (T, error) { if response.Code != "ok" { var zero T return zero, &APIError{APIResponse: response} } var result T err := json.Unmarshal(response.Response, &result) if err != nil { var zero T return zero, fmt.Errorf("unable to unmarshal response: %s, %w", string(response.Response), err) } return result, nil } ================================================ FILE: providers/dns/ispconfig/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL) if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }) } func TestClient_Login(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("login.json"), servermock.CheckRequestJSONBodyFromFixture("login-request.json"), servermock.CheckQueryParameter().Strict(). With("login", ""), ). Build(t) sessionID, err := client.Login(t.Context(), "user", "secret") require.NoError(t, err) assert.Equal(t, "abc", sessionID) } func TestClient_Login_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json"), ). Build(t) _, err := client.Login(t.Context(), "user", "secret") require.EqualError(t, err, `code: remote_fault, message: The login failed. Username or password wrong., response: false`) } func TestClient_GetClientID(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("client_get_id.json"), servermock.CheckRequestJSONBodyFromFixture("client_get_id-request.json"), servermock.CheckQueryParameter().Strict(). With("client_get_id", ""), ). Build(t) id, err := client.GetClientID(t.Context(), "sessionA", "sysA") require.NoError(t, err) assert.Equal(t, 123, id) } func TestClient_GetZoneID(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("dns_zone_get_id.json"), servermock.CheckRequestJSONBodyFromFixture("dns_zone_get_id-request.json"), servermock.CheckQueryParameter().Strict(). With("dns_zone_get_id", ""), ). Build(t) zoneID, err := client.GetZoneID(t.Context(), "sessionA", "example.com") require.NoError(t, err) assert.Equal(t, 123, zoneID) } func TestClient_GetZone(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("dns_zone_get.json"), servermock.CheckRequestJSONBodyFromFixture("dns_zone_get-request.json"), servermock.CheckQueryParameter().Strict(). With("dns_zone_get", ""), ). Build(t) zone, err := client.GetZone(t.Context(), "sessionA", "example.com.") require.NoError(t, err) expected := &Zone{ ID: "456", ServerID: "123", SysUserID: "789", SysGroupID: "2", Origin: "example.com.", Serial: "2025102902", Active: "Y", } assert.Equal(t, expected, zone) } func TestClient_GetTXT(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("dns_txt_get.json"), servermock.CheckRequestJSONBodyFromFixture("dns_txt_get-request.json"), servermock.CheckQueryParameter().Strict(). With("dns_txt_get", ""), ). Build(t) record, err := client.GetTXT(t.Context(), "sessionA", "example.com.") require.NoError(t, err) expected := &Record{ID: 123} assert.Equal(t, expected, record) } func TestClient_AddTXT(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("dns_txt_add.json"), servermock.CheckRequestJSONBodyFromFixture("dns_txt_add-request.json"), servermock.CheckQueryParameter().Strict(). With("dns_txt_add", ""), ). Build(t) now := time.Date(2025, 12, 25, 1, 1, 1, 0, time.UTC) params := RecordParams{ ServerID: "serverA", Zone: "example.com.", Name: "foo.example.com.", Type: "txt", Data: "txtTXTtxt", Aux: "0", TTL: "3600", Active: "y", Stamp: now.Format("2006-01-02 15:04:05"), UpdateSerial: true, } recordID, err := client.AddTXT(t.Context(), "sessionA", "clientA", params) require.NoError(t, err) assert.Equal(t, "123", recordID) } func TestClient_DeleteTXT(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("dns_txt_delete.json"), servermock.CheckRequestJSONBodyFromFixture("dns_txt_delete-request.json"), servermock.CheckQueryParameter().Strict(). With("dns_txt_delete", ""), ). Build(t) count, err := client.DeleteTXT(t.Context(), "sessionA", "123") require.NoError(t, err) assert.Equal(t, 1, count) } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/client_get_id-request.json ================================================ { "session_id": "sessionA", "sys_userid": "sysA" } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/client_get_id.json ================================================ { "code": "ok", "message": "foo", "response": 123 } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_txt_add-request.json ================================================ { "session_id": "sessionA", "client_id": "clientA", "params": { "server_id": "serverA", "zone": "example.com.", "name": "foo.example.com.", "type": "txt", "data": "txtTXTtxt", "aux": "0", "ttl": "3600", "active": "y", "stamp": "2025-12-25 01:01:01", "update_serial": true }, "update_serial": true } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_txt_add.json ================================================ { "code": "ok", "message": "foo", "response": "123" } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_txt_delete-request.json ================================================ { "session_id": "sessionA", "primary_id": "123", "update_serial": true } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_txt_delete.json ================================================ { "code": "ok", "message": "foo", "response": 1 } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_txt_get-request.json ================================================ { "session_id": "sessionA", "primary_id": { "name": "example.com.", "type": "txt" } } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_txt_get.json ================================================ { "code": "ok", "message": "foo", "response": { "id": 123 } } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_zone_get-request.json ================================================ { "primary_id": "example.com.", "session_id": "sessionA" } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_zone_get.json ================================================ { "code": "ok", "message": "foo", "response": { "id": "456", "sys_userid": "789", "sys_groupid": "2", "sys_perm_user": "riud", "sys_perm_group": "riud", "sys_perm_other": "", "server_id": "123", "origin": "example.com.", "ns": "ns1.example.org.", "mbox": "support.example.net.", "serial": "2025102902", "refresh": "7200", "retry": "540", "expire": "604800", "minimum": "3600", "ttl": "3600", "active": "Y", "xfer": "", "also_notify": "", "update_acl": "", "dnssec_initialized": "N", "dnssec_wanted": "N", "dnssec_algo": "ECDSAP256SHA256", "dnssec_last_signed": "0", "dnssec_info": "", "rendered_zone": "" } } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_zone_get_id-request.json ================================================ { "origin": "example.com", "session_id": "sessionA" } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/dns_zone_get_id.json ================================================ { "code": "ok", "message": "foo", "response": 123 } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/error.json ================================================ { "code": "remote_fault", "message": "The login failed. Username or password wrong.", "response": false } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/login-request.json ================================================ { "username": "user", "password": "secret", "client_login": false } ================================================ FILE: providers/dns/ispconfig/internal/fixtures/login.json ================================================ { "code": "ok", "message": "foo", "response": "abc" } ================================================ FILE: providers/dns/ispconfig/internal/readme.md ================================================ ## Error Response ```json { "code": "", "message": "", "response": false } ``` ## Login Endpoint * URL: `?login` * HTTP Method: `POST` - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/login.html - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/login.php ### Request Body (JSON) ```json { "username": "", "password": "", "client_login": false } ``` ### Response Body (JSON) ```json { "code": "ok", "message": "foo", "response": "abc" } ``` - `response`: is the `sessionID` ## Get Client ID Endpoint * URL: `?client_get_id` * HTTP Method: `POST` - function `client_get_id`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/client.inc.php#L97 - TABLE `sys_user`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L1852 - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/client_get_id.html - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/client_get_id.php ### Request Body (JSON) ```json { "session_id": "", "sys_userid": "" } ``` ### Response Body (JSON) ```json { "code": "ok", "message": "foo", "response": 123 } ``` ## DNS Zone Get ID Endpoint * URL: `?dns_zone_get_id` * HTTP Method: `POST` - function `dns_zone_get_id`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L142 - TABLE `dns_soa`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L615 ### Request Body (JSON) ```json { "session_id": "", "origin": "" } ``` ### Response Body (JSON) ```json { "code": "ok", "message": "foo", "response": 123 } ``` ## DNS Zone Get Endpoint * URL: `?dns_zone_get` * HTTP Method: `POST` - function `dns_zone_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L87 - function `getDataRecord`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remoting_lib.inc.php#L248 - TABLE `dns_soa`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L615 - Depending on the request, the response may be an array or an object (`primary_id` can be a string, an array or an object). - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_zone_get.html - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_zone_get.php ### Request Body (JSON) ```json { "session_id": "", "primary_id": "" } ``` ### Response Body (JSON) ```json { "code": "ok", "message": "foo", "response": { "id": 456, "server_id": 123, "sys_userid": 789 } } ``` ## DNS TXT Get Endpoint * URL: `?dns_txt_get` * HTTP Method: `POST` - function `dns_txt_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L640 - function `dns_rr_get`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L195 - form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php - TABLE `dns_rr`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/install/sql/ispconfig3.sql?ref_type=heads#L490 - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_get.html - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_get.php ### Request Body (JSON) ```json { "session_id": "", "primary_id": { "name": ".", "type": "TXT" } } ``` ### Response Body (JSON) ```json { "code": "ok", "message": "foo", "response": { "id": 123 } } ``` ## DNS TXT Add Endpoint * URL: `?dns_txt_add` * HTTP Method: `POST` - function `dns_txt_add`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L645 - function `dns_rr_add` https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L212 - form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_add.html - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_add.php ### Request Body (JSON) ```json { "session_id": "", "client_id": "", "params": { "server_id": "", "zone": "", "name": ".", "type": "txt", "data": "", "aux": "0", "ttl": "3600", "active": "y", "stamp": "", "update_serial": true }, "update_serial": true } ``` - `stamp`: (ex: `2025-12-17 23:35:58`) - `serial`: (ex: `1766010947`) ### Response Body (JSON) ```json { "code": "ok", "message": "foo", "response": "123" } ``` ## DNS TXT Delete Endpoint * URL: `?dns_txt_delete` * HTTP Method: `POST` - function `dns_txt_delete`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L655 - function `dns_rr_delete`: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/lib/classes/remote.d/dns.inc.php#L247 - form: https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/interface/web/dns/form/dns_txt.tform.php - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/dns_txt_delete.html - https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/examples/dns_txt_delete.php ### Request Body (JSON) ```json { "session_id": "", "primary_id": "", "update_serial": true } ``` ### Response Body (JSON) ```json { "code": "ok", "message": "foo", "response": 1 } ``` --- https://www.ispconfig.org/ https://git.ispconfig.org/ispconfig/ispconfig3 https://forum.howtoforge.com/#ispconfig-3.23 ================================================ FILE: providers/dns/ispconfig/internal/types.go ================================================ package internal import ( "encoding/json" "strings" ) type APIError struct { APIResponse } func (e *APIError) Error() string { var msg strings.Builder msg.WriteString("code: " + e.Code) if e.Message != "" { msg.WriteString(", message: " + e.Message) } if len(e.Response) > 0 { msg.WriteString(", response: " + string(e.Response)) } return msg.String() } type APIResponse struct { Code string `json:"code"` Message string `json:"message"` Response json.RawMessage `json:"response"` } type LoginRequest struct { Username string `json:"username"` Password string `json:"password"` ClientLogin bool `json:"client_login"` } type ClientIDRequest struct { SessionID string `json:"session_id"` SysUserID string `json:"sys_userid"` } type Zone struct { ID string `json:"id"` ServerID string `json:"server_id"` SysUserID string `json:"sys_userid"` SysGroupID string `json:"sys_groupid"` Origin string `json:"origin"` Serial string `json:"serial"` Active string `json:"active"` } type GetTXTRequest struct { SessionID string `json:"session_id"` PrimaryID struct { Name string `json:"name"` Type string `json:"type"` } `json:"primary_id"` } type Record struct { ID int `json:"id"` } type AddTXTRequest struct { SessionID string `json:"session_id"` ClientID string `json:"client_id"` Params *RecordParams `json:"params,omitempty"` UpdateSerial bool `json:"update_serial"` } type RecordParams struct { ServerID string `json:"server_id"` Zone string `json:"zone"` Name string `json:"name"` // 'a','aaaa','alias','cname','hinfo','mx','naptr','ns','ds','ptr','rp','srv','txt' Type string `json:"type"` Data string `json:"data"` // "0" Aux string `json:"aux"` TTL string `json:"ttl"` // 'n','y' Active string `json:"active"` // `2025-12-17 23:35:58` Stamp string `json:"stamp"` UpdateSerial bool `json:"update_serial"` } type DeleteTXTRequest struct { SessionID string `json:"session_id"` PrimaryID string `json:"primary_id"` UpdateSerial bool `json:"update_serial"` } ================================================ FILE: providers/dns/ispconfig/ispconfig.go ================================================ // Package ispconfig implements a DNS provider for solving the DNS-01 challenge using ISPConfig. package ispconfig import ( "context" "crypto/tls" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/ispconfig/internal" ) // Environment variables names. const ( envNamespace = "ISPCONFIG_" EnvServerURL = envNamespace + "SERVER_URL" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" ) // Config is used to configure the creation of the DNSProvider. type Config struct { ServerURL string Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client InsecureSkipVerify bool } // 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 { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for ISPConfig. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("ispconfig: %w", err) } config := NewDefaultConfig() config.ServerURL = values[EnvServerURL] config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.InsecureSkipVerify = env.GetOrDefaultBool(EnvInsecureSkipVerify, false) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ISPConfig. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ispconfig: the configuration of the DNS provider is nil") } if config.ServerURL == "" { return nil, errors.New("ispconfig: missing server URL") } if config.Username == "" || config.Password == "" { return nil, errors.New("ispconfig: credentials missing") } client, err := internal.NewClient(config.ServerURL) if err != nil { return nil, fmt.Errorf("ispconfig: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } if config.InsecureSkipVerify { client.HTTPClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) sessionID, err := d.client.Login(ctx, d.config.Username, d.config.Password) if err != nil { return fmt.Errorf("ispconfig: login: %w", err) } zoneID, err := d.findZone(ctx, sessionID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("ispconfig: get zone id: %w", err) } zone, err := d.client.GetZone(ctx, sessionID, strconv.Itoa(zoneID)) if err != nil { return fmt.Errorf("ispconfig: get zone: %w", err) } clientID, err := d.client.GetClientID(ctx, sessionID, zone.SysUserID) if err != nil { return fmt.Errorf("ispconfig: get client id: %w", err) } params := internal.RecordParams{ ServerID: "serverA", Zone: zone.ID, Name: info.EffectiveFQDN, Type: "txt", Data: info.Value, Aux: "0", TTL: strconv.Itoa(d.config.TTL), Active: "y", Stamp: time.Now().UTC().Format("2006-01-02 15:04:05"), } recordID, err := d.client.AddTXT(ctx, sessionID, strconv.Itoa(clientID), params) if err != nil { return fmt.Errorf("ispconfig: add txt record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) // gets the record's unique ID d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("ispconfig: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } sessionID, err := d.client.Login(ctx, d.config.Username, d.config.Password) if err != nil { return fmt.Errorf("ispconfig: login: %w", err) } _, err = d.client.DeleteTXT(ctx, sessionID, recordID) if err != nil { return fmt.Errorf("ispconfig: delete txt record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } func (d *DNSProvider) findZone(ctx context.Context, sessionID, fqdn string) (int, error) { for domain := range dns01.UnFqdnDomainsSeq(fqdn) { zoneID, err := d.client.GetZoneID(ctx, sessionID, domain) if err == nil { return zoneID, nil } } return 0, fmt.Errorf("zone not found for %q", fqdn) } ================================================ FILE: providers/dns/ispconfig/ispconfig.toml ================================================ Name = "ISPConfig 3" Description = '''''' URL = "https://www.ispconfig.org/" Code = "ispconfig" Since = "v4.31.0" Example = ''' ISPCONFIG_SERVER_URL="https://example.com:8080/remote/json.php" \ ISPCONFIG_USERNAME="xxx" \ ISPCONFIG_PASSWORD="yyy" \ lego --dns ispconfig -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ISPCONFIG_SERVER_URL = "Server URL" ISPCONFIG_USERNAME = "Username" ISPCONFIG_PASSWORD = "Password" [Configuration.Additional] ISPCONFIG_INSECURE_SKIP_VERIFY = "Whether to verify the API certificate" ISPCONFIG_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ISPCONFIG_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ISPCONFIG_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" ISPCONFIG_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://git.ispconfig.org/ispconfig/ispconfig3/-/blob/develop/remoting_client/API-docs/index.html" ================================================ FILE: providers/dns/ispconfig/ispconfig_test.go ================================================ package ispconfig import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvServerURL, EnvUsername, EnvPassword, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvServerURL: "https://example.com:80/", EnvUsername: "user", EnvPassword: "secret", }, }, { desc: "missing server URL", envVars: map[string]string{ EnvServerURL: "", EnvUsername: "user", EnvPassword: "secret", }, expected: "ispconfig: some credentials information are missing: ISPCONFIG_SERVER_URL", }, { desc: "missing username", envVars: map[string]string{ EnvServerURL: "https://example.com:80/", EnvUsername: "", EnvPassword: "secret", }, expected: "ispconfig: some credentials information are missing: ISPCONFIG_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvServerURL: "https://example.com:80/", EnvUsername: "user", EnvPassword: "", }, expected: "ispconfig: some credentials information are missing: ISPCONFIG_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "ispconfig: some credentials information are missing: ISPCONFIG_SERVER_URL,ISPCONFIG_USERNAME,ISPCONFIG_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string serverURL string username string password string expected string }{ { desc: "success", serverURL: "https://example.com:80/", username: "user", password: "secret", }, { desc: "missing server URL", username: "user", password: "secret", expected: "ispconfig: missing server URL", }, { desc: "missing username", serverURL: "https://example.com:80/", password: "secret", expected: "ispconfig: credentials missing", }, { desc: "missing password", serverURL: "https://example.com:80/", username: "user", expected: "ispconfig: credentials missing", }, { desc: "missing credentials", expected: "ispconfig: missing server URL", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.ServerURL = test.serverURL config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func 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) } ================================================ FILE: providers/dns/ispconfigddns/internal/client.go ================================================ package internal import ( "context" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" querystring "github.com/google/go-querystring/query" ) const ( addAction = "add" deleteAction = "delete" ) type Client struct { token string serverURL string HTTPClient *http.Client } func NewClient(serverURL, token string) (*Client, error) { _, err := url.Parse(serverURL) if err != nil { return nil, fmt.Errorf("server URL: %w", err) } return &Client{ serverURL: serverURL, token: token, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddTXTRecord(ctx context.Context, zone, fqdn, content string) error { return c.updateRecord(ctx, UpdateRecord{Action: addAction, Zone: zone, Type: "TXT", Record: fqdn, Data: content}) } func (c *Client) DeleteTXTRecord(ctx context.Context, zone, fqdn, recordContent string) error { return c.updateRecord(ctx, UpdateRecord{Action: deleteAction, Zone: zone, Type: "TXT", Record: fqdn, Data: recordContent}) } func (c *Client) updateRecord(ctx context.Context, action UpdateRecord) error { req, err := c.newRequest(ctx, action) if err != nil { return err } return c.do(req) } func (c *Client) do(req *http.Request) error { useragent.SetHeader(req.Header) req.SetBasicAuth("anonymous", c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() // The endpoint uses the `DefaultDdnsResponseWriter`, // and this writer uses HTTP status code to determine if the request was successful or not. // - https://github.com/mhofer117/ispconfig-ddns-module/blob/8b011a5bb138881d9f13360a5c4fec10c0084613/lib/updater/DdnsUpdater.php#L53-L57 // - https://github.com/mhofer117/ispconfig-ddns-module/blob/master/lib/updater/response/DefaultDdnsResponseWriter.php if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return nil } func (c *Client) newRequest(ctx context.Context, action UpdateRecord) (*http.Request, error) { endpoint, err := url.Parse(c.serverURL) if err != nil { return nil, err } endpoint = endpoint.JoinPath("ddns", "update.php") values, err := querystring.Values(action) if err != nil { return nil, err } endpoint.RawQuery = values.Encode() method := http.MethodPost if action.Action == deleteAction { method = http.MethodDelete } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") return req, nil } ================================================ FILE: providers/dns/ispconfigddns/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil } func TestClient_AddTXTRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /ddns/update.php", servermock.Noop(), servermock.CheckHeader(). WithBasicAuth("anonymous", "secret"), servermock.CheckQueryParameter().Strict(). With("action", "add"). With("zone", "example.com"). With("type", "TXT"). With("record", "_acme-challenge.example.com."). With("data", "token"), ). Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token") require.NoError(t, err) } func TestClient_AddTXTRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /ddns/update.php", servermock.RawStringResponse("Missing or invalid token."). WithStatusCode(http.StatusUnauthorized), ). Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token") require.EqualError(t, err, "unexpected status code: [status code: 401] body: Missing or invalid token.") } func TestClient_DeleteTXTRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("DELETE /ddns/update.php", servermock.Noop(), servermock.CheckHeader(). WithBasicAuth("anonymous", "secret"), servermock.CheckQueryParameter().Strict(). With("action", "delete"). With("zone", "example.com"). With("type", "TXT"). With("record", "_acme-challenge.example.com."). With("data", "token"), ). Build(t) err := client.DeleteTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token") require.NoError(t, err) } func TestClient_DeleteTXTRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("DELETE /ddns/update.php", servermock.RawStringResponse("Missing or invalid token."). WithStatusCode(http.StatusUnauthorized), ). Build(t) err := client.DeleteTXTRecord(t.Context(), "example.com", "_acme-challenge.example.com.", "token") require.EqualError(t, err, "unexpected status code: [status code: 401] body: Missing or invalid token.") } ================================================ FILE: providers/dns/ispconfigddns/internal/types.go ================================================ package internal type UpdateRecord struct { Action string `url:"action,omitempty"` Zone string `url:"zone,omitempty"` Type string `url:"type,omitempty"` Record string `url:"record,omitempty"` Data string `url:"data,omitempty"` } ================================================ FILE: providers/dns/ispconfigddns/ispconfigddns.go ================================================ // Package ispconfigddns implements a DNS provider for solving the DNS-01 challenge using ISPConfig 3 Dynamic DNS (DDNS) Module. package ispconfigddns import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/ispconfigddns/internal" ) // Environment variables names. const ( envNamespace = "ISPCONFIG_DDNS_" EnvServerURL = envNamespace + "SERVER_URL" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ServerURL string Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for ISPConfig 3 Dynamic DNS (DDNS) Module. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvServerURL, EnvToken) if err != nil { return nil, fmt.Errorf("ispconfig (DDNS module): %w", err) } config := NewDefaultConfig() config.ServerURL = values[EnvServerURL] config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ISPConfig 3 Dynamic DNS (DDNS) Module. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ispconfig (DDNS module): the configuration of the DNS provider is nil") } if config.ServerURL == "" { return nil, errors.New("ispconfig (DDNS module): missing server URL") } if config.Token == "" { return nil, errors.New("ispconfig (DDNS module): missing token") } client, err := internal.NewClient(config.ServerURL, config.Token) if err != nil { return nil, fmt.Errorf("ispconfig (DDNS module): %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to control checking compliance to spec. 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 { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ispconfig (DDNS module): could not find zone for domain %q: %w", domain, err) } err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(zone), info.EffectiveFQDN, info.Value) if err != nil { return fmt.Errorf("ispconfig (DDNS module): add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ispconfig (DDNS module): could not find zone for domain %q: %w", domain, err) } err = d.client.DeleteTXTRecord(context.Background(), dns01.UnFqdn(zone), info.EffectiveFQDN, info.Value) if err != nil { return fmt.Errorf("ispconfig (DDNS module): delete record: %w", err) } return nil } ================================================ FILE: providers/dns/ispconfigddns/ispconfigddns.toml ================================================ Name = "ISPConfig 3 - Dynamic DNS (DDNS) Module" Description = '''''' URL = "https://www.ispconfig.org/" Code = "ispconfigddns" Since = "v4.31.0" Example = ''' ISPCONFIG_DDNS_SERVER_URL="https://panel.example.com:8080" \ ISPCONFIG_DDNS_TOKEN=xxxxxx \ lego --dns ispconfigddns -d '*.example.com' -d example.com run ''' Additional = ''' ISPConfig DNS provider supports leveraging the [ISPConfig 3 Dynamic DNS (DDNS) Module](https://github.com/mhofer117/ispconfig-ddns-module). Requires the DDNS module described at https://www.ispconfig.org/ispconfig/download/ See https://www.howtoforge.com/community/threads/ispconfig-3-danymic-dns-ddns-module.87967/ for additional details. ''' [Configuration] [Configuration.Credentials] ISPCONFIG_DDNS_SERVER_URL = "API server URL (ex: https://panel.example.com:8080)" ISPCONFIG_DDNS_TOKEN = "DDNS API token" [Configuration.Additional] ISPCONFIG_DDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ISPCONFIG_DDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ISPCONFIG_DDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" ISPCONFIG_DDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://github.com/mhofer117/ispconfig-ddns-module/tree/master/lib/updater" ================================================ FILE: providers/dns/ispconfigddns/ispconfigddns_test.go ================================================ package ispconfigddns import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvServerURL, EnvToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvServerURL: "https://example.com", EnvToken: "secret", }, }, { desc: "missing server URL", envVars: map[string]string{ EnvServerURL: "", EnvToken: "secret", }, expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_SERVER_URL", }, { desc: "missing token", envVars: map[string]string{ EnvServerURL: "https://example.com", EnvToken: "", }, expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_TOKEN", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "ispconfig (DDNS module): some credentials information are missing: ISPCONFIG_DDNS_SERVER_URL,ISPCONFIG_DDNS_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string serverURL string token string expected string }{ { desc: "success", serverURL: "https://example.com", token: "secret", }, { desc: "missing server URL", serverURL: "", token: "secret", expected: "ispconfig (DDNS module): missing server URL", }, { desc: "missing token", serverURL: "https://example.com", token: "", expected: "ispconfig (DDNS module): missing token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.ServerURL = test.serverURL config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.Token = "secret" config.ServerURL = server.URL return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithBasicAuth("anonymous", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /ddns/update.php", servermock.DumpRequest(), servermock.CheckQueryParameter().Strict(). With("action", "add"). With("zone", "example.com"). With("type", "TXT"). With("record", "_acme-challenge.example.com."). With("data", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /ddns/update.php", servermock.DumpRequest(), servermock.CheckQueryParameter().Strict(). With("action", "delete"). With("zone", "example.com"). With("type", "TXT"). With("record", "_acme-challenge.example.com."). With("data", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"), ). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/iwantmyname/iwantmyname.go ================================================ // Package iwantmyname implements a DNS provider for solving the DNS-01 challenge using iwantmyname. package iwantmyname import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/config/env" ) // Environment variables names. const ( envNamespace = "IWANTMYNAME_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{} } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config } // NewDNSProvider returns a DNSProvider instance configured for iwantmyname. // Credentials must be passed in the environment variables: IWANTMYNAME_USERNAME, IWANTMYNAME_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("iwantmyname: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for iwantmyname. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("iwantmyname: the iwantmyname API has shut down https://github.com/go-acme/lego/issues/2563") } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, _, keyAuth string) error { return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { return nil } ================================================ FILE: providers/dns/iwantmyname/iwantmyname.toml ================================================ Name = "iwantmyname (Deprecated)" Description = ''' The iwantmyname API has shut down. https://github.com/go-acme/lego/issues/2563 ''' URL = "https://iwantmyname.com" Code = "iwantmyname" Since = "v4.7.0" Example = ''' IWANTMYNAME_USERNAME=xxxxxxxx \ IWANTMYNAME_PASSWORD=xxxxxxxx \ lego --dns iwantmyname -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] IWANTMYNAME_USERNAME = "API username" IWANTMYNAME_PASSWORD = "API password" [Configuration.Additional] IWANTMYNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" IWANTMYNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" IWANTMYNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" IWANTMYNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://iwantmyname.com/developer/domain-dns-api" ================================================ FILE: providers/dns/jdcloud/fixtures/create_record-request.json ================================================ { "domainId": "20", "regionId": "cn-north-1", "req": { "hostRecord": "_acme-challenge", "hostValue": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "jcloudRes": null, "mxPriority": null, "port": null, "ttl": 120, "type": "TXT", "viewValue": -1, "weight": null } } ================================================ FILE: providers/dns/jdcloud/fixtures/create_record.json ================================================ { "requestId": "azerty", "error": { "code": 0, "status": "", "message": "" }, "result": { "dataList": { "id": 123, "hostRecord": "_acme-challenge", "hostValue": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "jcloudRes": false, "mxPriority": 0, "port": 0, "ttl": 120, "type": "TXT", "weight": 0, "viewValue": [ 1, 2 ] } } } ================================================ FILE: providers/dns/jdcloud/fixtures/delete_record.json ================================================ { "requestId": "azerty", "error": { "code": 0, "status": "", "message": "" }, "result": {} } ================================================ FILE: providers/dns/jdcloud/fixtures/describe_domains_page1.json ================================================ { "requestId": "azerty", "error": { "code": 0, "status": "", "message": "" }, "result": { "dataList": [ { "id": 1, "domainName": "1.example" }, { "id": 2, "domainName": "2.example" }, { "id": 3, "domainName": "3.example" }, { "id": 4, "domainName": "4.example" }, { "id": 5, "domainName": "5.example" }, { "id": 6, "domainName": "6.example" }, { "id": 7, "domainName": "7.example" }, { "id": 8, "domainName": "8.example" }, { "id": 9, "domainName": "9.example" }, { "id": 10, "domainName": "10.example" } ], "currentCount": 10, "totalCount": 20, "totalPage": 2 } } ================================================ FILE: providers/dns/jdcloud/fixtures/describe_domains_page2.json ================================================ { "requestId": "azerty", "error": { "code": 0, "status": "", "message": "" }, "result": { "dataList": [ { "id": 11, "domainName": "11.example" }, { "id": 12, "domainName": "12.example" }, { "id": 13, "domainName": "13.example" }, { "id": 14, "domainName": "14.example" }, { "id": 15, "domainName": "15.example" }, { "id": 16, "domainName": "16.example" }, { "id": 17, "domainName": "17.example" }, { "id": 18, "domainName": "18.example" }, { "id": 19, "domainName": "19.example" }, { "id": 20, "domainName": "example.com" } ], "currentCount": 10, "totalCount": 20, "totalPage": 2 } } ================================================ FILE: providers/dns/jdcloud/jdcloud.go ================================================ // Package jdcloud implements a DNS provider for solving the DNS-01 challenge using JD Cloud. package jdcloud import ( "errors" "fmt" "strconv" "sync" "time" "github.com/go-acme/jdcloud-sdk-go/core" "github.com/go-acme/jdcloud-sdk-go/services/domainservice/apis" jdcclient "github.com/go-acme/jdcloud-sdk-go/services/domainservice/client" domainservice "github.com/go-acme/jdcloud-sdk-go/services/domainservice/models" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" ) // Environment variables names. const ( envNamespace = "JDCLOUD_" EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" EnvAccessKeySecret = envNamespace + "ACCESS_KEY_SECRET" EnvRegionID = envNamespace + "REGION_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 { AccessKeyID string AccessKeySecret string RegionID string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout time.Duration } // 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), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *jdcclient.DomainserviceClient recordIDs map[string]int domainIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for JD Cloud. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccessKeyID, EnvAccessKeySecret) if err != nil { return nil, fmt.Errorf("jdcloud: %w", err) } config := NewDefaultConfig() config.AccessKeyID = values[EnvAccessKeyID] config.AccessKeySecret = values[EnvAccessKeySecret] // https://docs.jdcloud.com/en/common-declaration/api/introduction#Region%20Code config.RegionID = env.GetOrDefaultString(EnvRegionID, "cn-north-1") return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for JD Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("jdcloud: the configuration of the DNS provider is nil") } if config.AccessKeyID == "" || config.AccessKeySecret == "" { return nil, errors.New("jdcloud: missing credentials") } cred := core.NewCredentials(config.AccessKeyID, config.AccessKeySecret) client := jdcclient.NewDomainserviceClient(cred) client.DisableLogger() client.Config.SetTimeout(config.HTTPTimeout) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), domainIDs: make(map[string]int), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("jdcloud: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("jdcloud: %w", err) } zone, err := d.findZone(dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("jdcloud: %w", err) } // https://docs.jdcloud.com/cn/jd-cloud-dns/api/createresourcerecord crrr := apis.NewCreateResourceRecordRequestWithAllParams( d.config.RegionID, strconv.Itoa(zone.Id), &domainservice.AddRR{ HostRecord: subDomain, HostValue: info.Value, Ttl: d.config.TTL, Type: "TXT", ViewValue: -1, }, ) record, err := jdcclient.CreateResourceRecord(d.client, crrr) if err != nil { return fmt.Errorf("jdcloud: create resource record: %w", err) } d.recordIDsMu.Lock() d.domainIDs[token] = zone.Id d.recordIDs[token] = record.Result.DataList.Id d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() recordID, recordOK := d.recordIDs[token] domainID, domainOK := d.domainIDs[token] d.recordIDsMu.Unlock() if !recordOK { return fmt.Errorf("jdcloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } if !domainOK { return fmt.Errorf("jdcloud: unknown domain ID for '%s' '%s'", info.EffectiveFQDN, token) } // https://docs.jdcloud.com/cn/jd-cloud-dns/api/deleteresourcerecord drrr := apis.NewDeleteResourceRecordRequestWithAllParams( d.config.RegionID, strconv.Itoa(domainID), strconv.Itoa(recordID), ) _, err := jdcclient.DeleteResourceRecord(d.client, drrr) if err != nil { return fmt.Errorf("jdcloud: delete resource record: %w", err) } return 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 } func (d *DNSProvider) findZone(zone string) (*domainservice.DomainInfo, error) { // https://docs.jdcloud.com/cn/jd-cloud-dns/api/describedomains ddr := apis.NewDescribeDomainsRequestWithoutParam() ddr.SetRegionId(d.config.RegionID) ddr.SetPageNumber(1) ddr.SetPageSize(10) ddr.SetDomainName(zone) for { response, err := jdcclient.DescribeDomains(d.client, ddr) if err != nil { return nil, fmt.Errorf("describe domains: %w", err) } for _, d := range response.Result.DataList { if d.DomainName == zone { return &d, nil } } if len(response.Result.DataList) < ddr.PageSize || response.Result.TotalPage <= ddr.PageNumber { break } ddr.SetPageNumber(ddr.PageNumber + 1) } return nil, errors.New("zone not found") } ================================================ FILE: providers/dns/jdcloud/jdcloud.toml ================================================ Name = "JD Cloud" Description = '''''' URL = "https://www.jdcloud.com/" Code = "jdcloud" Since = "v4.31.0" Example = ''' JDCLOUD_ACCESS_KEY_ID="xxx" \ JDCLOUD_ACCESS_KEY_SECRET="yyy" \ lego --dns jdcloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] JDCLOUD_ACCESS_KEY_ID = "Access key ID" JDCLOUD_ACCESS_KEY_SECRET = "Access key secret" [Configuration.Additional] JDCLOUD_REGION_ID = "Region ID (Default: cn-north-1)" JDCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" JDCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" JDCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" JDCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.jdcloud.com/cn/jd-cloud-dns/api/overview" Common = "https://docs.jdcloud.com/en/common-declaration/api/introduction" GoClient = "https://github.com/jdcloud-api/jdcloud-sdk-go" ================================================ FILE: providers/dns/jdcloud/jdcloud_test.go ================================================ package jdcloud import ( "fmt" "net" "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAccessKeyID, EnvAccessKeySecret, EnvRegionID, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessKeyID: "abc123", EnvAccessKeySecret: "secret", }, }, { desc: "missing access key ID", envVars: map[string]string{ EnvAccessKeyID: "", EnvAccessKeySecret: "secret", }, expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_ID", }, { desc: "missing access key secret", envVars: map[string]string{ EnvAccessKeyID: "abc123", EnvAccessKeySecret: "", }, expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_SECRET", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "jdcloud: some credentials information are missing: JDCLOUD_ACCESS_KEY_ID,JDCLOUD_ACCESS_KEY_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accessKeyID string accessKeySecret string expected string }{ { desc: "success", accessKeyID: "abc123", accessKeySecret: "secret", }, { desc: "missing access key ID", accessKeySecret: "secret", expected: "jdcloud: missing credentials", }, { desc: "missing access key secret", accessKeyID: "abc123", expected: "jdcloud: missing credentials", }, { desc: "missing credentials", expected: "jdcloud: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessKeyID = test.accessKeyID config.AccessKeySecret = test.accessKeySecret config.RegionID = "cn-north-1" p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.AccessKeyID = "abc123" config.AccessKeySecret = "secret" config.RegionID = "cn-north-1" p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } serverURL, _ := url.Parse(server.URL) p.client.Config.SetEndpoint(net.JoinHostPort(serverURL.Hostname(), serverURL.Port())) p.client.Config.SetScheme(serverURL.Scheme) p.client.Config.SetTimeout(server.Client().Timeout) return p, nil }, ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /v2/regions/cn-north-1/domain", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { pageNumber := req.URL.Query().Get("pageNumber") servermock.ResponseFromFixture( fmt.Sprintf("describe_domains_page%s.json", pageNumber), ).ServeHTTP(rw, req) }), servermock.CheckQueryParameter().Strict(). With("domainName", "example.com"). WithRegexp("pageNumber", `(1|2)`). With("pageSize", "10"), servermock.CheckHeader(). WithRegexp("Authorization", `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), ). Route("POST /v2/regions/cn-north-1/domain/20/ResourceRecord", servermock.ResponseFromFixture("create_record.json"), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json"), servermock.CheckHeader(). WithRegexp("Authorization", `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) require.Len(t, provider.domainIDs, 1) require.Len(t, provider.recordIDs, 1) assert.Equal(t, 20, provider.domainIDs["abc"]) assert.Equal(t, 123, provider.recordIDs["abc"]) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /v2/regions/cn-north-1/domain/20/ResourceRecord/123", servermock.ResponseFromFixture("delete_record.json"), servermock.CheckHeader(). WithRegexp("Authorization", `JDCLOUD2-HMAC-SHA256 Credential=abc123/\d{8}/cn-north-1/domainservice/jdcloud2_request, SignedHeaders=content-type;host;x-jdcloud-date;x-jdcloud-nonce, Signature=\w+`). WithRegexp("X-Jdcloud-Date", `\d{8}T\d{6}Z`). WithRegexp("X-Jdcloud-Nonce", `[\w-]+`), ). Build(t) provider.domainIDs["abc"] = 20 provider.recordIDs["abc"] = 123 err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/joker/internal/dmapi/client.go ================================================ // Package dmapi Client for DMAPI joker.com. // https://joker.com/faq/category/39/22-dmapi.html package dmapi import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://dmapi.joker.com/request/" // Response Joker DMAPI Response. type Response struct { Headers url.Values Body string StatusCode int StatusText string AuthSid string } type AuthInfo struct { APIKey string Username string Password string } // Client a DMAPI Client. type Client struct { apiKey string username string password string token *Token muToken sync.Mutex Debug bool BaseURL string HTTPClient *http.Client } // NewClient creates a new DMAPI Client. func NewClient(authInfo AuthInfo) *Client { return &Client{ apiKey: authInfo.APIKey, username: authInfo.Username, password: authInfo.Password, BaseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetZone returns content of DNS zone for domain. func (c *Client) GetZone(ctx context.Context, domain string) (*Response, error) { if getSessionID(ctx) == "" { return nil, errors.New("must be logged in to get zone") } return c.postRequest(ctx, "dns-zone-get", url.Values{"domain": {dns01.UnFqdn(domain)}}) } // PutZone uploads DNS zone to Joker DMAPI. func (c *Client) PutZone(ctx context.Context, domain, zone string) (*Response, error) { if getSessionID(ctx) == "" { return nil, errors.New("must be logged in to put zone") } return c.postRequest(ctx, "dns-zone-put", url.Values{"domain": {dns01.UnFqdn(domain)}, "zone": {strings.TrimSpace(zone)}}) } // postRequest performs actual HTTP request. func (c *Client) postRequest(ctx context.Context, cmd string, data url.Values) (*Response, error) { endpoint, err := url.JoinPath(c.BaseURL, cmd) if err != nil { return nil, err } if getSessionID(ctx) != "" { data.Set("auth-sid", getSessionID(ctx)) } if c.Debug { log.Infof("postRequest:\n\tURL: %q\n\tData: %v", endpoint, data) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } return parseResponse(string(raw)), nil } // parseResponse parses HTTP response body. func parseResponse(message string) *Response { r := &Response{Headers: url.Values{}, StatusCode: -1} lines, body, _ := strings.Cut(message, "\n\n") for line := range strings.Lines(lines) { if strings.TrimSpace(line) == "" { continue } k, v, _ := strings.Cut(line, ":") val := strings.TrimSpace(v) r.Headers.Add(k, val) switch k { case "Status-Code": i, err := strconv.Atoi(val) if err == nil { r.StatusCode = i } case "Status-Text": r.StatusText = val case "Auth-Sid": r.AuthSid = val } } r.Body = body return r } // Temporary workaround, until it get fixed on API side. func fixTxtLines(line string) string { fields := strings.Fields(line) if len(fields) < 6 || fields[1] != "TXT" { return line } if fields[3][0] == '"' && fields[4] == `"` { fields[3] = strings.TrimSpace(fields[3]) + `"` fields = append(fields[:4], fields[5:]...) } return strings.Join(fields, " ") } // RemoveTxtEntryFromZone clean-ups all TXT records with given name. func RemoveTxtEntryFromZone(zone, relative string) (string, bool) { prefix := fmt.Sprintf("%s TXT 0 ", relative) modified := false var zoneEntries []string for line := range strings.Lines(zone) { if strings.HasPrefix(line, prefix) { modified = true continue } zoneEntries = append(zoneEntries, line) } return strings.TrimSpace(strings.Join(zoneEntries, "\n")), modified } // AddTxtEntryToZone returns DNS zone with added TXT record. func AddTxtEntryToZone(zone, relative, value string, ttl int) string { var zoneEntries []string for line := range strings.Lines(zone) { zoneEntries = append(zoneEntries, fixTxtLines(line)) } newZoneEntry := fmt.Sprintf("%s TXT 0 %q %d", relative, value, ttl) zoneEntries = append(zoneEntries, newZoneEntry) return strings.TrimSpace(strings.Join(zoneEntries, "\n")) } ================================================ FILE: providers/dns/joker/internal/dmapi/client_test.go ================================================ package dmapi import ( "io" "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( correctAPIKey = "123" incorrectAPIKey = "321" serverErrorAPIKey = "500" ) const ( correctUsername = "lego" incorrectUsername = "not_lego" serverErrorUsername = "error" ) func mockBuilder(auth AuthInfo) *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(auth) client.BaseURL = server.URL client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func TestClient_GetZone(t *testing.T) { testZone := "@ A 0 192.0.2.2 3600" testCases := []struct { desc string authSid string domain string zone string expectedError bool expectedStatusCode int }{ { desc: "correct auth-sid, known domain", authSid: correctAPIKey, domain: "known", zone: testZone, expectedStatusCode: 0, }, { desc: "incorrect auth-sid, known domain", authSid: incorrectAPIKey, domain: "known", expectedStatusCode: 2202, }, { desc: "correct auth-sid, unknown domain", authSid: correctAPIKey, domain: "unknown", expectedStatusCode: 2202, }, { desc: "server error", authSid: "500", expectedError: true, }, } client := mockBuilder(AuthInfo{APIKey: "12345"}). Route("POST /dns-zone-get", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { authSid := req.FormValue("auth-sid") domain := req.FormValue("domain") switch { case authSid == correctAPIKey && domain == "known": _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone) case authSid == incorrectAPIKey || (authSid == correctAPIKey && domain == "unknown"): _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: Authorization error") default: http.NotFound(rw, req) } })). Build(t) for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { response, err := client.GetZone(mockContext(t, test.authSid), test.domain) if test.expectedError { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, response) assert.Equal(t, test.expectedStatusCode, response.StatusCode) assert.Equal(t, test.zone, response.Body) } }) } } func Test_parseResponse(t *testing.T) { testCases := []struct { desc string input string expected *Response }{ { desc: "Empty response", input: "", expected: &Response{ Headers: url.Values{}, StatusCode: -1, }, }, { desc: "No headers, just body", input: "\n\nTest body", expected: &Response{ Headers: url.Values{}, Body: "Test body", StatusCode: -1, }, }, { desc: "Headers and body", input: "Test-Header: value\n\nTest body", expected: &Response{ Headers: url.Values{"Test-Header": {"value"}}, Body: "Test body", StatusCode: -1, }, }, { desc: "Headers and body + Auth-Sid", input: "Test-Header: value\nAuth-Sid: 123\n\nTest body", expected: &Response{ Headers: url.Values{"Test-Header": {"value"}, "Auth-Sid": {"123"}}, Body: "Test body", StatusCode: -1, AuthSid: "123", }, }, { desc: "Headers and body + Status-Text", input: "Test-Header: value\nStatus-Text: OK\n\nTest body", expected: &Response{ Headers: url.Values{"Test-Header": {"value"}, "Status-Text": {"OK"}}, Body: "Test body", StatusText: "OK", StatusCode: -1, }, }, { desc: "Headers and body + Status-Code", input: "Test-Header: value\nStatus-Code: 2020\n\nTest body", expected: &Response{ Headers: url.Values{"Test-Header": {"value"}, "Status-Code": {"2020"}}, Body: "Test body", StatusCode: 2020, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() response := parseResponse(test.input) assert.Equal(t, test.expected, response) }) } } func Test_RemoveTxtEntryFromZone(t *testing.T) { testCases := []struct { desc string input string expected string modified bool }{ { desc: "empty zone", input: "", expected: "", modified: false, }, { desc: "zone with only A entry", input: "@ A 0 192.0.2.2 3600", expected: "@ A 0 192.0.2.2 3600", modified: false, }, { desc: "zone with only clenup entry", input: "_acme-challenge TXT 0 \"old \" 120", expected: "", modified: true, }, { desc: "zone with one A and one cleanup entries", input: "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0 \"old \" 120", expected: "@ A 0 192.0.2.2 3600", modified: true, }, { desc: "zone with one A and multiple cleanup entries", input: "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0 \"old \" 120\n_acme-challenge TXT 0 \"another \" 120", expected: "@ A 0 192.0.2.2 3600", modified: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() zone, modified := RemoveTxtEntryFromZone(test.input, "_acme-challenge") assert.Equal(t, test.expected, zone) assert.Equal(t, test.modified, modified) }) } } func Test_AddTxtEntryToZone(t *testing.T) { testCases := []struct { desc string input string expected string }{ { desc: "empty zone", input: "", expected: "_acme-challenge TXT 0 \"test\" 120", }, { desc: "zone with A entry", input: "@ A 0 192.0.2.2 3600", expected: "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0 \"test\" 120", }, { desc: "zone with required cleanup entry", input: "_acme-challenge TXT 0 \"old \" 120", expected: "_acme-challenge TXT 0 \"old\" 120\n_acme-challenge TXT 0 \"test\" 120", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { zone := AddTxtEntryToZone(test.input, "_acme-challenge", "test", 120) assert.Equal(t, test.expected, zone) }) } } func Test_fixTxtLines(t *testing.T) { testCases := []struct { desc string input string expected string }{ { desc: "clean-up", input: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE " 120`, expected: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`, }, { desc: "already cleaned", input: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`, expected: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`, }, { desc: "special DNS entry", input: "$dyndns=yes:username:password", expected: "$dyndns=yes:username:password", }, { desc: "SRV entry", input: "_jabber._tcp SRV 20/0 xmpp-server1.l.google.com:5269 300", expected: "_jabber._tcp SRV 20/0 xmpp-server1.l.google.com:5269 300", }, { desc: "MX entry", input: "@ MX 10 ASPMX.L.GOOGLE.COM 300", expected: "@ MX 10 ASPMX.L.GOOGLE.COM 300", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { line := fixTxtLines(test.input) assert.Equal(t, test.expected, line) }) } } ================================================ FILE: providers/dns/joker/internal/dmapi/identity.go ================================================ package dmapi import ( "context" "errors" "fmt" "net/url" "time" ) type token string const sessionIDKey token = "session-id" // Token session ID. // > Every request (except "login") requires the presence of the Auth-Sid variable ("Session ID"), // > which is returned by the "login" request (login). An active session will expire after some inactivity period (default: 1 hour). // https://joker.com/faq/content/22/12/en/commonalities-for-all-requests.html type Token struct { SessionID string ExpireAt time.Time } // login performs a log in to Joker's DMAPI. func (c *Client) login(ctx context.Context) (*Response, error) { var values url.Values switch { case c.username != "" && c.password != "": values = url.Values{ "username": {c.username}, "password": {c.password}, } case c.apiKey != "": values = url.Values{"api-key": {c.apiKey}} default: return nil, errors.New("no username and password or api-key") } response, err := c.postRequest(ctx, "login", values) if err != nil { return response, err } if response == nil { return nil, errors.New("login returned nil response") } if response.AuthSid == "" { return response, errors.New("login did not return valid Auth-Sid") } return response, nil } // Logout closes authenticated session with Joker's DMAPI. func (c *Client) Logout(ctx context.Context) (*Response, error) { if c.token == nil { return nil, errors.New("already logged out") } response, err := c.postRequest(ctx, "logout", url.Values{}) c.muToken.Lock() c.token = nil c.muToken.Unlock() if err != nil { return response, err } return response, nil } func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { c.muToken.Lock() defer c.muToken.Unlock() if c.token != nil && time.Now().UTC().Before(c.token.ExpireAt) { return context.WithValue(ctx, sessionIDKey, c.token.SessionID), nil } response, err := c.login(ctx) if err != nil { return nil, formatResponseError(response, err) } c.token = &Token{ SessionID: response.AuthSid, ExpireAt: time.Now().UTC().Add(1 * time.Hour), } return context.WithValue(ctx, sessionIDKey, response.AuthSid), nil } func getSessionID(ctx context.Context) string { tok, ok := ctx.Value(sessionIDKey).(string) if !ok { return "" } return tok } // formatResponseError formats error with optional details from DMAPI response. func formatResponseError(response *Response, err error) error { if response != nil { return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers) } return fmt.Errorf("joker: DMAPI error: %w", err) } ================================================ FILE: providers/dns/joker/internal/dmapi/identity_test.go ================================================ package dmapi import ( "context" "fmt" "io" "net/http" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockContext(t *testing.T, sessionID string) context.Context { t.Helper() if sessionID == "" { sessionID = "xxx" } return context.WithValue(t.Context(), sessionIDKey, sessionID) } func TestClient_login_apikey(t *testing.T) { testCases := []struct { desc string apiKey string expectedError bool expectedStatusCode int expectedAuthSid string }{ { desc: "correct key", apiKey: correctAPIKey, expectedStatusCode: 0, expectedAuthSid: correctAPIKey, }, { desc: "incorrect key", apiKey: incorrectAPIKey, expectedStatusCode: 2200, expectedError: true, }, { desc: "server error", apiKey: serverErrorAPIKey, expectedStatusCode: -500, expectedError: true, }, { desc: "non-ok status code", apiKey: "333", expectedStatusCode: 2202, expectedError: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(AuthInfo{APIKey: test.apiKey}). Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { switch req.FormValue("api-key") { case correctAPIKey: _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") case incorrectAPIKey: _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") case serverErrorAPIKey: http.NotFound(rw, req) default: _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") } })). Build(t) response, err := client.login(t.Context()) if test.expectedError { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, response) assert.Equal(t, test.expectedStatusCode, response.StatusCode) assert.Equal(t, test.expectedAuthSid, response.AuthSid) } }) } } func TestClient_login_username(t *testing.T) { testCases := []struct { desc string username string password string expectedError bool expectedStatusCode int expectedAuthSid string }{ { desc: "correct username and password", username: correctUsername, password: "go-acme", expectedError: false, expectedStatusCode: 0, expectedAuthSid: correctAPIKey, }, { desc: "incorrect username", username: incorrectUsername, password: "go-acme", expectedStatusCode: 2200, expectedError: true, }, { desc: "server error", username: serverErrorUsername, password: "go-acme", expectedStatusCode: -500, expectedError: true, }, { desc: "non-ok status code", username: "random", password: "go-acme", expectedStatusCode: 2202, expectedError: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(AuthInfo{Username: test.username, Password: test.password}). Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { switch req.FormValue("username") { case correctUsername: _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") case incorrectUsername: _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") case serverErrorUsername: http.NotFound(rw, req) default: _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") } })). Build(t) response, err := client.login(t.Context()) if test.expectedError { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, response) assert.Equal(t, test.expectedStatusCode, response.StatusCode) assert.Equal(t, test.expectedAuthSid, response.AuthSid) } }) } } func TestClient_logout(t *testing.T) { testCases := []struct { desc string authSid string expectedError bool expectedStatusCode int }{ { desc: "correct auth-sid", authSid: correctAPIKey, expectedStatusCode: 0, }, { desc: "incorrect auth-sid", authSid: incorrectAPIKey, expectedStatusCode: 2200, }, { desc: "already logged out", authSid: "", expectedError: true, }, { desc: "server error", authSid: serverErrorAPIKey, expectedError: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(AuthInfo{APIKey: "12345"}). Route("POST /logout", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { switch req.FormValue("auth-sid") { case correctAPIKey: _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\n") case incorrectAPIKey: _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") default: http.NotFound(rw, req) } })). Build(t) client.token = &Token{SessionID: test.authSid} response, err := client.Logout(mockContext(t, test.authSid)) if test.expectedError { require.Error(t, err) } else { require.NoError(t, err) require.NotNil(t, response) assert.Equal(t, test.expectedStatusCode, response.StatusCode) } }) } } func TestClient_CreateAuthenticatedContext(t *testing.T) { id := atomic.Int32{} id.Add(100) client := mockBuilder(AuthInfo{Username: correctUsername, Password: "secret"}). Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { switch req.FormValue("username") { case correctUsername: _, _ = fmt.Fprintf(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: %d\n\ncom\nnet", id.Load()) id.Add(100) default: _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") } })). Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) assert.Equal(t, "100", getSessionID(ctx)) // the token is not expired then we use the "cache". client.muToken.Lock() client.token.SessionID = "cache" client.muToken.Unlock() ctx, err = client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) assert.Equal(t, "cache", getSessionID(ctx)) // force the expiration of the token client.muToken.Lock() client.token.ExpireAt = time.Now().UTC().Add(-1 * time.Hour) client.muToken.Unlock() ctx, err = client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) assert.Equal(t, "200", getSessionID(ctx)) } ================================================ FILE: providers/dns/joker/internal/svc/client.go ================================================ // Package svc Client for the SVC API. // https://joker.com/faq/content/6/496/en/let_s-encrypt-support.html package svc import ( "context" "fmt" "io" "net/http" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://svc.joker.com/nic/replace" type request struct { Username string `url:"username"` Password string `url:"password"` Zone string `url:"zone"` Label string `url:"label"` Type string `url:"type"` Value string `url:"value"` } type Client struct { username string password string BaseURL string HTTPClient *http.Client } func NewClient(username, password string) *Client { return &Client{ username: username, password: password, BaseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) SendRequest(ctx context.Context, zone, label, value string) error { payload := request{ Username: c.username, Password: c.password, Zone: zone, Label: label, Type: "TXT", Value: value, } v, err := querystring.Values(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL, strings.NewReader(v.Encode())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if resp.StatusCode == http.StatusOK && strings.HasPrefix(string(raw), "OK") { return nil } return fmt.Errorf("error: %d: %s", resp.StatusCode, string(raw)) } ================================================ FILE: providers/dns/joker/internal/svc/client_test.go ================================================ package svc import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("test", "secret") client.BaseURL = server.URL client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func TestClient_Send(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.RawStringResponse("OK: 1 inserted, 0 deleted"), servermock.CheckForm().Strict(). With("zone", "example.com"). With("label", "_acme-challenge"). With("type", "TXT"). With("value", "123"). With("username", "test"). With("password", "secret"), ). Build(t) zone := "example.com" label := "_acme-challenge" value := "123" err := client.SendRequest(t.Context(), zone, label, value) require.NoError(t, err) } func TestClient_Send_empty(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.RawStringResponse("OK: 1 inserted, 0 deleted"), servermock.CheckForm().Strict(). With("zone", "example.com"). With("label", "_acme-challenge"). With("type", "TXT"). With("value", ""). With("username", "test"). With("password", "secret"), ). Build(t) zone := "example.com" label := "_acme-challenge" value := "" err := client.SendRequest(t.Context(), zone, label, value) require.NoError(t, err) } ================================================ FILE: providers/dns/joker/joker.go ================================================ // Package joker implements a DNS provider for solving the DNS-01 challenge using joker.com. package joker import ( "net/http" "os" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" ) // Environment variables names. const ( envNamespace = "JOKER_" EnvAPIKey = envNamespace + "API_KEY" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvDebug = envNamespace + "DEBUG" EnvMode = envNamespace + "API_MODE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const ( modeDMAPI = "DMAPI" modeSVC = "SVC" ) // Config is used to configure the creation of the DNSProvider. type Config struct { Debug bool APIKey string Username string Password string APIMode string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ APIMode: env.GetOrDefaultString(EnvMode, modeDMAPI), Debug: env.GetOrDefaultBool(EnvDebug, false), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second), }, } } // NewDNSProvider returns a DNSProvider instance configured for Joker. // Credentials must be passed in the environment variable JOKER_API_KEY. func NewDNSProvider() (challenge.ProviderTimeout, error) { if os.Getenv(EnvMode) == modeSVC { return newSvcProvider() } return newDmapiProvider() } // NewDNSProviderConfig return a DNSProvider instance configured for Joker. func NewDNSProviderConfig(config *Config) (challenge.ProviderTimeout, error) { if config.APIMode == modeSVC { return newSvcProviderConfig(config) } return newDmapiProviderConfig(config) } ================================================ FILE: providers/dns/joker/joker.toml ================================================ Name = "Joker" Description = '''''' URL = "https://joker.com" Code = "joker" Since = "v2.6.0" Example = ''' # SVC JOKER_API_MODE=SVC \ JOKER_USERNAME= \ JOKER_PASSWORD= \ lego --dns joker -d '*.example.com' -d example.com run # DMAPI JOKER_API_MODE=DMAPI \ JOKER_USERNAME= \ JOKER_PASSWORD= \ lego --dns joker -d '*.example.com' -d example.com run ## or JOKER_API_MODE=DMAPI \ JOKER_API_KEY= \ lego --dns joker -d '*.example.com' -d example.com run ''' Additional = ''' ## SVC mode In the SVC mode, username and passsword are not your email and account passwords, but those displayed in Joker.com domain dashboard when enabling Dynamic DNS. As per [Joker.com documentation](https://joker.com/faq/content/6/496/en/let_s-encrypt-support.html): > 1. please log in at Joker.com, visit 'My Domains', > find the domain you want to add Let's Encrypt certificate for, and chose "DNS" in the menu > > 2. on the top right, you will find the setting for 'Dynamic DNS'. > If not already active, please activate it. > It will not affect any other already existing DNS records of this domain. > > 3. please take a note of the credentials which are now shown as 'Dynamic DNS Authentication', consisting of a 'username' and a 'password'. > > 4. this is all you have to do here - and only once per domain. ''' [Configuration] [Configuration.Credentials] JOKER_API_MODE = "'DMAPI' or 'SVC'. DMAPI is for resellers accounts. (Default: DMAPI)" JOKER_USERNAME = "Joker.com username" JOKER_PASSWORD = "Joker.com password" JOKER_API_KEY = "API key (only with DMAPI mode)" [Configuration.Additional] JOKER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" JOKER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" JOKER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" JOKER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" JOKER_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60), only with 'SVC' mode" [Links] API = "https://joker.com/faq/category/39/22-dmapi.html" API_SVC = "https://joker.com/faq/content/6/496/en/let_s-encrypt-support.html" ================================================ FILE: providers/dns/joker/joker_test.go ================================================ package joker import ( "fmt" "os" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvUsername, EnvPassword, EnvMode). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected any }{ { desc: "mode DMAPI (default)", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "123", }, expected: &dmapiProvider{}, }, { desc: "mode DMAPI", envVars: map[string]string{ EnvMode: modeDMAPI, EnvUsername: "123", EnvPassword: "123", }, expected: &dmapiProvider{}, }, { desc: "mode SVC", envVars: map[string]string{ EnvMode: modeSVC, EnvUsername: "123", EnvPassword: "123", }, expected: &svcProvider{}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) fmt.Println(os.Getenv(EnvMode)) p, err := NewDNSProvider() require.NoError(t, err) require.NotNil(t, p) assert.IsType(t, test.expected, p) }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string mode string expected any }{ { desc: "mode DMAPI (default)", expected: &dmapiProvider{}, }, { desc: "mode DMAPI", mode: modeDMAPI, expected: &dmapiProvider{}, }, { desc: "mode SVC", mode: modeSVC, expected: &svcProvider{}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = "123" config.Password = "123" config.APIMode = test.mode p, err := NewDNSProviderConfig(config) require.NoError(t, err) require.NotNil(t, p) assert.IsType(t, test.expected, p) }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/joker/provider_dmapi.go ================================================ package joker import ( "context" "errors" "fmt" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/joker/internal/dmapi" ) var _ challenge.ProviderTimeout = (*dmapiProvider)(nil) // dmapiProvider implements the challenge.Provider interface. type dmapiProvider struct { config *Config client *dmapi.Client } // newDmapiProvider returns a DNSProvider instance configured for Joker. // Credentials must be passed in the environment variable: JOKER_USERNAME, JOKER_PASSWORD or JOKER_API_KEY. func newDmapiProvider() (*dmapiProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { var errU error values, errU = env.Get(EnvUsername, EnvPassword) if errU != nil { //nolint:errorlint // false-positive return nil, fmt.Errorf("joker: %v or %v", errU, err) } } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.Username = values[EnvUsername] config.Password = values[EnvPassword] return newDmapiProviderConfig(config) } // newDmapiProviderConfig return a DNSProvider instance configured for Joker. func newDmapiProviderConfig(config *Config) (*dmapiProvider, error) { if config == nil { return nil, errors.New("joker: the configuration of the DNS provider is nil") } if config.APIKey == "" { if config.Username == "" || config.Password == "" { return nil, errors.New("joker: credentials missing") } } client := dmapi.NewClient(dmapi.AuthInfo{ APIKey: config.APIKey, Username: config.Username, Password: config.Password, }) client.Debug = config.Debug if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &dmapiProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *dmapiProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *dmapiProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("joker: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("joker: %w", err) } if d.config.Debug { log.Infof("[%s] joker: adding TXT record %q to zone %q with value %q", domain, subDomain, zone, info.Value) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return err } response, err := d.client.GetZone(ctx, zone) if err != nil || response.StatusCode != 0 { return formatResponseError(response, err) } dnsZone := dmapi.AddTxtEntryToZone(response.Body, subDomain, info.Value, d.config.TTL) response, err = d.client.PutZone(ctx, zone, dnsZone) if err != nil || response.StatusCode != 0 { return formatResponseError(response, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *dmapiProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("joker: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("joker: %w", err) } if d.config.Debug { log.Infof("[%s] joker: removing entry %q from zone %q", domain, subDomain, zone) } ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return err } defer func() { // Try to log out in case of errors _, _ = d.client.Logout(ctx) }() response, err := d.client.GetZone(ctx, zone) if err != nil || response.StatusCode != 0 { return formatResponseError(response, err) } dnsZone, modified := dmapi.RemoveTxtEntryFromZone(response.Body, subDomain) if modified { response, err = d.client.PutZone(ctx, zone, dnsZone) if err != nil || response.StatusCode != 0 { return formatResponseError(response, err) } } response, err = d.client.Logout(ctx) if err != nil { return formatResponseError(response, err) } return nil } // formatResponseError formats error with optional details from DMAPI response. func formatResponseError(response *dmapi.Response, err error) error { if response != nil { return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers) } return fmt.Errorf("joker: DMAPI error: %w", err) } ================================================ FILE: providers/dns/joker/provider_dmapi_test.go ================================================ package joker import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_newDmapiProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success API key", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "success username password", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvUsername: "", EnvPassword: "", }, expected: "joker: some credentials information are missing: JOKER_USERNAME,JOKER_PASSWORD or some credentials information are missing: JOKER_API_KEY", }, { desc: "missing password", envVars: map[string]string{ EnvAPIKey: "", EnvUsername: "123", EnvPassword: "", }, expected: "joker: some credentials information are missing: JOKER_PASSWORD or some credentials information are missing: JOKER_API_KEY", }, { desc: "missing username", envVars: map[string]string{ EnvAPIKey: "", EnvUsername: "", EnvPassword: "123", }, expected: "joker: some credentials information are missing: JOKER_USERNAME or some credentials information are missing: JOKER_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := newDmapiProvider() if test.expected != "" { require.EqualError(t, err, test.expected) } else { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) } }) } } func Test_newDmapiProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string username string password string expected string }{ { desc: "success api key", apiKey: "123", }, { desc: "success username and password", username: "123", password: "123", }, { desc: "missing credentials", expected: "joker: credentials missing", }, { desc: "missing credentials: username", expected: "joker: credentials missing", username: "123", }, { desc: "missing credentials: password", expected: "joker: credentials missing", password: "123", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Username = test.username config.Password = test.password p, err := newDmapiProviderConfig(config) if test.expected != "" { require.EqualError(t, err, test.expected) } else { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) } }) } } ================================================ FILE: providers/dns/joker/provider_svc.go ================================================ package joker import ( "context" "errors" "fmt" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/joker/internal/svc" ) var _ challenge.ProviderTimeout = (*svcProvider)(nil) // svcProvider implements the challenge.Provider interface. type svcProvider struct { config *Config client *svc.Client } // newSvcProvider returns a DNSProvider instance configured for Joker. // Credentials must be passed in the environment variable: JOKER_USERNAME, JOKER_PASSWORD. func newSvcProvider() (*svcProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("joker: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return newSvcProviderConfig(config) } // newSvcProviderConfig return a DNSProvider instance configured for Joker. func newSvcProviderConfig(config *Config) (*svcProvider, error) { if config == nil { return nil, errors.New("joker: the configuration of the DNS provider is nil") } if config.Username == "" || config.Password == "" { return nil, errors.New("joker: credentials missing") } client := svc.NewClient(config.Username, config.Password) client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &svcProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *svcProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *svcProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("joker: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("joker: %w", err) } return d.client.SendRequest(context.Background(), dns01.UnFqdn(zone), subDomain, info.Value) } // CleanUp removes the TXT record matching the specified parameters. func (d *svcProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("joker: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("joker: %w", err) } return d.client.SendRequest(context.Background(), dns01.UnFqdn(zone), subDomain, "") } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *svcProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/joker/provider_svc_test.go ================================================ package joker import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_newSvcProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success username password", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvUsername: "", EnvPassword: "", }, expected: "joker: some credentials information are missing: JOKER_USERNAME,JOKER_PASSWORD", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "", }, expected: "joker: some credentials information are missing: JOKER_PASSWORD", }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "123", }, expected: "joker: some credentials information are missing: JOKER_USERNAME", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := newSvcProvider() if test.expected != "" { require.EqualError(t, err, test.expected) } else { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) } }) } } func Test_newSvcProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success username and password", username: "123", password: "123", }, { desc: "missing credentials", expected: "joker: credentials missing", }, { desc: "missing credentials: username", expected: "joker: credentials missing", username: "123", }, { desc: "missing credentials: password", expected: "joker: credentials missing", password: "123", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := newSvcProviderConfig(config) if test.expected != "" { require.EqualError(t, err, test.expected) } else { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) } }) } } ================================================ FILE: providers/dns/keyhelp/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // APIKeyHeader API key header. const APIKeyHeader = "X-Api-Key" // Client the KeyHelp API client. type Client struct { apiKey string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(baseURL, apiKey string) (*Client, error) { if baseURL == "" { return nil, errors.New("missing base URL") } if apiKey == "" { return nil, errors.New("credentials missing") } base, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("parse base URL: %w", err) } return &Client{ apiKey: apiKey, baseURL: base.JoinPath("api", "v2"), HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(APIKeyHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { endpoint := c.baseURL.JoinPath("domains") query := endpoint.Query() query.Set("sort", "domain_utf8") endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []Domain err = c.do(req, &result) if err != nil { return nil, err } return result, nil } func (c *Client) ListDomainRecords(ctx context.Context, domainID int) (*DomainRecords, error) { endpoint := c.baseURL.JoinPath("dns", strconv.Itoa(domainID)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result DomainRecords err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } func (c *Client) UpdateDomainRecords(ctx context.Context, domainID int, records DomainRecords) (*DomainID, error) { endpoint := c.baseURL.JoinPath("dns", strconv.Itoa(domainID)) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, records) if err != nil { return nil, err } var result DomainID err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/keyhelp/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). With(APIKeyHeader, "secret"). WithJSONHeaders(), ) } func TestClient_ListDomains(t *testing.T) { client := mockBuilder(). Route("GET /api/v2/domains", servermock.ResponseFromFixture("get_domains.json"), servermock.CheckQueryParameter(). With("sort", "domain_utf8"). Strict()). Build(t) domains, err := client.ListDomains(t.Context()) require.NoError(t, err) expected := []Domain{{ ID: 8, UserID: 4, ParentDomainID: 0, Status: 1, Domain: "example.com", DomainUTF8: "example.com", IsEmailDomain: true, }} assert.Equal(t, expected, domains) } func TestClient_ListDomains_error(t *testing.T) { client := mockBuilder(). Route("GET /api/v2/domains", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.ListDomains(t.Context()) require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.") } func TestClient_ListDomainRecords(t *testing.T) { client := mockBuilder(). Route("GET /api/v2/dns/123", servermock.ResponseFromFixture("get_domain_records.json")). Build(t) domainRecords, err := client.ListDomainRecords(t.Context(), 123) require.NoError(t, err) expected := &DomainRecords{ DkimRecord: `default._domainkey IN TXT ( "v=DKIM1; k=rsa; s=email; " "...DKIM KEY..." )`, Records: &Records{ Soa: &SOARecord{ TTL: 86400, PrimaryNs: "ns.example.com.", RName: "root.example.com.", Refresh: 14400, Retry: 1800, Expire: 604800, Minimum: 3600, }, Other: []Record{{ Host: "@", TTL: 86400, Type: "A", Value: "192.168.178.1", }}, }, } assert.Equal(t, expected, domainRecords) } func TestClient_ListDomainRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /api/v2/dns/8", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.ListDomainRecords(t.Context(), 8) require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.") } func TestClient_UpdateDomainRecords(t *testing.T) { client := mockBuilder(). Route("PUT /api/v2/dns/8", servermock.ResponseFromFixture("update_domain_records.json"), servermock.CheckRequestJSONBodyFromFixture("update_domain_records-request.json")). Build(t) records := DomainRecords{ DkimRecord: `default._domainkey IN TXT ( "v=DKIM1; k=rsa; s=email; " "...DKIM KEY..." )`, Records: &Records{ Soa: &SOARecord{ TTL: 86400, PrimaryNs: "ns.example.com.", RName: "root.example.com.", Refresh: 14400, Retry: 1800, Expire: 604800, Minimum: 3600, }, Other: []Record{ { Host: "@", TTL: 86400, Type: "A", Value: "192.168.178.1", }, { Host: "_acme-challenge", TTL: 120, Type: "TXT", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", }, }, }, } domainID, err := client.UpdateDomainRecords(t.Context(), 8, records) require.NoError(t, err) expected := &DomainID{ID: 8} assert.Equal(t, expected, domainID) } func TestClient_UpdateDomainRecords_error(t *testing.T) { client := mockBuilder(). Route("PUT /api/v2/dns/123", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) records := DomainRecords{} _, err := client.UpdateDomainRecords(t.Context(), 123, records) require.EqualError(t, err, "401 Unauthorized: API key is missing or invalid.") } ================================================ FILE: providers/dns/keyhelp/internal/fixtures/error.json ================================================ { "code": "401 Unauthorized", "message": "API key is missing or invalid." } ================================================ FILE: providers/dns/keyhelp/internal/fixtures/get_domain_records.json ================================================ { "is_custom_dns": false, "is_dns_disabled": false, "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", "records": { "soa": { "ttl": 86400, "primary_ns": "ns.example.com.", "rname": "root.example.com.", "refresh": 14400, "retry": 1800, "expire": 604800, "minimum": 3600 }, "other": [ { "host": "@", "ttl": 86400, "type": "A", "value": "192.168.178.1" } ] } } ================================================ FILE: providers/dns/keyhelp/internal/fixtures/get_domain_records2.json ================================================ { "is_custom_dns": false, "is_dns_disabled": false, "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", "records": { "soa": { "ttl": 86400, "primary_ns": "ns.example.com.", "rname": "root.example.com.", "refresh": 14400, "retry": 1800, "expire": 604800, "minimum": 3600 }, "other": [ { "host": "@", "ttl": 86400, "type": "A", "value": "192.168.178.1" }, { "host": "_acme-challenge", "ttl": 120, "type": "TXT", "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" } ] } } ================================================ FILE: providers/dns/keyhelp/internal/fixtures/get_domains.json ================================================ [ { "id": 8, "id_user": 4, "id_parent_domain": 0, "status": 1, "domain": "example.com", "domain_utf8": "example.com", "created_at": "2019-08-15T11:29:13+02:00", "php_version": "", "traffic": 32434624, "is_disabled": false, "delete_on": "2025-09-02T19:31:14+0000", "dkim_selector": "default", "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", "is_custom_dns": false, "is_dns_disabled": false, "is_subdomain": false, "is_system_domain": false, "is_email_domain": true, "is_email_sending_only": false, "target": { "target": "https://www.keyhelp.de", "is_forwarding": true, "forwarding_type": 301 }, "security": { "id_certificate": 0, "lets_encrypt": true, "is_prefer_https": true, "is_hsts": true, "hsts_max_age": 10368000, "hsts_include": true, "hsts_preload": true }, "apache": { "http_directives": "# My custom HTTP directives", "https_directives": "# My custom HTTPS directives" } } ] ================================================ FILE: providers/dns/keyhelp/internal/fixtures/update_domain_records-request.json ================================================ { "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", "records": { "soa": { "ttl": 86400, "primary_ns": "ns.example.com.", "rname": "root.example.com.", "refresh": 14400, "retry": 1800, "expire": 604800, "minimum": 3600 }, "other": [ { "host": "@", "ttl": 86400, "type": "A", "value": "192.168.178.1" }, { "host": "_acme-challenge", "ttl": 120, "type": "TXT", "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" } ] } } ================================================ FILE: providers/dns/keyhelp/internal/fixtures/update_domain_records-request2.json ================================================ { "dkim_record": "default._domainkey IN TXT ( \"v=DKIM1; k=rsa; s=email; \" \"...DKIM KEY...\" )", "records": { "soa": { "ttl": 86400, "primary_ns": "ns.example.com.", "rname": "root.example.com.", "refresh": 14400, "retry": 1800, "expire": 604800, "minimum": 3600 }, "other": [ { "host": "@", "ttl": 86400, "type": "A", "value": "192.168.178.1" } ] } } ================================================ FILE: providers/dns/keyhelp/internal/fixtures/update_domain_records.json ================================================ { "id": 8 } ================================================ FILE: providers/dns/keyhelp/internal/types.go ================================================ package internal import ( "fmt" ) type APIError struct { Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` } func (a *APIError) Error() string { return fmt.Sprintf("%s: %s", a.Code, a.Message) } type Domain struct { ID int `json:"id,omitempty"` UserID int `json:"id_user,omitempty"` ParentDomainID int `json:"id_parent_domain,omitempty"` Status int `json:"status,omitempty"` Domain string `json:"domain,omitempty"` DomainUTF8 string `json:"domain_utf8,omitempty"` IsDisabled bool `json:"is_disabled,omitempty"` IsCustomDNS bool `json:"is_custom_dns,omitempty"` IsDNSDisabled bool `json:"is_dns_disabled,omitempty"` IsSubdomain bool `json:"is_subdomain,omitempty"` IsSystemDomain bool `json:"is_system_domain,omitempty"` IsEmailDomain bool `json:"is_email_domain,omitempty"` IsEmailSendingOnly bool `json:"is_email_sending_only,omitempty"` } type DomainID struct { ID int `json:"id,omitempty"` } type DomainRecords struct { IsCustomDNS bool `json:"is_custom_dns,omitempty"` IsDNSDisabled bool `json:"is_dns_disabled,omitempty"` DkimRecord string `json:"dkim_record,omitempty"` Records *Records `json:"records,omitempty"` } type Records struct { Soa *SOARecord `json:"soa,omitempty"` Other []Record `json:"other,omitempty"` } type SOARecord struct { TTL int `json:"ttl,omitempty"` PrimaryNs string `json:"primary_ns,omitempty"` RName string `json:"rname,omitempty"` Refresh int `json:"refresh,omitempty"` Retry int `json:"retry,omitempty"` Expire int `json:"expire,omitempty"` Minimum int `json:"minimum,omitempty"` } type Record struct { Host string `json:"host"` TTL int `json:"ttl"` Type string `json:"type"` Value string `json:"value"` } ================================================ FILE: providers/dns/keyhelp/keyhelp.go ================================================ // Package keyhelp implements a DNS provider for solving the DNS-01 challenge using KeyHelp. package keyhelp import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/keyhelp/internal" ) // Environment variables names. const ( envNamespace = "KEYHELP_" EnvBaseURL = envNamespace + "BASE_URL" EnvAPIKey = envNamespace + "API_KEY" 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 { BaseURL string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client domainIDs map[string]int domainIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for KeyHelp. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvBaseURL, EnvAPIKey) if err != nil { return nil, fmt.Errorf("keyhelp: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvBaseURL] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for KeyHelp. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("keyhelp: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.BaseURL, config.APIKey) if err != nil { return nil, fmt.Errorf("keyhelp: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, domainIDs: make(map[string]int), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("keyhelp: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() domainInfo, err := d.findDomain(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("keyhelp: %w", err) } domainRecords, err := d.client.ListDomainRecords(ctx, domainInfo.ID) if err != nil { return fmt.Errorf("keyhelp: list domain records: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("keyhelp: %w", err) } records := domainRecords.Records.Other records = append(records, internal.Record{ Host: subDomain, TTL: d.config.TTL, Type: "TXT", Value: info.Value, }) req := internal.DomainRecords{ DkimRecord: domainRecords.DkimRecord, Records: &internal.Records{ Soa: domainRecords.Records.Soa, Other: records, }, } _, err = d.client.UpdateDomainRecords(ctx, domainInfo.ID, req) if err != nil { return fmt.Errorf("keyhelp: update domain records (add): %w", err) } d.domainIDsMu.Lock() d.domainIDs[token] = domainInfo.ID d.domainIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) // get the domain's unique ID from when we created it d.domainIDsMu.Lock() domainID, ok := d.domainIDs[token] d.domainIDsMu.Unlock() if !ok { return fmt.Errorf("keyhelp: unknown record ID for '%s'", info.EffectiveFQDN) } domainRecords, err := d.client.ListDomainRecords(ctx, domainID) if err != nil { return fmt.Errorf("keyhelp: list domain records: %w", err) } var records []internal.Record for _, record := range domainRecords.Records.Other { if record.Type == "TXT" && record.Value == info.Value { continue } records = append(records, record) } req := internal.DomainRecords{ DkimRecord: domainRecords.DkimRecord, Records: &internal.Records{ Soa: domainRecords.Records.Soa, Other: records, }, } _, err = d.client.UpdateDomainRecords(ctx, domainID, req) if err != nil { return fmt.Errorf("keyhelp: update domain records (delete): %w", err) } // Delete domain ID from map d.domainIDsMu.Lock() delete(d.domainIDs, token) d.domainIDsMu.Unlock() return 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 } func (d *DNSProvider) findDomain(ctx context.Context, zone string) (internal.Domain, error) { domains, err := d.client.ListDomains(ctx) if err != nil { return internal.Domain{}, fmt.Errorf("list domains: %w", err) } for _, domain := range domains { if domain.DomainUTF8 == zone || domain.Domain == zone { return domain, nil } } return internal.Domain{}, fmt.Errorf("domain not found: %s", zone) } ================================================ FILE: providers/dns/keyhelp/keyhelp.toml ================================================ Name = "KeyHelp" Description = '''''' URL = "https://www.keyweb.de/en/keyhelp/keyhelp/" Code = "keyhelp" Since = "v4.26.0" Example = ''' KEYHELP_BASE_URL="https://keyhelp.example.com" \ KEYHELP_API_KEY="xxx" \ lego --dns keyhelp -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] KEYHELP_BASE_URL= "Server URL" KEYHELP_API_KEY = "API key" [Configuration.Additional] KEYHELP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" KEYHELP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" KEYHELP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" KEYHELP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://app.swaggerhub.com/apis-docs/keyhelp/api/" ================================================ FILE: providers/dns/keyhelp/keyhelp_test.go ================================================ package keyhelp import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/keyhelp/internal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvBaseURL, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvBaseURL: "https://keyhelp.example.com", EnvAPIKey: "secret", }, }, { desc: "missing base URL", envVars: map[string]string{ EnvAPIKey: "secret", }, expected: "keyhelp: some credentials information are missing: KEYHELP_BASE_URL", }, { desc: "missing API key", envVars: map[string]string{ EnvBaseURL: "https://keyhelp.example.com", }, expected: "keyhelp: some credentials information are missing: KEYHELP_API_KEY", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "keyhelp: some credentials information are missing: KEYHELP_BASE_URL,KEYHELP_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string baseURL string apiKey string expected string }{ { desc: "success", baseURL: "https://keyhelp.example.com", apiKey: "secret", }, { desc: "missing base URL", apiKey: "secret", expected: "keyhelp: missing base URL", }, { desc: "missing API key", baseURL: "https://keyhelp.example.com", expected: "keyhelp: credentials missing", }, { desc: "missing credentials", expected: "keyhelp: missing base URL", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.BaseURL = test.baseURL config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.APIKey = "secret" config.BaseURL = server.URL return NewDNSProviderConfig(config) }, servermock.CheckHeader(). With(internal.APIKeyHeader, "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /api/v2/domains", servermock.ResponseFromInternal("get_domains.json"), servermock.CheckQueryParameter(). With("sort", "domain_utf8"). Strict()). Route("GET /api/v2/dns/8", servermock.ResponseFromInternal("get_domain_records.json")). Route("PUT /api/v2/dns/8", servermock.ResponseFromInternal("update_domain_records.json"), servermock.CheckRequestJSONBodyFromInternal("update_domain_records-request.json")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) assert.Equal(t, 8, provider.domainIDs["abc"]) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /api/v2/dns/8", servermock.ResponseFromInternal("get_domain_records2.json")). Route("PUT /api/v2/dns/8", servermock.ResponseFromInternal("update_domain_records.json"), servermock.CheckRequestJSONBodyFromInternal("update_domain_records-request2.json")). Build(t) provider.domainIDs["abc"] = 8 err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/leaseweb/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://api.leaseweb.com/hosting/v2" const AuthHeader = "X-LSW-Auth" // Client the Leaseweb API client. type Client struct { apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey string) (*Client, error) { if apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // CreateRRSet creates a resource record set. // https://developer.leaseweb.com/docs/#tag/DNS/operation/createResourceRecordSet func (c *Client) CreateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) { endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, rrset) if err != nil { return nil, err } result := &RRSet{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } // GetRRSet gets a resource record set. // https://developer.leaseweb.com/docs/#tag/DNS/operation/getResourceRecordSet func (c *Client) GetRRSet(ctx context.Context, domainName, name, rType string) (*RRSet, error) { endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := &RRSet{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } // UpdateRRSet updates a resource record set. // https://developer.leaseweb.com/docs/#tag/DNS/operation/updateResourceRecordSet func (c *Client) UpdateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) { endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", rrset.Name, rrset.Type) // Reset values that are not allowed to be updated. rrset.Name = "" rrset.Type = "" rrset.Editable = false req, err := newJSONRequest(ctx, http.MethodPut, endpoint, rrset) if err != nil { return nil, err } result := &RRSet{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } // DeleteRRSet deletes a resource record set. // https://developer.leaseweb.com/docs/#tag/DNS/operation/deleteResourceRecordSet func (c *Client) DeleteRRSet(ctx context.Context, domainName, name, rType string) error { endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) req.Header.Add(AuthHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { if resp.StatusCode == http.StatusNotFound { return &NotFoundError{APIError{ CorrelationID: resp.Header.Get("Correlation-Id"), ErrorCode: strconv.Itoa(http.StatusNotFound), ErrorMessage: string(raw), }} } return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if errAPI.ErrorCode == strconv.Itoa(http.StatusNotFound) { return &NotFoundError{APIError: errAPI} } return &errAPI } // TTLRounder rounds the given TTL in seconds to the next accepted value. // Accepted TTL values are: 60, 300, 1800, 3600, 14400, 28800, 43200, 86400. func TTLRounder(ttl int) int { for _, validTTL := range []int{60, 300, 1800, 3600, 14400, 28800, 43200, 86400} { if ttl <= validTTL { return validTTL } } return 3600 } ================================================ FILE: providers/dns/leaseweb/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). With(AuthHeader, "secret"), ) } func TestClient_CreateRRSet(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/resourceRecordSets", servermock.ResponseFromFixture("createResourceRecordSet.json"), servermock.CheckRequestJSONBodyFromFixture("createResourceRecordSet-request.json"), ). Build(t) rrset := RRSet{ Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, Name: "_acme-challenge.example.com.", TTL: 300, Type: "TXT", } result, err := client.CreateRRSet(t.Context(), "example.com", rrset) require.NoError(t, err) expected := &RRSet{ Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, Name: "_acme-challenge.example.com.", Editable: true, TTL: 300, Type: "TXT", } assert.Equal(t, expected, result) } func TestClient_GetRRSet(t *testing.T) { client := mockBuilder(). Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromFixture("getResourceRecordSet.json"), ). Build(t) result, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") require.NoError(t, err) expected := &RRSet{ Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"}, Name: "_acme-challenge.example.com.", Editable: true, TTL: 3600, Type: "TXT", } assert.Equal(t, expected, result) } func TestClient_GetRRSet_error_404(t *testing.T) { client := mockBuilder(). Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromFixture("error_404.json"). WithStatusCode(http.StatusNotFound), ). Build(t) _, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") require.EqualError(t, err, "404: Resource not found (289346a1-3eaf-4da4-b707-62ef12eb08be)") target := &NotFoundError{} require.ErrorAs(t, err, &target) } func TestClient_UpdateRRSet(t *testing.T) { client := mockBuilder(). Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromFixture("updateResourceRecordSet.json"), servermock.CheckRequestJSONBodyFromFixture("updateResourceRecordSet-request.json"), ). Build(t) rrset := RRSet{ Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, Name: "_acme-challenge.example.com.", TTL: 3600, Type: "TXT", } result, err := client.UpdateRRSet(t.Context(), "example.com", rrset) require.NoError(t, err) expected := &RRSet{ Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, Name: "_acme-challenge.example.com.", Editable: true, TTL: 3600, Type: "TXT", } assert.Equal(t, expected, result) } func TestClient_DeleteRRSet(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.Noop(). WithStatusCode(http.StatusNoContent), ). Build(t) err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") require.NoError(t, err) } func TestClient_DeleteRRSet_error(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromFixture("error_401.json"). WithStatusCode(http.StatusUnauthorized), ). Build(t) err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") require.EqualError(t, err, "401: You are not authorized to view this resource. (289346a1-3eaf-4da4-b707-62ef12eb08be)") } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json ================================================ { "content": [ "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" ], "name": "_acme-challenge.example.com.", "ttl": 300, "type": "TXT" } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json ================================================ { "_links": { "self": { "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" }, "collection": { "href": "/domains/example.com/resourceRecordSets" } }, "content": [ "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" ], "editable": true, "name": "_acme-challenge.example.com.", "ttl": 300, "type": "TXT" } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/error_400.json ================================================ { "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", "errorCode": "400", "errorDetails": {}, "errorMessage": "The API could not interpret your request correctly." } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/error_401.json ================================================ { "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", "errorCode": "401", "errorMessage": "You are not authorized to view this resource." } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/error_404.json ================================================ { "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", "errorCode": "404", "errorMessage": "Resource not found" } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json ================================================ { "_links": { "self": { "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" }, "collection": { "href": "/domains/example.com/resourceRecordSets" } }, "content": [ "foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" ], "editable": true, "name": "_acme-challenge.example.com.", "ttl": 3600, "type": "TXT" } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json ================================================ { "_links": { "self": { "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" }, "collection": { "href": "/domains/example.com/resourceRecordSets" } }, "content": [ "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" ], "editable": true, "name": "_acme-challenge.example.com.", "ttl": 3600, "type": "TXT" } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json ================================================ { "content": [ "foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" ], "ttl": 3600 } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json ================================================ { "content": [ "foo" ], "ttl": 3600 } ================================================ FILE: providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json ================================================ { "_links": { "self": { "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" }, "collection": { "href": "/domains/example.com/resourceRecordSets" } }, "content": [ "foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" ], "editable": true, "name": "_acme-challenge.example.com.", "ttl": 3600, "type": "TXT" } ================================================ FILE: providers/dns/leaseweb/internal/types.go ================================================ package internal import ( "encoding/json" "fmt" ) type NotFoundError struct { APIError } type APIError struct { CorrelationID string `json:"correlationId,omitempty"` ErrorCode string `json:"errorCode,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` ErrorDetails json.RawMessage `json:"errorDetails,omitempty"` } func (a *APIError) Error() string { msg := fmt.Sprintf("%s: %s (%s)", a.ErrorCode, a.ErrorMessage, a.CorrelationID) if len(a.ErrorDetails) > 0 { msg += fmt.Sprintf(": %s", string(a.ErrorDetails)) } return msg } type RRSet struct { Content []string `json:"content,omitempty"` Name string `json:"name,omitempty"` Editable bool `json:"editable,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` } ================================================ FILE: providers/dns/leaseweb/leaseweb.go ================================================ // Package leaseweb implements a DNS provider for solving the DNS-01 challenge using Leaseweb. package leaseweb import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal" ) // Environment variables names. const ( envNamespace = "LEASEWEB_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Leaseweb. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("leaseweb: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Leaseweb. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("leaseweb: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIKey) if err != nil { return nil, fmt.Errorf("leaseweb: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err) } existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") if err != nil { notfoundErr := &internal.NotFoundError{} if !errors.As(err, ¬foundErr) { return fmt.Errorf("leaseweb: get RRSet: %w", err) } // Create the RRSet. rrset := internal.RRSet{ Content: []string{info.Value}, Name: info.EffectiveFQDN, TTL: internal.TTLRounder(d.config.TTL), Type: "TXT", } _, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), rrset) if err != nil { return fmt.Errorf("leaseweb: create RRSet: %w", err) } return nil } // Update the RRSet. existingRRSet.Content = append(existingRRSet.Content, info.Value) _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet) if err != nil { return fmt.Errorf("leaseweb: update RRSet: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err) } existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") if err != nil { return fmt.Errorf("leaseweb: get RRSet: %w", err) } var content []string for _, s := range existingRRSet.Content { if s != info.Value { content = append(content, s) } } if len(content) == 0 { err = d.client.DeleteRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") if err != nil { return fmt.Errorf("leaseweb: delete RRSet: %w", err) } return nil } existingRRSet.Content = content _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet) if err != nil { return fmt.Errorf("leaseweb: update RRSet: %w", err) } return 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 } ================================================ FILE: providers/dns/leaseweb/leaseweb.toml ================================================ Name = "Leaseweb" Description = '''''' URL = "https://www.leaseweb.com/en/" Code = "leaseweb" Since = "v4.32.0" Example = ''' LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns leaseweb -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LEASEWEB_API_KEY = "API key" [Configuration.Additional] LEASEWEB_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" LEASEWEB_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" LEASEWEB_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" LEASEWEB_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.leaseweb.com/docs/#tag/DNS" ================================================ FILE: providers/dns/leaseweb/leaseweb_test.go ================================================ package leaseweb import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "leaseweb: some credentials information are missing: LEASEWEB_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "secret", }, { desc: "missing credentials", expected: "leaseweb: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). With(internal.AuthHeader, "secret"), ) } func TestDNSProvider_Present_create(t *testing.T) { provider := mockBuilder(). Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromInternal("error_404.json"). WithStatusCode(http.StatusNotFound), ). Route("POST /domains/example.com/resourceRecordSets", servermock.ResponseFromInternal("createResourceRecordSet.json"), servermock.CheckRequestJSONBodyFromInternal("createResourceRecordSet-request.json"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_Present_update(t *testing.T) { provider := mockBuilder(). Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromInternal("getResourceRecordSet.json"), ). Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromInternal("updateResourceRecordSet.json"), servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request.json"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp_delete(t *testing.T) { provider := mockBuilder(). Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromInternal("getResourceRecordSet2.json"), ). Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.Noop(). WithStatusCode(http.StatusNoContent), ). Build(t) err := provider.CleanUp("example.com", "abc", "1234d==") require.NoError(t, err) } func TestDNSProvider_CleanUp_update(t *testing.T) { provider := mockBuilder(). Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromInternal("getResourceRecordSet.json"), ). Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", servermock.ResponseFromInternal("updateResourceRecordSet.json"), servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request2.json"), ). Build(t) err := provider.CleanUp("example.com", "abc", "1234d==") require.NoError(t, err) } ================================================ FILE: providers/dns/liara/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) const defaultBaseURL = "https://dns-service.iran.liara.ir" // Client a Liara DNS API client. type Client struct { baseURL *url.URL httpClient *http.Client teamID string } // NewClient creates a new Client. func NewClient(hc *http.Client, teamID string) *Client { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } return &Client{ httpClient: hc, baseURL: baseURL, teamID: teamID, } } // GetRecords gets the records of a domain. // https://openapi.liara.ir/?urls.primaryName=DNS func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records") req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var response Response[[]Record] err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return response.Data, nil } // CreateRecord creates a record. func (c *Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records") req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, fmt.Errorf("create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { return nil, parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var response Response[*Record] err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return response.Data, nil } // GetRecord gets a specific record. func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID) req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var response Response[*Record] err = json.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return response.Data, nil } // DeleteRecord deletes a record. func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string) error { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID) req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return fmt.Errorf("create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { return parseError(req, resp) } return nil } func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { if c.teamID != "" { query := endpoint.Query() query.Set("teamID", c.teamID) endpoint.RawQuery = query.Encode() } buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, &errAPI) } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/liara/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "key" func mockBuilder(teamID string) *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey), teamID) client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer "+apiKey)) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(""). Route("GET /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordsResponse.json")). Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ { ID: "string", Type: "string", Name: "string", Contents: []Content{ { Text: "string", }, }, TTL: 3600, }, } assert.Equal(t, expected, records) } func TestClient_GetRecord(t *testing.T) { client := mockBuilder(""). Route("GET /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("RecordResponse.json")). Build(t) record, err := client.GetRecord(t.Context(), "example.com", "123") require.NoError(t, err) expected := &Record{ ID: "string", Type: "string", Name: "string", Contents: []Content{ { Text: "string", }, }, TTL: 3600, } assert.Equal(t, expected, record) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(""). Route("POST /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordResponse.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`)). Build(t) data := Record{ Type: "string", Name: "string", Contents: []Content{ { Text: "string", }, }, TTL: 3600, } record, err := client.CreateRecord(t.Context(), "example.com", data) require.NoError(t, err) expected := &Record{ ID: "string", Type: "string", Name: "string", Contents: []Content{ { Text: "string", }, }, TTL: 3600, } assert.Equal(t, expected, record) } func TestClient_CreateRecord_withTeamID(t *testing.T) { client := mockBuilder("123"). Route("POST /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordResponse.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`), servermock.CheckQueryParameter().Strict().With("teamID", "123"), ). Build(t) data := Record{ Type: "string", Name: "string", Contents: []Content{ { Text: "string", }, }, TTL: 3600, } record, err := client.CreateRecord(t.Context(), "example.com", data) require.NoError(t, err) expected := &Record{ ID: "string", Type: "string", Name: "string", Contents: []Content{ { Text: "string", }, }, TTL: 3600, } assert.Equal(t, expected, record) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(""). Route("DELETE /api/v1/zones/example.com/dns-records/123", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "123") require.NoError(t, err) } func TestClient_DeleteRecord_NotFound_Response(t *testing.T) { client := mockBuilder(""). Route("DELETE /api/v1/zones/example.com/dns-records/123", servermock.Noop(). WithStatusCode(http.StatusNotFound)). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "123") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(""). Route("DELETE /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "123") require.EqualError(t, err, "[status code: 401] Unauthorized: Invalid token missing header") } ================================================ FILE: providers/dns/liara/internal/fixtures/RecordResponse.json ================================================ { "status": "string", "data": { "id": "string", "name": "string", "type": "string", "ttl": 3600, "contents": [{ "text": "string" }] } } ================================================ FILE: providers/dns/liara/internal/fixtures/RecordsResponse.json ================================================ { "status": "string", "data": [ { "id": "string", "name": "string", "type": "string", "ttl": 3600, "contents": [{ "text": "string" }] } ] } ================================================ FILE: providers/dns/liara/internal/fixtures/error.json ================================================ { "statusCode": 401, "error": "Unauthorized", "message": "Invalid token missing header" } ================================================ FILE: providers/dns/liara/internal/types.go ================================================ package internal import "fmt" type Content struct { Text string `json:"text,omitempty"` } type Record struct { ID string `json:"id,omitempty"` Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl"` Contents []Content `json:"contents"` } type Response[D any] struct { Status string `json:"status"` Data D `json:"data"` } type APIError struct { StatusCode int `json:"statusCode"` ErrorCode string `json:"error"` ErrorMessage string `json:"message"` } func (a APIError) Error() string { return fmt.Sprintf("%s: %s", a.ErrorCode, a.ErrorMessage) } ================================================ FILE: providers/dns/liara/liara.go ================================================ // Package liara implements a DNS provider for solving the DNS-01 challenge using Liara DNS. package liara import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/liara/internal" "github.com/hashicorp/go-retryablehttp" ) // Environment variables names. const ( envNamespace = "LIARA_" EnvAPIKey = envNamespace + "API_KEY" EnvTeamID = envNamespace + "TEAM_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const ( minTTL = 120 maxTTL = 432000 ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string TeamID 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, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Liara DNS. // Liara_API_KEY must be passed in the environment variables. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("liara: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.TeamID = env.GetOrFile(EnvTeamID) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Liara DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("liara: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("liara: APIKey is missing") } if config.TTL < minTTL { return nil, fmt.Errorf("liara: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } if config.TTL > maxTTL { return nil, fmt.Errorf("liara: invalid TTL, TTL (%d) must be lower than %d", config.TTL, maxTTL) } retryClient := retryablehttp.NewClient() retryClient.RetryMax = 5 if config.HTTPClient != nil { retryClient.HTTPClient = config.HTTPClient } retryClient.Logger = log.Logger client := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey), ), config.TeamID, ) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("liara: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("liara: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Contents: []internal.Content{{Text: info.Value}}, TTL: d.config.TTL, } newRecord, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("liara: failed to create TXT record, fqdn=%s: %w", info.EffectiveFQDN, err) } d.recordIDsMu.Lock() d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("liara: could not find zone for domain %q: %w", domain, err) } // gets the record's unique ID d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("liara: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) if err != nil { return fmt.Errorf("liara: failed to delete TXT record, id=%s: %w", recordID, err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/liara/liara.toml ================================================ Name = "Liara" Description = '''''' URL = "https://liara.ir" Code = "liara" Since = "v4.10.0" Example = ''' LIARA_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns liara -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LIARA_API_KEY = "The API key" [Configuration.Additional] LIARA_TEAM_ID = "The team ID to access services in a team" LIARA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" LIARA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" LIARA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" LIARA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://openapi.liara.ir/?urls.primaryName=DNS" ================================================ FILE: providers/dns/liara/liara_test.go ================================================ package liara import ( "fmt" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const ( envDomain = envNamespace + "DOMAIN" lowerThanMinTTL = 100 greaterThanMaxTTL = 440000 ) var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", }, }, { desc: "missing API key", envVars: map[string]string{}, expected: "liara: some credentials information are missing: LIARA_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string ttl int expected string }{ { desc: "success", apiKey: "key", ttl: minTTL, }, { desc: "missing API key", ttl: maxTTL, expected: "liara: APIKey is missing", }, { desc: "invalid TTL", ttl: lowerThanMinTTL, apiKey: "key", expected: fmt.Sprintf("liara: invalid TTL, TTL (%d) must be greater than %d", lowerThanMinTTL, minTTL), }, { desc: "invalid TTL", ttl: greaterThanMaxTTL, apiKey: "key", expected: fmt.Sprintf("liara: invalid TTL, TTL (%d) must be lower than %d", greaterThanMaxTTL, maxTTL), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.TTL = test.ttl p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/lightsail/lightsail.go ================================================ // Package lightsail implements a DNS provider for solving the DNS-01 challenge using AWS Lightsail DNS. package lightsail import ( "context" "errors" "fmt" "math/rand" "strconv" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/retry" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/lightsail" awstypes "github.com/aws/aws-sdk-go-v2/service/lightsail/types" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" ) // Environment variables names. const ( envNamespace = "LIGHTSAIL_" EnvRegion = envNamespace + "REGION" EnvDNSZone = "DNS_ZONE" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) const maxRetries = 5 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { DNSZone string Region string PropagationTimeout time.Duration PollingInterval time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *lightsail.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for the AWS Lightsail service. // // AWS Credentials are automatically detected in the following locations // and prioritized in the following order: // 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, // [AWS_SESSION_TOKEN], [DNS_ZONE], [LIGHTSAIL_REGION] // 2. Shared credentials file (defaults to ~/.aws/credentials) // 3. Amazon EC2 IAM role // // public hosted zone via the FQDN. // // See also: https://github.com/aws/aws-sdk-go/wiki/configuring-sdk func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.DNSZone = env.GetOrFile(EnvDNSZone) config.Region = env.GetOrDefaultString(EnvRegion, "us-east-1") return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for AWS Lightsail. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("lightsail: the configuration of the DNS provider is nil") } ctx := context.Background() cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(config.Region), awsconfig.WithRetryer(func() aws.Retryer { return retry.NewStandard(func(options *retry.StandardOptions) { options.MaxAttempts = maxRetries // It uses a basic exponential backoff algorithm that returns an initial // delay of ~400ms with an upper limit of ~30 seconds which should prevent // causing a high number of consecutive throttling errors. // For reference: Route 53 enforces an account-wide(!) 5req/s query limit. options.Backoff = retry.BackoffDelayerFunc(func(attempt int, err error) (time.Duration, error) { retryCount := min(attempt, 7) delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) return time.Duration(delay) * time.Millisecond, nil }) }) }), ) if err != nil { return nil, err } return &DNSProvider{ config: config, client: lightsail.NewFromConfig(cfg), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) params := &lightsail.CreateDomainEntryInput{ DomainName: aws.String(d.config.DNSZone), DomainEntry: &awstypes.DomainEntry{ Name: aws.String(info.EffectiveFQDN), Target: aws.String(strconv.Quote(info.Value)), Type: aws.String("TXT"), }, } _, err := d.client.CreateDomainEntry(ctx, params) if err != nil { return fmt.Errorf("lightsail: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) params := &lightsail.DeleteDomainEntryInput{ DomainName: aws.String(d.config.DNSZone), DomainEntry: &awstypes.DomainEntry{ Name: aws.String(info.EffectiveFQDN), Type: aws.String("TXT"), Target: aws.String(strconv.Quote(info.Value)), }, } _, err := d.client.DeleteDomainEntry(ctx, params) if err != nil { return fmt.Errorf("lightsail: %w", err) } return 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 } ================================================ FILE: providers/dns/lightsail/lightsail.toml ================================================ Name = "Amazon Lightsail" Description = '''''' URL = "https://aws.amazon.com/lightsail/" Code = "lightsail" Since = "v0.5.0" Example = '''''' Additional = ''' ## Description AWS Credentials are automatically detected in the following locations and prioritized in the following order: 1. Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, [`AWS_SESSION_TOKEN`] 2. Shared credentials file (defaults to `~/.aws/credentials`, profiles can be specified using `AWS_PROFILE`) 3. Amazon EC2 IAM role AWS region is not required to set as the Lightsail DNS zone is in global (us-east-1) region. ## Policy The following AWS IAM policy document describes the minimum permissions required for lego to complete the DNS challenge. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "lightsail:DeleteDomainEntry", "lightsail:CreateDomainEntry" ], "Resource": "" } ] } ``` Replace the `Resource` value with your Lightsail DNS zone ARN. You can retrieve the ARN using aws cli by running `aws lightsail get-domains --region us-east-1` (Lightsail web console does not show the ARN, unfortunately). It should be in the format of `arn:aws:lightsail:global::Domain/`. You also need to replace the region in the ARN to `us-east-1` (instead of `global`). Alternatively, you can also set the `Resource` to `*` (wildcard), which allow to access all domain, but this is not recommended. ''' [Configuration] [Configuration.Credentials] AWS_ACCESS_KEY_ID = "Managed by the AWS client. Access key ID (`AWS_ACCESS_KEY_ID_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead)" AWS_SECRET_ACCESS_KEY = "Managed by the AWS client. Secret access key (`AWS_SECRET_ACCESS_KEY_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead)" DNS_ZONE = "Domain name of the DNS zone" [Configuration.Additional] AWS_SHARED_CREDENTIALS_FILE = "Managed by the AWS client. Shared credentials file." LIGHTSAIL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" LIGHTSAIL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" [Links] GoClient = "https://github.com/aws/aws-sdk-go-v2" ================================================ FILE: providers/dns/lightsail/lightsail_integration_test.go ================================================ package lightsail import ( "testing" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/lightsail" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/stretchr/testify/require" ) func TestLiveTTL(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) domain := envTest.GetDomain() err = provider.Present(domain, "foo", "bar") require.NoError(t, err) // we need a separate Lightsail client here as the one in the DNS provider is unexported. fqdn := "_acme-challenge." + domain ctx := t.Context() cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) svc := lightsail.NewFromConfig(cfg) require.NoError(t, err) defer func() { errC := provider.CleanUp(domain, "foo", "bar") if errC != nil { t.Log(errC) } }() params := &lightsail.GetDomainInput{ DomainName: aws.String(domain), } resp, err := svc.GetDomain(ctx, params) require.NoError(t, err) entries := resp.Domain.DomainEntries for _, entry := range entries { if ptr.Deref(entry.Type) == "TXT" && ptr.Deref(entry.Name) == fqdn { return } } t.Fatalf("Could not find a TXT record for _acme-challenge.%s", domain) } ================================================ FILE: providers/dns/lightsail/lightsail_test.go ================================================ package lightsail import ( "net/http/httptest" "os" "testing" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/lightsail" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( envAwsNamespace = "AWS_" envAwsAccessKeyID = envAwsNamespace + "ACCESS_KEY_ID" envAwsSecretAccessKey = envAwsNamespace + "SECRET_ACCESS_KEY" envAwsRegion = envAwsNamespace + "REGION" envAwsHostedZoneID = envAwsNamespace + "HOSTED_ZONE_ID" ) var envTest = tester.NewEnvTest( envAwsAccessKeyID, envAwsSecretAccessKey, envAwsRegion, envAwsHostedZoneID). WithDomain(EnvDNSZone). WithLiveTestRequirements(envAwsAccessKeyID, envAwsSecretAccessKey, EnvDNSZone) func TestCredentialsFromEnv(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() _ = os.Setenv(envAwsAccessKeyID, "123") _ = os.Setenv(envAwsSecretAccessKey, "123") _ = os.Setenv(envAwsRegion, "us-east-1") ctx := t.Context() cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) cs, err := cfg.Credentials.Retrieve(ctx) require.NoError(t, err, "Expected credentials to be set from environment") expected := aws.Credentials{ AccessKeyID: "123", SecretAccessKey: "123", Source: "EnvConfigCredentials", } assert.Equal(t, expected, cs) } func TestDNSProvider_Present(t *testing.T) { provider := servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { return &DNSProvider{ client: lightsail.NewFromConfig(aws.Config{ HTTPClient: server.Client(), Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), Region: "mock-region", BaseEndpoint: aws.String(server.URL), RetryMaxAttempts: 1, }), config: NewDefaultConfig(), }, nil }). Route("POST /", nil). Build(t) domain := "example.com" keyAuth := "123456d==" err := provider.Present(domain, "", keyAuth) require.NoError(t, err) } ================================================ FILE: providers/dns/limacity/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://www.lima-city.de/usercp" type Client struct { apiKey string baseURL *url.URL HTTPClient *http.Client } func NewClient(apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } func (c *Client) GetDomains(ctx context.Context) ([]Domain, error) { endpoint := c.baseURL.JoinPath("domains.json") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var results DomainsResponse err = c.do(req, &results) if err != nil { return nil, err } return results.Data, nil } func (c *Client) GetRecords(ctx context.Context, domainID int) ([]Record, error) { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records.json") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var results RecordsResponse err = c.do(req, &results) if err != nil { return nil, err } return results.Data, nil } func (c *Client) AddRecord(ctx context.Context, domainID int, record Record) error { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records.json") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, NameserverRecordPayload{Data: record}) if err != nil { return err } var results APIResponse err = c.do(req, &results) if err != nil { return err } return nil } func (c *Client) UpdateRecord(ctx context.Context, domainID, recordID int, record Record) error { endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, NameserverRecordPayload{Data: record}) if err != nil { return err } var results APIResponse err = c.do(req, &results) if err != nil { return err } return nil } func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error { // /domains/{domainId}/records/{recordId} DELETE endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domainID), "records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } var results APIResponse err = c.do(req, &results) if err != nil { return err } return nil } func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth("api", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIResponse err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, &errAPI) } ================================================ FILE: providers/dns/limacity/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "secret" func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(apiKey) client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("api", apiKey), ) } func TestClient_GetDomains(t *testing.T) { client := mockBuilder(). Route("GET /domains.json", servermock.ResponseFromFixture("get-domains.json")). Build(t) domains, err := client.GetDomains(t.Context()) require.NoError(t, err) expected := []Domain{{ ID: 123, UnicodeFqdn: "example.com", Domain: "example", TLD: "com", Status: "ok", }} assert.Equal(t, expected, domains) } func TestClient_GetDomains_error(t *testing.T) { client := mockBuilder(). Route("GET /domains.json", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.GetDomains(t.Context()) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /domains/123/records.json", servermock.ResponseFromFixture("get-records.json")). Build(t) records, err := client.GetRecords(t.Context(), 123) require.NoError(t, err) expected := []Record{ { ID: 1234, Content: "ns1.lima-city.de", Name: "example.com", TTL: 36000, Type: "NS", }, { ID: 5678, Content: `"foobar"`, Name: "_acme-challenge.example.com", TTL: 36000, Type: "TXT", }, } assert.Equal(t, expected, records) } func TestClient_GetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /domains/123/records.json", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.GetRecords(t.Context(), 123) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/123/records.json", servermock.ResponseFromFixture("ok.json"), servermock.CheckRequestJSONBody(`{"nameserver_record":{"name":"foo","content":"bar","ttl":12,"type":"TXT"}}`)). Build(t) record := Record{ Name: "foo", Content: "bar", TTL: 12, Type: "TXT", } err := client.AddRecord(t.Context(), 123, record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/123/records.json", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) record := Record{ Name: "foo", Content: "bar", TTL: 12, Type: "TXT", } err := client.AddRecord(t.Context(), 123, record) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_UpdateRecord(t *testing.T) { client := mockBuilder(). Route("PUT /domains/123/records/456", servermock.ResponseFromFixture("ok.json"), servermock.CheckRequestJSONBody(`{"nameserver_record":{}}`)). Build(t) err := client.UpdateRecord(t.Context(), 123, 456, Record{}) require.NoError(t, err) } func TestClient_UpdateRecord_error(t *testing.T) { client := mockBuilder(). Route("PUT /domains/123/records/456", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) err := client.UpdateRecord(t.Context(), 123, 456, Record{}) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/123/records/456", servermock.ResponseFromFixture("ok.json")). Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/123/records/456", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } ================================================ FILE: providers/dns/limacity/internal/fixtures/error.json ================================================ { "status": "invalid_resource", "errors": { "name": [ "muss ausgefüllt werden" ] } } ================================================ FILE: providers/dns/limacity/internal/fixtures/get-domains.json ================================================ { "domains": [ { "id": 123, "mode": "CREATE", "tld": "com", "domain": "example", "in_subscription": false, "auto_renew": false, "status": "ok", "unicode_fqdn": "example.com", "registered_at": "1970-01-01T00:00:00+00:00", "registered_until": "2000-01-01T00:00:00+00:00" } ] } ================================================ FILE: providers/dns/limacity/internal/fixtures/get-records.json ================================================ { "records": [ { "id": 1234, "content": "ns1.lima-city.de", "name": "example.com", "ttl": 36000, "type": "NS", "priority": null }, { "id": 5678, "content": "\"foobar\"", "name": "_acme-challenge.example.com", "subdomain": "_acme-challenge", "ttl": 36000, "type": "TXT", "priority": null } ] } ================================================ FILE: providers/dns/limacity/internal/fixtures/ok.json ================================================ { "status": "ok" } ================================================ FILE: providers/dns/limacity/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type RecordsResponse struct { Data []Record `json:"records,omitempty"` } type NameserverRecordPayload struct { Data Record `json:"nameserver_record"` } type DomainsResponse struct { Data []Domain `json:"domains,omitempty"` } type APIResponse struct { Status string `json:"status,omitempty"` Details map[string][]string `json:"errors,omitempty"` } func (a APIResponse) Error() string { var details []string for k, v := range a.Details { details = append(details, fmt.Sprintf("%s: %s", k, v)) } return fmt.Sprintf("status: %s, details: %s", a.Status, strings.Join(details, ",")) } type Record struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` } type Domain struct { ID int `json:"id,omitempty"` UnicodeFqdn string `json:"unicode_fqdn,omitempty"` Domain string `json:"domain,omitempty"` TLD string `json:"tld,omitempty"` Status string `json:"status,omitempty"` } ================================================ FILE: providers/dns/limacity/limacity.go ================================================ // Package limacity implements a DNS provider for solving the DNS-01 challenge using Lima-City DNS. package limacity import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/limacity/internal" ) // Environment variables names. const ( envNamespace = "LIMACITY_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string TTL int PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 8*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 80*time.Second), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 90*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client domainIDs map[string]int domainIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Lima-City DNS. // LIMACITY_API_KEY must be passed in the environment variables. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("limacity: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Lima-City DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("limacity: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("limacity: APIKey is missing") } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, domainIDs: make(map[string]int), }, 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) domains, err := d.client.GetDomains(ctx) if err != nil { return fmt.Errorf("limacity: get domains: %w", err) } dom, err := findDomain(domains, info.EffectiveFQDN) if err != nil { return fmt.Errorf("limacity: find domain: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, dom.UnicodeFqdn) if err != nil { return fmt.Errorf("limacity: %w", err) } record := internal.Record{ Name: subDomain, Content: info.Value, TTL: d.config.TTL, Type: "TXT", } err = d.client.AddRecord(ctx, dom.ID, record) if err != nil { return fmt.Errorf("limacity: add record: %w", err) } d.domainIDsMu.Lock() d.domainIDs[token] = dom.ID d.domainIDsMu.Unlock() return nil } // CleanUp removes the TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) // gets the domain's unique ID d.domainIDsMu.Lock() domainID, ok := d.domainIDs[token] d.domainIDsMu.Unlock() if !ok { return fmt.Errorf("limacity: unknown domain ID for '%s' '%s'", info.EffectiveFQDN, token) } records, err := d.client.GetRecords(ctx, domainID) if err != nil { return fmt.Errorf("limacity: get records: %w", err) } var recordID int for _, record := range records { if record.Type == "TXT" && record.Content == strconv.Quote(info.Value) { recordID = record.ID break } } if recordID == 0 { return errors.New("limacity: TXT record not found") } err = d.client.DeleteRecord(ctx, domainID, recordID) if err != nil { return fmt.Errorf("limacity: delete record (domain ID=%d, record ID=%d): %w", domainID, recordID, err) } d.domainIDsMu.Lock() delete(d.domainIDs, info.EffectiveFQDN) d.domainIDsMu.Unlock() return nil } func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) { for f := range dns01.DomainsSeq(fqdn) { domain := dns01.UnFqdn(f) for _, dom := range domains { if dom.UnicodeFqdn == domain || dom.UnicodeFqdn == f { return dom, nil } } } return internal.Domain{}, fmt.Errorf("domain %s not found", fqdn) } ================================================ FILE: providers/dns/limacity/limacity.toml ================================================ Name = "Lima-City" Description = '''''' URL = "https://www.lima-city.de" Code = "limacity" Since = "v4.18.0" Example = ''' LIMACITY_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns limacity -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LIMACITY_API_KEY = "The API key" [Configuration.Additional] LIMACITY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 80)" LIMACITY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 480)" LIMACITY_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 90)" LIMACITY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" LIMACITY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.lima-city.de/hilfe/lima-city-api" ================================================ FILE: providers/dns/limacity/limacity_test.go ================================================ package limacity import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", }, }, { desc: "missing API key", envVars: map[string]string{}, expected: "limacity: some credentials information are missing: LIMACITY_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "key", }, { desc: "missing API key", expected: "limacity: APIKey is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/linode/linode.go ================================================ // Package linode implements a DNS provider for solving the DNS-01 challenge using Linode DNS and Linode's APIv4 package linode import ( "context" "encoding/json" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/linode/linodego" "golang.org/x/oauth2" ) // Environment variables names. const ( envNamespace = "LINODE_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const ( minTTL = 300 dnsUpdateFreqMins = 15 dnsUpdateFudgeSecs = 120 ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), } } type hostedZoneInfo struct { domainID int resourceName string } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *linodego.Client } // NewDNSProvider returns a DNSProvider instance configured for Linode. // Credentials must be passed in the environment variable: LINODE_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("linode: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Linode. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("linode: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("linode: Linode Access Token missing") } if config.TTL < minTTL { return nil, fmt.Errorf("linode: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } oauth2Client := &http.Client{ Timeout: config.HTTPTimeout, Transport: &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.Token}), }, } client := linodego.NewClient(clientdebug.Wrap(oauth2Client)) client.SetUserAgent(useragent.Get()) return &DNSProvider{config: config, client: &client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (time.Duration, time.Duration) { timeout := d.config.PropagationTimeout if d.config.PropagationTimeout <= 0 { // 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) + (minTTL * time.Second) + (dnsUpdateFudgeSecs * time.Second) } return timeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZoneInfo(ctx, info.EffectiveFQDN) if err != nil { return err } createOpts := linodego.DomainRecordCreateOptions{ Name: dns01.UnFqdn(info.EffectiveFQDN), Target: info.Value, TTLSec: d.config.TTL, Type: linodego.RecordTypeTXT, } _, err = d.client.CreateDomainRecord(ctx, zone.domainID, createOpts) return err } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZoneInfo(ctx, info.EffectiveFQDN) if err != nil { return err } // Get all TXT records for the specified domain. listOpts := linodego.NewListOptions(0, `{"type":"TXT"}`) resources, err := d.client.ListDomainRecords(ctx, zone.domainID, listOpts) if err != nil { return err } // Remove the specified resource, if it exists. for _, resource := range resources { if (resource.Name == dns01.UnFqdn(info.EffectiveFQDN) || resource.Name == zone.resourceName) && resource.Target == info.Value { if err := d.client.DeleteDomainRecord(ctx, zone.domainID, resource.ID); err != nil { return err } } } return nil } func (d *DNSProvider) getHostedZoneInfo(ctx context.Context, fqdn string) (*hostedZoneInfo, error) { // Lookup the zone that handles the specified FQDN. authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) } // Query the authority zone. filter, err := json.Marshal(map[string]string{"domain": dns01.UnFqdn(authZone)}) if err != nil { return nil, fmt.Errorf("failed to create JSON filter: %w", err) } listOpts := linodego.NewListOptions(0, string(filter)) domains, err := d.client.ListDomains(ctx, listOpts) if err != nil { return nil, err } if len(domains) == 0 { return nil, errors.New("domain not found") } subDomain, err := dns01.ExtractSubDomain(fqdn, authZone) if err != nil { return nil, err } return &hostedZoneInfo{ domainID: domains[0].ID, resourceName: subDomain, }, nil } ================================================ FILE: providers/dns/linode/linode.toml ================================================ Name = "Linode (v4)" Description = '''''' URL = "https://www.linode.com/" Code = "linode" Aliases = ["linodev4"] # "linodev4" is for compatibility with v3, must be dropped in v5 Since = "v1.1.0" Example = ''' LINODE_TOKEN=xxxxx \ lego --dns linode -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LINODE_TOKEN = "API token" [Configuration.Additional] LINODE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)" LINODE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" LINODE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" LINODE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.linode.com/api/v4" GoClient = "https://github.com/linode/linodego" ================================================ FILE: providers/dns/linode/linode_test.go ================================================ package linode import ( "net/http" "net/http/httptest" "os" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/linode/linodego" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvToken) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvToken: "", }, expected: "linode: some credentials information are missing: LINODE_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "123", }, { desc: "missing credentials", expected: "linode: Linode Access Token missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { defer envTest.RestoreEnv() os.Setenv(EnvToken, "testing") domain := "example.com" keyAuth := "dGVzdGluZw==" testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "Success", builder: mockBuilder(). Route("GET /v4/domains", servermock.JSONEncode(linodego.DomainsPagedResponse{ PageOptions: &linodego.PageOptions{ Pages: 1, Results: 1, Page: 1, }, Data: []linodego.Domain{{ Domain: domain, ID: 1234, }}, })). Route("POST /v4/domains/1234/records", servermock.JSONEncode(linodego.DomainRecord{ ID: 1234, })), }, { desc: "NoDomain", builder: mockBuilder(). Route("GET /v4/domains", servermock.JSONEncode(linodego.APIError{ Errors: []linodego.APIErrorReason{{ Reason: "Not found", }}, }). WithStatusCode(http.StatusNotFound)), expectedError: "[404] Not found", }, { desc: "CreateFailed", builder: mockBuilder(). Route("GET /v4/domains", servermock.JSONEncode(&linodego.DomainsPagedResponse{ PageOptions: &linodego.PageOptions{ Pages: 1, Results: 1, Page: 1, }, Data: []linodego.Domain{{ Domain: "example.com", ID: 1234, }}, })). Route("POST /v4/domains/1234/records", servermock.JSONEncode(linodego.APIError{ Errors: []linodego.APIErrorReason{{ Reason: "Failed to create domain resource", Field: "somefield", }}, }). WithStatusCode(http.StatusBadRequest)), expectedError: "[400] [somefield] Failed to create domain resource", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { provider := test.builder.Build(t) err := provider.Present(domain, "", keyAuth) if test.expectedError == "" { assert.NoError(t, err) } else { assert.EqualError(t, err, test.expectedError) } }) } } func TestDNSProvider_CleanUp(t *testing.T) { defer envTest.RestoreEnv() os.Setenv(EnvToken, "testing") domain := "example.com" keyAuth := "dGVzdGluZw==" testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "Success", builder: mockBuilder(). Route("GET /v4/domains", servermock.JSONEncode(&linodego.DomainsPagedResponse{ PageOptions: &linodego.PageOptions{ Pages: 1, Results: 1, Page: 1, }, Data: []linodego.Domain{{ Domain: "foobar.com", ID: 1234, }}, })). Route("GET /v4/domains/1234/records", servermock.JSONEncode(&linodego.DomainRecordsPagedResponse{ PageOptions: &linodego.PageOptions{ Pages: 1, Results: 1, Page: 1, }, Data: []linodego.DomainRecord{{ ID: 1234, Name: "_acme-challenge", Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", Type: "TXT", }}, })). Route("DELETE /v4/domains/1234/records/1234", servermock.RawStringResponse("{}").WithHeader("Content-Type", "application/json")), }, { desc: "NoDomain", builder: mockBuilder(). Route("GET /v4/domains", servermock.JSONEncode(linodego.APIError{ Errors: []linodego.APIErrorReason{{ Reason: "Not found", }}, }). WithStatusCode(http.StatusNotFound)). Route("GET /v4/domains/1234/records", servermock.JSONEncode(linodego.APIError{ Errors: []linodego.APIErrorReason{{ Reason: "Not found", }}, }, ). WithStatusCode(http.StatusNotFound)), expectedError: "[404] Not found", }, { desc: "DeleteFailed", builder: mockBuilder(). Route("GET /v4/domains", servermock.JSONEncode(linodego.DomainsPagedResponse{ PageOptions: &linodego.PageOptions{ Pages: 1, Results: 1, Page: 1, }, Data: []linodego.Domain{{ ID: 1234, Domain: "example.com", }}, })). Route("GET /v4/domains/1234/records", servermock.JSONEncode(linodego.DomainRecordsPagedResponse{ PageOptions: &linodego.PageOptions{ Pages: 1, Results: 1, Page: 1, }, Data: []linodego.DomainRecord{{ ID: 1234, Name: "_acme-challenge", Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", Type: "TXT", }}, })). Route("DELETE /v4/domains/1234/records/1234", servermock.JSONEncode(linodego.APIError{ Errors: []linodego.APIErrorReason{{ Reason: "Failed to delete domain resource", }}, }). WithStatusCode(http.StatusBadRequest)), expectedError: "[400] Failed to delete domain resource", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { provider := test.builder.Build(t) err := provider.CleanUp(domain, "", keyAuth) if test.expectedError == "" { assert.NoError(t, err) } else { assert.EqualError(t, err, test.expectedError) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("Skipping live test") } // TODO implement this test } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("Skipping live test") } // TODO implement this test } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { p, err := NewDNSProvider() if err != nil { return nil, err } p.client.SetBaseURL(server.URL) return p, nil }) } ================================================ FILE: providers/dns/liquidweb/liquidweb.go ================================================ // Package liquidweb implements a DNS provider for solving the DNS-01 challenge using Liquid Web. package liquidweb import ( "errors" "fmt" "sort" "strconv" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" lw "github.com/liquidweb/liquidweb-go/client" "github.com/liquidweb/liquidweb-go/network" ) // Environment variables names. const ( envNamespace = "LIQUID_WEB_" altEnvNamespace = "LWAPI_" EnvURL = envNamespace + "URL" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvZone = envNamespace + "ZONE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://api.liquidweb.com" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string Username string Password string Zone string TTL int PollingInterval time.Duration PropagationTimeout time.Duration HTTPTimeout time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ BaseURL: defaultBaseURL, TTL: env.GetOneWithFallback(EnvTTL, 300, strconv.Atoi, altEnvName(EnvTTL)), PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, 2*time.Minute, env.ParseSecond, altEnvName(EnvPropagationTimeout)), PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)), HTTPTimeout: env.GetOneWithFallback(EnvHTTPTimeout, 1*time.Minute, env.ParseSecond, altEnvName(EnvHTTPTimeout)), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *lw.API recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Liquid Web. func NewDNSProvider() (*DNSProvider, error) { values, err := env.GetWithFallback( []string{EnvUsername, altEnvName(EnvUsername)}, []string{EnvPassword, altEnvName(EnvPassword)}, ) if err != nil { return nil, fmt.Errorf("liquidweb: %w", err) } config := NewDefaultConfig() config.BaseURL = env.GetOneWithFallback(EnvURL, defaultBaseURL, env.ParseString, altEnvName(EnvURL)) config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.Zone = env.GetOneWithFallback(EnvZone, "", env.ParseString, altEnvName(EnvZone)) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Liquid Web. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("liquidweb: the configuration of the DNS provider is nil") } if config.BaseURL == "" { config.BaseURL = defaultBaseURL } client, err := lw.NewAPI(config.Username, config.Password, config.BaseURL, int(config.HTTPTimeout.Seconds())) if err != nil { return nil, fmt.Errorf("liquidweb: could not create Liquid Web API client: %w", err) } return &DNSProvider{ config: config, recordIDs: make(map[string]int), client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (time.Duration, time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) params := &network.DNSRecordParams{ Name: dns01.UnFqdn(info.EffectiveFQDN), RData: strconv.Quote(info.Value), Type: "TXT", Zone: d.config.Zone, TTL: d.config.TTL, } if params.Zone == "" { bestZone, err := d.findZone(params.Name) if err != nil { return fmt.Errorf("liquidweb: %w", err) } params.Zone = bestZone } dnsEntry, err := d.client.NetworkDNS.Create(params) if err != nil { return fmt.Errorf("liquidweb: could not create TXT record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = int(dnsEntry.ID) d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("liquidweb: unknown record ID for '%s'", domain) } params := &network.DNSRecordParams{ID: recordID} _, err := d.client.NetworkDNS.Delete(params) if err != nil { return fmt.Errorf("liquidweb: could not remove TXT record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) findZone(domain string) (string, error) { zones, err := d.client.NetworkDNSZone.ListAll() if err != nil { return "", fmt.Errorf("failed to retrieve zones for account: %w", err) } // filter the zones on the account to only ones that match var zs []network.DNSZone for _, item := range zones.Items { if strings.HasSuffix(domain, item.Name) { zs = append(zs, item) } } if len(zs) < 1 { return "", fmt.Errorf("no valid zone in account for certificate '%s'", domain) } // powerdns _only_ looks for records on the longest matching subdomain zone aka, // for test.sub.example.com if sub.example.com exists, // it will look there it will not look atexample.com even if it also exists sort.Slice(zs, func(i, j int) bool { return len(zs[i].Name) > len(zs[j].Name) }) return zs[0].Name, nil } func altEnvName(v string) string { return strings.ReplaceAll(v, envNamespace, altEnvNamespace) } ================================================ FILE: providers/dns/liquidweb/liquidweb.toml ================================================ Name = "Liquid Web" Description = '''''' URL = "https://liquidweb.com" Code = "liquidweb" Since = "v3.1.0" Example = ''' LWAPI_USERNAME=someuser \ LWAPI_PASSWORD=somepass \ lego --dns liquidweb -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LWAPI_USERNAME = "Liquid Web API Username" LWAPI_PASSWORD = "Liquid Web API Password" [Configuration.Additional] LWAPI_ZONE = "DNS Zone" LWAPI_URL = "Liquid Web API endpoint" LWAPI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" LWAPI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" LWAPI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" LWAPI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://api.liquidweb.com/docs/" GoClient = "https://github.com/liquidweb/liquidweb-go" ================================================ FILE: providers/dns/liquidweb/liquidweb_test.go ================================================ package liquidweb import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/liquidweb/liquidweb-go/network" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvURL, EnvUsername, EnvPassword, EnvZone). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "minimum-success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", }, }, { desc: "set-everything", envVars: map[string]string{ EnvURL: "https://storm.example", EnvUsername: "user", EnvPassword: "secret", EnvZone: "blars.com", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "liquidweb: some credentials information are missing: LIQUID_WEB_USERNAME,LIQUID_WEB_PASSWORD", }, { desc: "missing username", envVars: map[string]string{ EnvPassword: "secret", EnvZone: "blars.example", }, expected: "liquidweb: some credentials information are missing: LIQUID_WEB_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "user", EnvZone: "blars.example", }, expected: "liquidweb: some credentials information are missing: LIQUID_WEB_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string zone string expected string }{ { desc: "success", username: "acme", password: "secret", zone: "example.com", }, { desc: "missing credentials", username: "", password: "", zone: "", expected: "liquidweb: could not create Liquid Web API client: provided username is empty", }, { desc: "missing username", username: "", password: "secret", zone: "example.com", expected: "liquidweb: could not create Liquid Web API client: provided username is empty", }, { desc: "missing password", username: "acme", password: "", zone: "example.com", expected: "liquidweb: could not create Liquid Web API client: provided password is empty", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password config.Zone = test.zone p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { provider := mockProvider(t) err := provider.Present("tacoman.example", "", "") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockProvider(t, network.DNSRecord{ Name: "_acme-challenge.tacoman.example", RData: "123d==", Type: "TXT", TTL: 300, ID: 1234567, ZoneID: 42, }) provider.recordIDs["123d=="] = 1234567 err := provider.CleanUp("tacoman.example.", "123d==", "") require.NoError(t, err) } func TestDNSProvider(t *testing.T) { testCases := []struct { desc string initRecs []network.DNSRecord domain string token string keyAuth string present bool expPresentErr string cleanup bool }{ { desc: "expected successful", domain: "tacoman.example", token: "123", keyAuth: "456", present: true, cleanup: true, }, { desc: "other successful", domain: "banana.example", token: "123", keyAuth: "456", present: true, cleanup: true, }, { desc: "zone not on account", domain: "huckleberry.example", token: "123", keyAuth: "456", present: true, expPresentErr: "no valid zone in account for certificate '_acme-challenge.huckleberry.example'", cleanup: false, }, { desc: "ssl for domain", domain: "sundae.cherry.example", token: "5847953", keyAuth: "34872934", present: true, cleanup: true, }, { desc: "complicated domain", domain: "always.money.stand.banana.example", token: "5847953", keyAuth: "there is always money in the banana stand", present: true, cleanup: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { provider := mockProvider(t, test.initRecs...) if test.present { err := provider.Present(test.domain, test.token, test.keyAuth) if test.expPresentErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, test.expPresentErr) } } if test.cleanup { err := provider.CleanUp(test.domain, test.token, test.keyAuth) require.NoError(t, err) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/liquidweb/servermock_test.go ================================================ package liquidweb import ( "encoding/json" "fmt" "io" "math/rand" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/liquidweb/liquidweb-go/network" "github.com/liquidweb/liquidweb-go/types" ) func mockProvider(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider { t.Helper() recs := make(map[int]network.DNSRecord) for _, rec := range initRecs { recs[int(rec.ID)] = rec } return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Username = "user" config.Password = "secret" config.BaseURL = server.URL return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithBasicAuth("user", "secret"), ). Route("/v1/Network/DNS/Record/delete", mockAPIDelete(recs)). Route("/v1/Network/DNS/Record/create", mockAPICreate(recs)). Route("/v1/Network/DNS/Zone/list", mockAPIListZones()). Route("/bleed/Network/DNS/Record/delete", mockAPIDelete(recs)). Route("/bleed/Network/DNS/Record/create", mockAPICreate(recs)). Route("/bleed/Network/DNS/Zone/list", mockAPIListZones()). Build(t) } func mockAPICreate(recs map[int]network.DNSRecord) http.HandlerFunc { _, mockAPIServerZones := makeMockZones() return func(rw http.ResponseWriter, req *http.Request) { body, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, "invalid request", http.StatusInternalServerError) return } payload := struct { Params network.DNSRecord `json:"params"` }{} if err = json.Unmarshal(body, &payload); err != nil { http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } payload.Params.ID = types.FlexInt(rand.Intn(10000000)) payload.Params.ZoneID = types.FlexInt(mockAPIServerZones[payload.Params.Name]) if _, exists := recs[int(payload.Params.ID)]; exists { http.Error(rw, "dns record already exists", http.StatusTeapot) return } recs[int(payload.Params.ID)] = payload.Params resp, err := json.Marshal(payload.Params) if err != nil { http.Error(rw, "", http.StatusInternalServerError) return } http.Error(rw, string(resp), http.StatusOK) } } func mockAPIDelete(recs map[int]network.DNSRecord) http.HandlerFunc { return func(rw http.ResponseWriter, req *http.Request) { body, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, "invalid request", http.StatusInternalServerError) return } payload := struct { Params struct { Name string `json:"name"` ID int `json:"id"` } `json:"params"` }{} if err := json.Unmarshal(body, &payload); err != nil { http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } if payload.Params.ID == 0 { http.Error(rw, `{"error":"","error_class":"LW::Exception::Input::Multiple","errors":[{"error":"","error_class":"LW::Exception::Input::Required","field":"id","full_message":"The required field 'id' was missing a value.","position":null}],"field":["id"],"full_message":"The following input errors occurred:\nThe required field 'id' was missing a value.","type":null}`, http.StatusOK) return } if _, ok := recs[payload.Params.ID]; !ok { http.Error(rw, fmt.Sprintf(`{"error":"","error_class":"LW::Exception::RecordNotFound","field":"network_dns_rr","full_message":"Record 'network_dns_rr: %d' not found","input":"%d","public_message":null}`, payload.Params.ID, payload.Params.ID), http.StatusOK) return } delete(recs, payload.Params.ID) http.Error(rw, fmt.Sprintf("{\"deleted\":%d}", payload.Params.ID), http.StatusOK) } } func mockAPIListZones() http.HandlerFunc { mockZones, mockAPIServerZones := makeMockZones() return func(rw http.ResponseWriter, req *http.Request) { body, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, "invalid request", http.StatusInternalServerError) return } payload := struct { Params struct { PageNum int `json:"page_num"` } `json:"params"` }{} if err = json.Unmarshal(body, &payload); err != nil { http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } switch { case payload.Params.PageNum < 1: payload.Params.PageNum = 1 case payload.Params.PageNum > len(mockZones): payload.Params.PageNum = len(mockZones) } resp := mockZones[payload.Params.PageNum] resp.ItemTotal = types.FlexInt(len(mockAPIServerZones)) resp.PageNum = types.FlexInt(payload.Params.PageNum) resp.PageSize = 5 resp.PageTotal = types.FlexInt(len(mockZones)) var respBody []byte if respBody, err = json.Marshal(resp); err == nil { http.Error(rw, string(respBody), http.StatusOK) return } http.Error(rw, "", http.StatusInternalServerError) } } func makeEncodingError(buf []byte) string { return fmt.Sprintf(`{"data":"%q","encoding":"JSON","error":"unexpected end of string while parsing JSON string, at character offset 32 (before \"(end of string)\") at /usr/local/lp/libs/perl/LW/Base/Role/Serializer.pm line 16.\n","error_class":"LW::Exception::Deserialize","full_message":"Could not deserialize \"%q\" from JSON: unexpected end of string while parsing JSON string, at character offset 32 (before \"(end of string)\") at /usr/local/lp/libs/perl/LW/Base/Role/Serializer.pm line 16.\n"}⏎`, string(buf), string(buf)) } func makeMockZones() (map[int]network.DNSZoneList, map[string]int) { mockZones := map[int]network.DNSZoneList{ 1: { Items: []network.DNSZone{ { ID: 1, Name: "blars.example", Active: 1, DelegationStatus: "CORRECT", PrimaryNameserver: "ns.example.org", }, { ID: 2, Name: "tacoman.example", Active: 1, DelegationStatus: "CORRECT", PrimaryNameserver: "ns.example.org", }, { ID: 3, Name: "storm.example", Active: 1, DelegationStatus: "CORRECT", PrimaryNameserver: "ns.example.org", }, { ID: 4, Name: "not-apple.example", Active: 1, DelegationStatus: "BAD_NAMESERVERS", PrimaryNameserver: "ns.example.org", }, { ID: 5, Name: "example.com", Active: 1, DelegationStatus: "BAD_NAMESERVERS", PrimaryNameserver: "ns.example.org", }, }, }, 2: { Items: []network.DNSZone{ { ID: 6, Name: "banana.example", Active: 1, DelegationStatus: "NXDOMAIN", PrimaryNameserver: "ns.example.org", }, { ID: 7, Name: "cherry.example", Active: 1, DelegationStatus: "SERVFAIL", PrimaryNameserver: "ns.example.org", }, { ID: 8, Name: "dates.example", Active: 1, DelegationStatus: "SERVFAIL", PrimaryNameserver: "ns.example.org", }, { ID: 9, Name: "eggplant.example", Active: 1, DelegationStatus: "SERVFAIL", PrimaryNameserver: "ns.example.org", }, { ID: 10, Name: "fig.example", Active: 1, DelegationStatus: "UNKNOWN", PrimaryNameserver: "ns.example.org", }, }, }, 3: { Items: []network.DNSZone{ { ID: 11, Name: "grapes.example", Active: 1, DelegationStatus: "UNKNOWN", PrimaryNameserver: "ns.example.org", }, { ID: 12, Name: "money.banana.example", Active: 1, DelegationStatus: "UNKNOWN", PrimaryNameserver: "ns.example.org", }, { ID: 13, Name: "money.stand.banana.example", Active: 1, DelegationStatus: "UNKNOWN", PrimaryNameserver: "ns.example.org", }, { ID: 14, Name: "stand.banana.example", Active: 1, DelegationStatus: "UNKNOWN", PrimaryNameserver: "ns.example.org", }, }, }, } mockAPIServerZones := make(map[string]int) for _, page := range mockZones { for _, zone := range page.Items { mockAPIServerZones[zone.Name] = int(zone.ID) } } return mockZones, mockAPIServerZones } ================================================ FILE: providers/dns/loopia/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/xml" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultBaseURL is url to the XML-RPC api. const DefaultBaseURL = "https://api.loopia.se/RPCSERV" // Client the Loopia client. type Client struct { apiUser string apiPassword string BaseURL string HTTPClient *http.Client } // NewClient creates a new Loopia Client. func NewClient(apiUser, apiPassword string) *Client { return &Client{ apiUser: apiUser, apiPassword: apiPassword, BaseURL: DefaultBaseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // AddTXTRecord adds a TXT record. func (c *Client) AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error { call := &methodCall{ MethodName: "addZoneRecord", Params: []param{ paramString{Value: c.apiUser}, paramString{Value: c.apiPassword}, paramString{Value: domain}, paramString{Value: subdomain}, paramStruct{ StructMembers: []structMember{ structMemberString{Name: "type", Value: "TXT"}, structMemberInt{Name: "ttl", Value: ttl}, structMemberInt{Name: "priority", Value: 0}, structMemberString{Name: "rdata", Value: value}, structMemberInt{Name: "record_id", Value: 0}, }, }, }, } resp := &responseString{} err := c.rpcCall(ctx, call, resp) if err != nil { return err } return checkResponse(resp.Value) } // RemoveTXTRecord removes a TXT record. func (c *Client) RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error { call := &methodCall{ MethodName: "removeZoneRecord", Params: []param{ paramString{Value: c.apiUser}, paramString{Value: c.apiPassword}, paramString{Value: domain}, paramString{Value: subdomain}, paramInt{Value: recordID}, }, } resp := &responseString{} err := c.rpcCall(ctx, call, resp) if err != nil { return err } return checkResponse(resp.Value) } // GetTXTRecords gets TXT records. func (c *Client) GetTXTRecords(ctx context.Context, domain, subdomain string) ([]RecordObj, error) { call := &methodCall{ MethodName: "getZoneRecords", Params: []param{ paramString{Value: c.apiUser}, paramString{Value: c.apiPassword}, paramString{Value: domain}, paramString{Value: subdomain}, }, } resp := &recordObjectsResponse{} err := c.rpcCall(ctx, call, resp) return resp.Params, err } // RemoveSubdomain remove a sub-domain. func (c *Client) RemoveSubdomain(ctx context.Context, domain, subdomain string) error { call := &methodCall{ MethodName: "removeSubdomain", Params: []param{ paramString{Value: c.apiUser}, paramString{Value: c.apiPassword}, paramString{Value: domain}, paramString{Value: subdomain}, }, } resp := &responseString{} err := c.rpcCall(ctx, call, resp) if err != nil { return err } return checkResponse(resp.Value) } // rpcCall makes an XML-RPC call to Loopia's RPC endpoint by marshaling the data given in the call argument to XML // and sending that via HTTP Post to Loopia. // The response is then unmarshalled into the resp argument. func (c *Client) rpcCall(ctx context.Context, call *methodCall, result response) error { req, err := newXMLRequest(ctx, c.BaseURL, call) if err != nil { return err } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = xml.Unmarshal(raw, result) if err != nil { return fmt.Errorf("unmarshal error: %w", err) } if result.faultCode() != 0 { return RPCError{ FaultCode: result.faultCode(), FaultString: strings.TrimSpace(result.faultString()), } } return nil } func newXMLRequest(ctx context.Context, endpoint string, payload any) (*http.Request, error) { body := new(bytes.Buffer) body.WriteString(xml.Header) encoder := xml.NewEncoder(body) encoder.Indent("", " ") err := encoder.Encode(payload) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "text/xml") return req, nil } func checkResponse(value string) error { switch v := strings.TrimSpace(value); v { case "OK": return nil case "AUTH_ERROR": return errors.New("authentication error") default: return fmt.Errorf("unknown error: %q", v) } } ================================================ FILE: providers/dns/loopia/internal/client_test.go ================================================ package internal import ( "encoding/xml" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder(password string) *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("apiuser", password) client.HTTPClient = server.Client() client.BaseURL = server.URL + "/" return client, nil }, servermock.CheckHeader().WithContentType("text/xml"), ) } func TestClient_AddZoneRecord(t *testing.T) { testCases := []struct { desc string password string domain string request string response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, request: addZoneRecordGoodAuth, response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, request: addZoneRecordBadAuth, response: responseAuthError, err: "authentication error", }, { desc: "unknown error", password: "goodpassword", domain: "badexample.com", request: addZoneRecordNonValidDomain, response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", request: addZoneRecordEmptyResponse, response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(test.password). Route("POST /", servermock.RawStringResponse(test.response), servermock.CheckRequestBody(test.request)). Build(t) err := client.AddTXTRecord(t.Context(), test.domain, exampleSubDomain, 123, "TXTrecord") if test.err == "" { require.NoError(t, err) } else { require.Error(t, err) assert.EqualError(t, err, test.err) } }) } } func TestClient_RemoveSubdomain(t *testing.T) { testCases := []struct { desc string password string domain string request string response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, request: removeSubdomainGoodAuth, response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, request: removeSubdomainBadAuth, response: responseAuthError, err: "authentication error", }, { desc: "unknown error", password: "goodpassword", domain: "badexample.com", request: removeSubdomainNonValidDomain, response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", request: removeSubdomainEmptyResponse, response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(test.password). Route("POST /", servermock.RawStringResponse(test.response), servermock.CheckRequestBody(test.request)). Build(t) err := client.RemoveSubdomain(t.Context(), test.domain, exampleSubDomain) if test.err == "" { require.NoError(t, err) } else { require.Error(t, err) assert.EqualError(t, err, test.err) } }) } } func TestClient_RemoveZoneRecord(t *testing.T) { testCases := []struct { desc string password string domain string request string response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, request: removeRecordGoodAuth, response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, request: removeRecordBadAuth, response: responseAuthError, err: "authentication error", }, { desc: "uknown error", password: "goodpassword", domain: "badexample.com", request: removeRecordNonValidDomain, response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", request: removeRecordEmptyResponse, response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(test.password). Route("POST /", servermock.RawStringResponse(test.response), servermock.CheckRequestBody(test.request)). Build(t) err := client.RemoveTXTRecord(t.Context(), test.domain, exampleSubDomain, 12345678) if test.err == "" { require.NoError(t, err) } else { require.Error(t, err) assert.EqualError(t, err, test.err) } }) } } func TestClient_GetZoneRecord(t *testing.T) { client := mockBuilder("goodpassword"). Route("POST /", servermock.RawStringResponse(getZoneRecordsResponse), servermock.CheckRequestBody(getZoneRecords)). Build(t) recordObjs, err := client.GetTXTRecords(t.Context(), exampleDomain, exampleSubDomain) require.NoError(t, err) expected := []RecordObj{ { Type: "TXT", TTL: 300, Priority: 0, Rdata: exampleRdata, RecordID: 12345678, }, } assert.Equal(t, expected, recordObjs) } func TestClient_rpcCall_404(t *testing.T) { client := mockBuilder("apipassword"). Route("POST /", servermock.RawStringResponse(""). WithStatusCode(http.StatusNotFound)). Build(t) call := &methodCall{ MethodName: "dummyMethod", Params: []param{ paramString{Value: "test1"}, }, } err := client.rpcCall(t.Context(), call, &responseString{}) require.EqualError(t, err, "unexpected status code: [status code: 404] body: ") } func TestClient_rpcCall_RPCError(t *testing.T) { client := mockBuilder("apipassword"). Route("POST /", servermock.RawStringResponse(responseRPCError)). Build(t) call := &methodCall{ MethodName: "getDomains", Params: []param{ paramString{Value: "test1"}, }, } err := client.rpcCall(t.Context(), call, &responseString{}) require.EqualError(t, err, "RPC Error: (201) Method signature error: 42") } func TestUnmarshallFaultyRecordObject(t *testing.T) { testCases := []struct { desc string xml string }{ { desc: "faulty name", xml: "name", }, { desc: "faulty string", xml: "foo", }, { desc: "faulty int", xml: "1", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { resp := &RecordObj{} err := xml.Unmarshal([]byte(test.xml), resp) require.Error(t, err) }) } } ================================================ FILE: providers/dns/loopia/internal/mock_test.go ================================================ package internal const ( exampleDomain = "example.com" exampleSubDomain = "_acme-challenge" exampleRdata = "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM" ) // Testdata based on real traffic between a xml-rpc client and the api. const responseOk = ` OK ` const responseAuthError = ` AUTH_ERROR ` const responseUnknownError = ` UNKNOWN_ERROR ` const responseRPCError = ` faultCode 201 faultString Method signature error: 42 ` const addZoneRecordGoodAuth = ` addZoneRecord apiuser goodpassword example.com _acme-challenge type TXT ttl 123 priority 0 rdata TXTrecord record_id 0 ` const addZoneRecordBadAuth = ` addZoneRecord apiuser badpassword example.com _acme-challenge type TXT ttl 123 priority 0 rdata TXTrecord record_id 0 ` const addZoneRecordNonValidDomain = ` addZoneRecord apiuser goodpassword badexample.com _acme-challenge type TXT ttl 123 priority 0 rdata TXTrecord record_id 0 ` const addZoneRecordEmptyResponse = ` addZoneRecord apiuser goodpassword empty.com _acme-challenge type TXT ttl 123 priority 0 rdata TXTrecord record_id 0 ` const getZoneRecords = ` getZoneRecords apiuser goodpassword example.com _acme-challenge ` const getZoneRecordsResponse = ` rdata LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM record_id 12345678 priority 0 ttl 300 type TXT ` const removeRecordGoodAuth = ` removeZoneRecord apiuser goodpassword example.com _acme-challenge 12345678 ` const removeRecordBadAuth = ` removeZoneRecord apiuser badpassword example.com _acme-challenge 12345678 ` const removeRecordNonValidDomain = ` removeZoneRecord apiuser goodpassword badexample.com _acme-challenge 12345678 ` const removeRecordEmptyResponse = ` removeZoneRecord apiuser goodpassword empty.com _acme-challenge 12345678 ` const removeSubdomainGoodAuth = ` removeSubdomain apiuser goodpassword example.com _acme-challenge ` const removeSubdomainBadAuth = ` removeSubdomain apiuser badpassword example.com _acme-challenge ` const removeSubdomainNonValidDomain = ` removeSubdomain apiuser goodpassword badexample.com _acme-challenge ` const removeSubdomainEmptyResponse = ` removeSubdomain apiuser goodpassword empty.com _acme-challenge ` ================================================ FILE: providers/dns/loopia/internal/types.go ================================================ package internal import ( "encoding/xml" "fmt" "strings" ) // types for XML-RPC method calls and parameters type param interface { param() } type paramString struct { XMLName xml.Name `xml:"param"` Value string `xml:"value>string"` } func (p paramString) param() {} type paramInt struct { XMLName xml.Name `xml:"param"` Value int `xml:"value>int"` } func (p paramInt) param() {} type paramStruct struct { XMLName xml.Name `xml:"param"` StructMembers []structMember `xml:"value>struct>member"` } func (p paramStruct) param() {} type structMember interface { structMember() } type structMemberString struct { Name string `xml:"name"` Value string `xml:"value>string"` } func (m structMemberString) structMember() {} type structMemberInt struct { Name string `xml:"name"` Value int `xml:"value>int"` } func (m structMemberInt) structMember() {} type methodCall struct { XMLName xml.Name `xml:"methodCall"` MethodName string `xml:"methodName"` Params []param `xml:"params>param"` } // types for XML-RPC responses type response interface { faultCode() int faultString() string } type responseString struct { responseFault Value string `xml:"params>param>value>string"` } type responseFault struct { FaultCode int `xml:"fault>value>struct>member>value>int"` FaultString string `xml:"fault>value>struct>member>value>string"` } func (r responseFault) faultCode() int { return r.FaultCode } func (r responseFault) faultString() string { return r.FaultString } type RPCError struct { FaultCode int FaultString string } func (e RPCError) Error() string { return fmt.Sprintf("RPC Error: (%d) %s", e.FaultCode, e.FaultString) } type recordObjectsResponse struct { responseFault XMLName xml.Name `xml:"methodResponse"` Params []RecordObj `xml:"params>param>value>array>data>value>struct"` } type RecordObj struct { Type string TTL int Priority int Rdata string RecordID int } func (r *RecordObj) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var name string for { t, err := d.Token() if err != nil { return err } switch tt := t.(type) { case xml.StartElement: switch tt.Name.Local { case "name": // The name of the record object: var s string if err = d.DecodeElement(&s, &start); err != nil { return err } name = strings.TrimSpace(s) case "string": // A string value of the record object: if err = r.decodeValueString(name, d, start); err != nil { return err } case "int": // An int value of the record object: if err = r.decodeValueInt(name, d, start); err != nil { return err } } case xml.EndElement: if tt == start.End() { return nil } } } } func (r *RecordObj) decodeValueString(name string, d *xml.Decoder, start xml.StartElement) error { var s string if err := d.DecodeElement(&s, &start); err != nil { return err } s = strings.TrimSpace(s) switch name { case "type": r.Type = s case "rdata": r.Rdata = s } return nil } func (r *RecordObj) decodeValueInt(name string, d *xml.Decoder, start xml.StartElement) error { var i int if err := d.DecodeElement(&i, &start); err != nil { return err } switch name { case "record_id": r.RecordID = i case "ttl": r.TTL = i case "priority": r.Priority = i } return nil } ================================================ FILE: providers/dns/loopia/loopia.go ================================================ // Package loopia implements a DNS provider for solving the DNS-01 challenge using loopia DNS. package loopia import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/loopia/internal" ) // Environment variables names. const ( envNamespace = "LOOPIA_" EnvAPIUser = envNamespace + "API_USER" EnvAPIPassword = envNamespace + "API_PASSWORD" EnvAPIURL = envNamespace + "API_URL" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) type dnsClient interface { AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error GetTXTRecords(ctx context.Context, domain, subdomain string) ([]internal.RecordObj, error) RemoveSubdomain(ctx context.Context, domain, subdomain string) error } // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIUser string APIPassword string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 40*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client dnsClient inProgressInfo map[string]int inProgressMu sync.Mutex // only for testing purpose. findZoneByFqdn func(fqdn string) (string, error) } // NewDNSProvider returns a DNSProvider instance configured for Loopia. // Credentials must be passed in the environment variables: // LOOPIA_API_USER, LOOPIA_API_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIUser, EnvAPIPassword) if err != nil { return nil, fmt.Errorf("loopia: %w", err) } config := NewDefaultConfig() config.APIUser = values[EnvAPIUser] config.APIPassword = values[EnvAPIPassword] config.BaseURL = env.GetOrDefaultString(EnvAPIURL, internal.DefaultBaseURL) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Loopia. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("loopia: the configuration of the DNS provider is nil") } if config.APIUser == "" || config.APIPassword == "" { return nil, errors.New("loopia: credentials missing") } // Min value for TTL is 300 if config.TTL < 300 { config.TTL = 300 } client := internal.NewClient(config.APIUser, config.APIPassword) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) if config.BaseURL != "" { client.BaseURL = config.BaseURL } return &DNSProvider{ config: config, client: client, findZoneByFqdn: dns01.FindZoneByFqdn, inProgressInfo: make(map[string]int), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) subDomain, authZone, err := d.splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("loopia: %w", err) } ctx := context.Background() err = d.client.AddTXTRecord(ctx, authZone, subDomain, d.config.TTL, info.Value) if err != nil { return fmt.Errorf("loopia: failed to add TXT record: %w", err) } txtRecords, err := d.client.GetTXTRecords(ctx, authZone, subDomain) if err != nil { return fmt.Errorf("loopia: failed to get TXT records: %w", err) } d.inProgressMu.Lock() defer d.inProgressMu.Unlock() for _, r := range txtRecords { if r.Rdata == info.Value { d.inProgressInfo[token] = r.RecordID return nil } } return errors.New("loopia: failed to find the stored TXT record") } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) subDomain, authZone, err := d.splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("loopia: %w", err) } d.inProgressMu.Lock() defer d.inProgressMu.Unlock() ctx := context.Background() err = d.client.RemoveTXTRecord(ctx, authZone, subDomain, d.inProgressInfo[token]) if err != nil { return fmt.Errorf("loopia: failed to remove TXT record: %w", err) } records, err := d.client.GetTXTRecords(ctx, authZone, subDomain) if err != nil { return fmt.Errorf("loopia: failed to get TXT records: %w", err) } if len(records) > 0 { return nil } err = d.client.RemoveSubdomain(ctx, authZone, subDomain) if err != nil { return fmt.Errorf("loopia: failed to remove subdomain: %w", err) } return nil } func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) { authZone, err := d.findZoneByFqdn(fqdn) if err != nil { return "", "", fmt.Errorf("could not find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, authZone) if err != nil { return "", "", err } return subDomain, dns01.UnFqdn(authZone), nil } ================================================ FILE: providers/dns/loopia/loopia.toml ================================================ Name = "Loopia" Description = '''''' URL = "https://loopia.com" Code = "loopia" Since = "v4.2.0" Example = ''' LOOPIA_API_USER=xxxxxxxx \ LOOPIA_API_PASSWORD=yyyyyyyy \ lego --dns loopia -d '*.example.com' -d example.com run ''' Additional = ''' ### API user You can [generate a new API user](https://customerzone.loopia.com/api/) from your account page. It needs to have the following permissions: * addZoneRecord * getZoneRecords * removeZoneRecord * removeSubdomain ''' [Configuration] [Configuration.Credentials] LOOPIA_API_USER = "API username" LOOPIA_API_PASSWORD = "API password" [Configuration.Additional] LOOPIA_API_URL = "API endpoint. Ex: https://api.loopia.se/RPCSERV or https://api.loopia.rs/RPCSERV" LOOPIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2400)" LOOPIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" LOOPIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" LOOPIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://www.loopia.com/api" ================================================ FILE: providers/dns/loopia/loopia_mock_test.go ================================================ package loopia import ( "context" "errors" "testing" "github.com/go-acme/lego/v4/providers/dns/loopia/internal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) const ( exampleDomain = "example.com" exampleSubDomain = "_acme-challenge" exampleRdata = "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM" ) func TestDNSProvider_Present(t *testing.T) { mockedFindZoneByFqdn := func(fqdn string) (string, error) { return exampleDomain + ".", nil } testCases := []struct { desc string getTXTRecordsError error getTXTRecordsReturn []internal.RecordObj addTXTRecordError error callAddTXTRecord bool callGetTXTRecords bool expectedError string expectedInProgressTokenInfo int }{ { desc: "Present OK", getTXTRecordsReturn: []internal.RecordObj{{Type: "TXT", Rdata: exampleRdata, RecordID: 12345678}}, callAddTXTRecord: true, callGetTXTRecords: true, expectedInProgressTokenInfo: 12345678, }, { desc: "AddTXTRecord fails", addTXTRecordError: errors.New("unknown error: 'ADDTXT'"), callAddTXTRecord: true, expectedError: "loopia: failed to add TXT record: unknown error: 'ADDTXT'", }, { desc: "GetTXTRecords fails", getTXTRecordsError: errors.New("unknown error: 'GETTXT'"), callAddTXTRecord: true, callGetTXTRecords: true, expectedError: "loopia: failed to get TXT records: unknown error: 'GETTXT'", }, { desc: "Failed to get ID", callAddTXTRecord: true, callGetTXTRecords: true, expectedError: "loopia: failed to find the stored TXT record", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIUser = "apiuser" config.APIPassword = "password" client := &mockedClient{} provider, err := NewDNSProviderConfig(config) require.NoError(t, err) provider.findZoneByFqdn = mockedFindZoneByFqdn provider.client = client if test.callAddTXTRecord { client.On("AddTXTRecord", exampleDomain, exampleSubDomain, config.TTL, exampleRdata).Return(test.addTXTRecordError) } if test.callGetTXTRecords { client.On("GetTXTRecords", exampleDomain, exampleSubDomain).Return(test.getTXTRecordsReturn, test.getTXTRecordsError) } err = provider.Present(exampleDomain, "token", "key") client.AssertExpectations(t) if test.expectedError == "" { require.NoError(t, err) assert.Equal(t, test.expectedInProgressTokenInfo, provider.inProgressInfo["token"]) } else { require.Error(t, err) assert.EqualError(t, err, test.expectedError) } }) } } func TestDNSProvider_Cleanup(t *testing.T) { mockedFindZoneByFqdn := func(fqdn string) (string, error) { return "example.com.", nil } testCases := []struct { desc string getTXTRecordsError error getTXTRecordsReturn []internal.RecordObj removeTXTRecordError error removeSubdomainError error callAddTXTRecord bool callGetTXTRecords bool callRemoveSubdomain bool expectedError string }{ { desc: "Cleanup Ok", callAddTXTRecord: true, callGetTXTRecords: true, callRemoveSubdomain: true, }, { desc: "removeTXTRecord failed", removeTXTRecordError: errors.New("authentication error"), callAddTXTRecord: true, expectedError: "loopia: failed to remove TXT record: authentication error", }, { desc: "removeSubdomain failed", removeSubdomainError: errors.New(`unknown error: "UNKNOWN_ERROR"`), callAddTXTRecord: true, callGetTXTRecords: true, callRemoveSubdomain: true, expectedError: `loopia: failed to remove subdomain: unknown error: "UNKNOWN_ERROR"`, }, { desc: "Don't call removeSubdomain when records", getTXTRecordsReturn: []internal.RecordObj{{Type: "TXT", Rdata: "LEFTOVER"}}, callAddTXTRecord: true, callGetTXTRecords: true, callRemoveSubdomain: false, }, { desc: "getTXTRecords failed", getTXTRecordsError: errors.New(`unknown error: "UNKNOWN_ERROR"`), callAddTXTRecord: true, callGetTXTRecords: true, callRemoveSubdomain: false, expectedError: `loopia: failed to get TXT records: unknown error: "UNKNOWN_ERROR"`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIUser = "apiuser" config.APIPassword = "password" client := &mockedClient{} provider, err := NewDNSProviderConfig(config) require.NoError(t, err) provider.findZoneByFqdn = mockedFindZoneByFqdn provider.client = client provider.inProgressInfo["token"] = 12345678 if test.callAddTXTRecord { client.On("RemoveTXTRecord", "example.com", "_acme-challenge", 12345678).Return(test.removeTXTRecordError) } if test.callGetTXTRecords { client.On("GetTXTRecords", "example.com", "_acme-challenge").Return(test.getTXTRecordsReturn, test.getTXTRecordsError) } if test.callRemoveSubdomain { client.On("RemoveSubdomain", "example.com", "_acme-challenge").Return(test.removeSubdomainError) } err = provider.CleanUp("example.com", "token", "key") client.AssertExpectations(t) if test.expectedError == "" { require.NoError(t, err) } else { require.Error(t, err) assert.EqualError(t, err, test.expectedError) } }) } } type mockedClient struct { mock.Mock } func (c *mockedClient) RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error { args := c.Called(domain, subdomain, recordID) return args.Error(0) } func (c *mockedClient) AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error { args := c.Called(domain, subdomain, ttl, value) return args.Error(0) } func (c *mockedClient) GetTXTRecords(ctx context.Context, domain, subdomain string) ([]internal.RecordObj, error) { args := c.Called(domain, subdomain) return args.Get(0).([]internal.RecordObj), args.Error(1) } func (c *mockedClient) RemoveSubdomain(ctx context.Context, domain, subdomain string) error { args := c.Called(domain, subdomain) return args.Error(0) } ================================================ FILE: providers/dns/loopia/loopia_test.go ================================================ package loopia import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIUser, EnvAPIPassword, EnvTTL, EnvPollingInterval, EnvPropagationTimeout, EnvHTTPTimeout). WithDomain(envDomain) func TestSplitDomain(t *testing.T) { provider := &DNSProvider{ findZoneByFqdn: func(fqdn string) (string, error) { return "example.com.", nil }, } testCases := []struct { desc string fqdn string subdomain string domain string }{ { desc: "single subdomain", fqdn: "subdomain.example.com", subdomain: "subdomain", domain: "example.com", }, { desc: "double subdomain", fqdn: "sub.domain.example.com", subdomain: "sub.domain", domain: "example.com", }, { desc: "asterisk subdomain", fqdn: "*.example.com", subdomain: "*", domain: "example.com", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { subdomain, domain, err := provider.splitDomain(test.fqdn) require.NoError(t, err) assert.Equal(t, test.subdomain, subdomain) assert.Equal(t, test.domain, domain) }) } } func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expectedError string }{ { desc: "success", envVars: map[string]string{ EnvAPIUser: "user", EnvAPIPassword: "secret", }, }, { desc: "missing API user", envVars: map[string]string{ EnvAPIUser: "", EnvAPIPassword: "secret", }, expectedError: "loopia: some credentials information are missing: LOOPIA_API_USER", }, { desc: "missing API password", envVars: map[string]string{ EnvAPIUser: "user", EnvAPIPassword: "", }, expectedError: "loopia: some credentials information are missing: LOOPIA_API_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expectedError: "loopia: some credentials information are missing: LOOPIA_API_USER,LOOPIA_API_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expectedError == "" { require.NoError(t, err) require.NotNil(t, p) } else { require.Error(t, err) require.EqualError(t, err, test.expectedError) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string config *Config expectedTTL int expectedError string }{ { desc: "success", config: &Config{ APIUser: "user", APIPassword: "secret", TTL: 3600, }, expectedTTL: 3600, }, { desc: "nil config user", expectedError: "loopia: the configuration of the DNS provider is nil", }, { desc: "empty user", config: &Config{ APIUser: "", APIPassword: "secret", TTL: 3600, }, expectedError: "loopia: credentials missing", }, { desc: "empty password", config: &Config{ APIUser: "user", APIPassword: "", TTL: 3600, }, expectedTTL: 3600, expectedError: "loopia: credentials missing", }, { desc: "too low TTL", config: &Config{ APIUser: "user", APIPassword: "secret", TTL: 299, }, expectedTTL: 300, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p, err := NewDNSProviderConfig(test.config) if test.expectedError == "" { require.NoError(t, err) require.NotNil(t, p) assert.Equal(t, test.expectedTTL, p.config.TTL) } else { require.Error(t, err) assert.EqualError(t, err, test.expectedError) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/luadns/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // defaultBaseURL represents the API endpoint to call. const defaultBaseURL = "https://api.luadns.com" // Client Lua DNS API client. type Client struct { apiUsername string apiToken string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiUsername, apiToken string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiUsername: apiUsername, apiToken: apiToken, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // ListZones gets all the hosted zones. // https://luadns.com/api.html#list-zones func (c *Client) ListZones(ctx context.Context) ([]DNSZone, error) { endpoint := c.baseURL.JoinPath("v1", "zones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var zones []DNSZone err = c.do(req, &zones) if err != nil { return nil, fmt.Errorf("could not list zones: %w", err) } return zones, nil } // CreateRecord creates a new record in a zone. // https://luadns.com/api.html#create-a-record func (c *Client) CreateRecord(ctx context.Context, zone DNSZone, newRecord DNSRecord) (*DNSRecord, error) { endpoint := c.baseURL.JoinPath("v1", "zones", strconv.Itoa(zone.ID), "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, newRecord) if err != nil { return nil, err } var record *DNSRecord err = c.do(req, &record) if err != nil { return nil, fmt.Errorf("could not create record %#v: %w", record, err) } return record, nil } // DeleteRecord deletes a record. // https://luadns.com/api.html#delete-a-record func (c *Client) DeleteRecord(ctx context.Context, record *DNSRecord) error { endpoint := c.baseURL.JoinPath("v1", "zones", strconv.Itoa(record.ZoneID), "records", strconv.Itoa(record.ID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, record) if err != nil { return err } err = c.do(req, nil) if err != nil { return fmt.Errorf("could not delete record %#v: %w", record, err) } return nil } func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth(c.apiUsername, c.apiToken) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errResp errorResponse err := json.Unmarshal(raw, &errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("status=%d: %w", resp.StatusCode, errResp) } ================================================ FILE: providers/dns/luadns/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder(apiToken string) *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("me", apiToken) client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("me", apiToken)) } func TestClient_ListZones(t *testing.T) { client := mockBuilder("secretA"). Route("GET /v1/zones", servermock.ResponseFromFixture("list_zones.json")). Build(t) zones, err := client.ListZones(t.Context()) require.NoError(t, err) expected := []DNSZone{ { ID: 1, Name: "example.com", Synced: false, QueriesCount: 0, RecordsCount: 3, AliasesCount: 0, RedirectsCount: 0, ForwardsCount: 0, TemplateID: 0, }, { ID: 2, Name: "example.net", Synced: false, QueriesCount: 0, RecordsCount: 3, AliasesCount: 0, RedirectsCount: 0, ForwardsCount: 0, TemplateID: 0, }, } assert.Equal(t, expected, zones) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder("secretB"). Route("POST /v1/zones/1/records", servermock.ResponseFromFixture("create_record.json"), servermock.CheckRequestJSONBody(`{"name":"example.com.","type":"MX","content":"10 mail.example.com.","ttl":300}`)). Build(t) zone := DNSZone{ID: 1} record := DNSRecord{ Name: "example.com.", Type: "MX", Content: "10 mail.example.com.", TTL: 300, } newRecord, err := client.CreateRecord(t.Context(), zone, record) require.NoError(t, err) expected := &DNSRecord{ ID: 100, Name: "example.com.", Type: "MX", Content: "10 mail.example.com.", TTL: 300, ZoneID: 1, } assert.Equal(t, expected, newRecord) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder("secretC"). Route("DELETE /v1/zones/1/records/2", servermock.ResponseFromFixture("delete_record.json"), servermock.CheckRequestJSONBody(`{"id":2,"name":"example.com.","type":"MX","content":"10 mail.example.com.","ttl":300,"zone_id":1}`)). Build(t) record := &DNSRecord{ ID: 2, Name: "example.com.", Type: "MX", Content: "10 mail.example.com.", TTL: 300, ZoneID: 1, } err := client.DeleteRecord(t.Context(), record) require.NoError(t, err) } ================================================ FILE: providers/dns/luadns/internal/fixtures/create_record.json ================================================ { "id": 100, "name": "example.com.", "type": "MX", "content": "10 mail.example.com.", "ttl": 300, "zone_id": 1, "created_at": "2015-01-17T14:04:35.251785849Z", "updated_at": "2015-01-17T14:04:35.251785972Z" } ================================================ FILE: providers/dns/luadns/internal/fixtures/delete_record.json ================================================ { "id": 100, "name": "example.com.", "type": "MX", "content": "10 mail.example.com.", "ttl": 300, "zone_id": 1, "created_at": "2015-01-17T14:04:35.251785849Z", "updated_at": "2015-01-17T14:04:35.251785972Z" } ================================================ FILE: providers/dns/luadns/internal/fixtures/list_zones.json ================================================ [ { "id": 1, "name": "example.com", "synced": false, "queries_count": 0, "records_count": 3, "aliases_count": 0, "redirects_count": 0, "forwards_count": 0, "template_id": 0 }, { "id": 2, "name": "example.net", "synced": false, "queries_count": 0, "records_count": 3, "aliases_count": 0, "redirects_count": 0, "forwards_count": 0, "template_id": 0 } ] ================================================ FILE: providers/dns/luadns/internal/types.go ================================================ package internal import "fmt" type errorResponse struct { Status string `json:"status"` RequestID string `json:"request_id"` Message string `json:"message"` } func (e errorResponse) Error() string { return fmt.Sprintf("status=%s, message=%s", e.Status, e.Message) } // DNSZone a DNS zone. type DNSZone struct { ID int `json:"id"` Name string `json:"name,omitempty"` Synced bool `json:"synced,omitempty"` QueriesCount int `json:"queries_count,omitempty"` RecordsCount int `json:"records_count,omitempty"` AliasesCount int `json:"aliases_count,omitempty"` RedirectsCount int `json:"redirects_count,omitempty"` ForwardsCount int `json:"forwards_count,omitempty"` TemplateID int `json:"template_id,omitempty"` } // DNSRecord a DNS record. type DNSRecord struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` ZoneID int `json:"zone_id,omitempty"` } ================================================ FILE: providers/dns/luadns/luadns.go ================================================ // Package luadns implements a DNS provider for solving the DNS-01 challenge using LuaDNS. package luadns import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/luadns/internal" ) // Environment variables names. const ( envNamespace = "LUADNS_" EnvAPIUsername = envNamespace + "API_USERNAME" EnvAPIToken = envNamespace + "API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIUsername string APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordsMu sync.Mutex records map[string]*internal.DNSRecord } // NewDNSProvider returns a DNSProvider instance configured for LuaDNS. // Credentials must be passed in the environment variables: // LUADNS_API_USERNAME and LUADNS_API_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIUsername, EnvAPIToken) if err != nil { return nil, fmt.Errorf("luadns: %w", err) } config := NewDefaultConfig() config.APIUsername = values[EnvAPIUsername] config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for LuaDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("luadns: the configuration of the DNS provider is nil") } if config.APIUsername == "" || config.APIToken == "" { return nil, errors.New("luadns: credentials missing") } if config.TTL < minTTL { return nil, fmt.Errorf("luadns: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.APIUsername, config.APIToken) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, records: make(map[string]*internal.DNSRecord), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zones, err := d.client.ListZones(ctx) if err != nil { return fmt.Errorf("luadns: failed to get zones: %w", err) } authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("luadns: could not find zone for domain %q: %w", domain, err) } zone := findZone(zones, dns01.UnFqdn(authZone)) if zone == nil { return fmt.Errorf("luadns: no matching zone found for domain %s", domain) } newRecord := internal.DNSRecord{ Name: info.EffectiveFQDN, Type: "TXT", Content: info.Value, TTL: d.config.TTL, } record, err := d.client.CreateRecord(ctx, *zone, newRecord) if err != nil { return fmt.Errorf("luadns: failed to create record: %w", err) } d.recordsMu.Lock() d.records[token] = record d.recordsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordsMu.Lock() record, ok := d.records[token] d.recordsMu.Unlock() if !ok { return fmt.Errorf("luadns: unknown record ID for '%s'", info.EffectiveFQDN) } err := d.client.DeleteRecord(context.Background(), record) if err != nil { return fmt.Errorf("luadns: failed to delete record: %w", err) } // Delete record from map d.recordsMu.Lock() delete(d.records, token) d.recordsMu.Unlock() return nil } func findZone(zones []internal.DNSZone, domain string) *internal.DNSZone { var result *internal.DNSZone for _, zone := range zones { if zone.Name != "" && strings.HasSuffix(domain, zone.Name) { if result == nil || len(zone.Name) > len(result.Name) { result = &zone } } } return result } ================================================ FILE: providers/dns/luadns/luadns.toml ================================================ Name = "LuaDNS" Description = '''''' URL = "https://luadns.com" Code = "luadns" Since = "v3.7.0" Example = ''' LUADNS_API_USERNAME=youremail \ LUADNS_API_TOKEN=xxxxxxxx \ lego --dns luadns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] LUADNS_API_USERNAME = "Username (your email)" LUADNS_API_TOKEN = "API token" [Configuration.Additional] LUADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" LUADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" LUADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" LUADNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://luadns.com/api.html" ================================================ FILE: providers/dns/luadns/luadns_test.go ================================================ package luadns import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/luadns/internal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIUsername, EnvAPIToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIUsername: "123", EnvAPIToken: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIUsername: "", EnvAPIToken: "", }, expected: "luadns: some credentials information are missing: LUADNS_API_USERNAME,LUADNS_API_TOKEN", }, { desc: "missing username", envVars: map[string]string{ EnvAPIUsername: "", EnvAPIToken: "456", }, expected: "luadns: some credentials information are missing: LUADNS_API_USERNAME", }, { desc: "missing api token", envVars: map[string]string{ EnvAPIUsername: "123", EnvAPIToken: "", }, expected: "luadns: some credentials information are missing: LUADNS_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string apiSecret string tll int expected string }{ { desc: "success", apiKey: "123", apiSecret: "456", tll: minTTL, }, { desc: "missing credentials", tll: minTTL, expected: "luadns: credentials missing", }, { desc: "missing username", apiSecret: "456", tll: minTTL, expected: "luadns: credentials missing", }, { desc: "missing api token", apiKey: "123", tll: minTTL, expected: "luadns: credentials missing", }, { desc: "invalid TTL", apiKey: "123", apiSecret: "456", tll: 30, expected: "luadns: invalid TTL, TTL (30) must be greater than 300", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIUsername = test.apiKey config.APIToken = test.apiSecret config.TTL = test.tll p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_findZone(t *testing.T) { testCases := []struct { desc string domain string zones []internal.DNSZone expected *internal.DNSZone }{ { desc: "simple domain", domain: "example.org", zones: []internal.DNSZone{ {Name: "example.org"}, {Name: "example.com"}, }, expected: &internal.DNSZone{Name: "example.org"}, }, { desc: "sub domain", domain: "aaa.example.org", zones: []internal.DNSZone{ {Name: "example.org"}, {Name: "aaa.example.org"}, {Name: "bbb.example.org"}, {Name: "example.com"}, }, expected: &internal.DNSZone{Name: "aaa.example.org"}, }, { desc: "empty zone name", domain: "example.org", zones: []internal.DNSZone{ {}, }, }, { desc: "not found", domain: "example.org", zones: []internal.DNSZone{ {Name: "example.net"}, {Name: "aaa.example.net"}, {Name: "bbb.example.net"}, {Name: "example.com"}, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() zone := findZone(test.zones, test.domain) assert.Equal(t, test.expected, zone) }) } } 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) } ================================================ FILE: providers/dns/mailinabox/mailinabox.go ================================================ // Package mailinabox implements a DNS provider for solving the DNS-01 challenge using Mail-in-a-Box. package mailinabox import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/mailinabox" ) // Environment variables names. const ( envNamespace = "MAILINABOX_" EnvEmail = envNamespace + "EMAIL" EnvPassword = envNamespace + "PASSWORD" EnvBaseURL = envNamespace + "BASE_URL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Email string Password string BaseURL string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *mailinabox.Client } // NewDNSProvider returns a DNSProvider instance configured for Mail-in-a-Box. // Credentials must be passed in the environment variables: // MAILINABOX_EMAIL, MAILINABOX_PASSWORD, and MAILINABOX_BASE_URL. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvBaseURL, EnvEmail, EnvPassword) if err != nil { return nil, fmt.Errorf("mailinabox: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvBaseURL] config.Email = values[EnvEmail] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for deSEC. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("mailinabox: the configuration of the DNS provider is nil") } if config.Email == "" || config.Password == "" { return nil, errors.New("mailinabox: incomplete credentials, missing email or password") } if config.BaseURL == "" { return nil, errors.New("mailinabox: missing base URL") } if config.HTTPClient == nil { config.HTTPClient = &http.Client{Timeout: 30 * time.Second} } config.HTTPClient = clientdebug.Wrap(config.HTTPClient) client, err := mailinabox.New(config.BaseURL, config.Email, config.Password, mailinabox.WithHTTPClient(config.HTTPClient)) if err != nil { return nil, fmt.Errorf("mailinabox: %w", err) } return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) record := mailinabox.Record{ Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", Value: info.Value, } _, err := d.client.DNS.AddRecord(ctx, record) if err != nil { return fmt.Errorf("mailinabox: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) record := mailinabox.Record{ Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", Value: info.Value, } _, err := d.client.DNS.RemoveRecord(ctx, record) if err != nil { return fmt.Errorf("mailinabox: remove record: %w", err) } return nil } ================================================ FILE: providers/dns/mailinabox/mailinabox.toml ================================================ Name = "Mail-in-a-Box" Description = '''''' URL = "https://mailinabox.email" Code = "mailinabox" Since = "v4.16.0" Example = ''' MAILINABOX_EMAIL=user@example.com \ MAILINABOX_PASSWORD=yyyy \ MAILINABOX_BASE_URL=https://box.example.com \ lego --dns mailinabox -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] MAILINABOX_EMAIL = "User email" MAILINABOX_PASSWORD = "User password" MAILINABOX_BASE_URL = "Base API URL (ex: https://box.example.com)" [Configuration.Additional] MAILINABOX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" MAILINABOX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" MAILINABOX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://mailinabox.email/api-docs.html" ================================================ FILE: providers/dns/mailinabox/mailinabox_test.go ================================================ package mailinabox import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvBaseURL, EnvEmail, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvBaseURL: "https://example.com", EnvEmail: "user@example.com", EnvPassword: "secret", }, }, { desc: "missing base URL", envVars: map[string]string{ EnvEmail: "user@example.com", EnvPassword: "secret", }, expected: "mailinabox: some credentials information are missing: MAILINABOX_BASE_URL", }, { desc: "missing email", envVars: map[string]string{ EnvBaseURL: "https://example.com", EnvPassword: "secret", }, expected: "mailinabox: some credentials information are missing: MAILINABOX_EMAIL", }, { desc: "missing password", envVars: map[string]string{ EnvBaseURL: "https://example.com", EnvEmail: "user@example.com", }, expected: "mailinabox: some credentials information are missing: MAILINABOX_PASSWORD", }, { desc: "missing all options", expected: "mailinabox: some credentials information are missing: MAILINABOX_BASE_URL,MAILINABOX_EMAIL,MAILINABOX_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string baseURL string email string password string expected string }{ { desc: "success", baseURL: "https://example.com", email: "user@example.com", password: "secret", }, { desc: "missing base URL", email: "user@example.com", password: "secret", expected: "mailinabox: missing base URL", }, { desc: "missing email", baseURL: "https://example.com", password: "secret", expected: "mailinabox: incomplete credentials, missing email or password", }, { desc: "missing password", baseURL: "https://example.com", email: "user@example.com", expected: "mailinabox: incomplete credentials, missing email or password", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.BaseURL = test.baseURL config.Email = test.email config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/manageengine/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://clouddns.manageengine.com/v1" // Client the ManageEngine CloudDNS API client. type Client struct { baseURL *url.URL httpClient *http.Client } // NewClient creates a new Client. func NewClient(hc *http.Client) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ baseURL: baseURL, httpClient: hc, } } // GetAllZones gets all zones. // https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#GET_All func (c *Client) GetAllZones(ctx context.Context) ([]Zone, error) { endpoint := c.baseURL.JoinPath("dns", "domain") req, err := newRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var results []Zone err = c.do(req, &results) if err != nil { return nil, err } return results, nil } // GetAllZoneRecords gets all "zone records" for a zone. // https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#GET_All_9 func (c *Client) GetAllZoneRecords(ctx context.Context, zoneID int) ([]ZoneRecord, error) { endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT") req, err := newRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var results []ZoneRecord err = c.do(req, &results) if err != nil { return nil, err } return results, nil } // DeleteZoneRecord deletes a "zone record". // https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#DEL_Delete_10 func (c *Client) DeleteZoneRecord(ctx context.Context, zoneID, domainID int) error { endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT", strconv.Itoa(domainID)) req, err := newRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } var results APIResponse return c.do(req, &results) } // CreateZoneRecord creates a "zone record". // https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#POST_Create_10 func (c *Client) CreateZoneRecord(ctx context.Context, zoneID int, record ZoneRecord) error { endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(zoneID), "records", "SPF_TXT", "/") req, err := newRequest(ctx, http.MethodPost, endpoint, []ZoneRecord{record}) if err != nil { return err } var results APIResponse return c.do(req, &results) } // UpdateZoneRecord update an existing "zone record". // https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation#PUT_Update_10 func (c *Client) UpdateZoneRecord(ctx context.Context, record ZoneRecord) error { if record.SpfTxtDomainID == 0 { return errors.New("SpfTxtDomainID is empty") } if record.ZoneID == 0 { return errors.New("ZoneID is empty") } endpoint := c.baseURL.JoinPath("dns", "domain", strconv.Itoa(record.ZoneID), "records", "SPF_TXT", strconv.Itoa(record.SpfTxtDomainID), "/") req, err := newRequest(ctx, http.MethodPut, endpoint, []ZoneRecord{record}) if err != nil { return err } var results APIResponse return c.do(req, &results) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { var body io.Reader = http.NoBody if payload != nil { buf := new(bytes.Buffer) err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } values := url.Values{} values.Set("config", buf.String()) body = strings.NewReader(values.Encode()) } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, &errAPI) } ================================================ FILE: providers/dns/manageengine/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(server.Client()) client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithAccept("application/json")) } func TestClient_GetAllZones(t *testing.T) { client := mockBuilder(). Route("GET /dns/domain", servermock.ResponseFromFixture("zone_domains_all.json")). Build(t) groups, err := client.GetAllZones(t.Context()) require.NoError(t, err) expected := []Zone{ { ZoneID: 1, ZoneName: "test.com.", ZoneTTL: 500, ZoneTargeting: true, Refresh: 43200, Retry: 3600, Expiry: 1209600, Minimum: 180, Org: 2, NsID: 1, Serial: 2022042206, Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, }, { ZoneID: 2, ZoneName: "yourdomain.com.", ZoneTTL: 1000, Refresh: 43200, Retry: 3600, Expiry: 1209600, Minimum: 180, Org: 2, Vanity: true, NsID: 1, Serial: 2022040608, Nss: []string{"ns11.yourdomain.com.", "ns21.yourdomain.net.", "ns31.yourdomain.com.", "ns41.yourdomain.net."}, }, { ZoneID: 20, ZoneName: "hello45.com.", ZoneTTL: 3000, Refresh: 43200, Retry: 3600, Expiry: 1209600, Minimum: 180, Org: 2, NsID: 1, Serial: 2022040711, Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, }, { ZoneID: 22, ZoneName: "zohoaccl.com.", ZoneTTL: 300, ZoneTargeting: true, Refresh: 43200, Retry: 3600, Expiry: 1209600, Minimum: 180, Org: 2, NsID: 1, Serial: 2022042206, Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, }, { ZoneID: 23, ZoneName: "zohocal.com.", ZoneTTL: 300, ZoneTargeting: true, Refresh: 43200, Retry: 3600, Expiry: 1209600, Minimum: 180, Org: 2, NsID: 1, Serial: 2022041310, Nss: []string{"ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net."}, }, } assert.Equal(t, expected, groups) } func TestClient_GetAllZones_error(t *testing.T) { client := mockBuilder(). Route("GET /dns/domain", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetAllZones(t.Context()) require.Error(t, err) require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") } func TestClient_GetAllZoneRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/domain/4/records/SPF_TXT", servermock.ResponseFromFixture("zone_records_all.json")). Build(t) groups, err := client.GetAllZoneRecords(t.Context(), 4) require.NoError(t, err) expected := []ZoneRecord{ { ZoneID: 4, SpfTxtDomainID: 6, DomainName: "spftest.example.com.", DomainTTL: 300, DomainLocationID: 1, RecordType: "SPF", Records: []Record{{ ID: 1, Values: []string{"necwcltpwxbz-noelget3jush-vop2xxvapot3eyq_0"}, DomainID: 6, }}, }, { ZoneID: 4, SpfTxtDomainID: 13, DomainName: "txt.example.com.", DomainTTL: 300, DomainLocationID: 1, RecordType: "TXT", Records: []Record{{ ID: 1, Values: []string{"v=spf1include:transmail.netinclude:example.com~all", "c-68e3oc4trm8w7piplscg7vgojmtkjrnrabr4king8"}, DomainID: 13, }}, }, } assert.Equal(t, expected, groups) } func TestClient_GetAllZoneRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /dns/domain/4/records/SPF_TXT", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetAllZoneRecords(t.Context(), 4) require.Error(t, err) require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") } func TestClient_DeleteZoneRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/domain/4/records/SPF_TXT/6", servermock.ResponseFromFixture("zone_record_delete.json")). Build(t) err := client.DeleteZoneRecord(t.Context(), 4, 6) require.NoError(t, err) } func TestClient_DeleteZoneRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/domain/4/records/SPF_TXT/6", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteZoneRecord(t.Context(), 4, 6) require.Error(t, err) require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") } func TestClient_CreateZoneRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/domain/4/records/SPF_TXT/", servermock.ResponseFromFixture("zone_record_create.json"), servermock.CheckHeader(). WithContentTypeFromURLEncoded(), servermock.CheckForm().Strict(). With("config", `[{"zone_id":1,"spf_txt_domain_id":2,"domain_name":"example.com","domain_ttl":120,"domain_location_id":3,"record_type":"TXT","records":[{"record_id":123,"value":["value1"],"domain_id":1}]}] `)). Build(t) record := ZoneRecord{ ZoneID: 1, SpfTxtDomainID: 2, DomainName: "example.com", DomainTTL: 120, DomainLocationID: 3, RecordType: "TXT", Records: []Record{ { ID: 123, Values: []string{"value1"}, Disabled: false, DomainID: 1, }, }, } err := client.CreateZoneRecord(t.Context(), 4, record) require.NoError(t, err) } func TestClient_CreateZoneRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /dns/domain/4/records/SPF_TXT/", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized), servermock.CheckHeader(). WithContentTypeFromURLEncoded()). Build(t) record := ZoneRecord{} err := client.CreateZoneRecord(t.Context(), 4, record) require.Error(t, err) require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") } func TestClient_CreateZoneRecord_error_bad_request(t *testing.T) { client := mockBuilder(). Route("POST /dns/domain/4/records/SPF_TXT/", servermock.ResponseFromFixture("error_bad_request.json"). WithStatusCode(http.StatusBadRequest), servermock.CheckHeader(). WithContentTypeFromURLEncoded()). Build(t) record := ZoneRecord{} err := client.CreateZoneRecord(t.Context(), 4, record) require.Error(t, err) require.EqualError(t, err, "[status code: 400] Invalid record format, Record should be in list.") } func TestClient_UpdateZoneRecord(t *testing.T) { client := mockBuilder(). Route("PUT /dns/domain/4/records/SPF_TXT/6/", servermock.ResponseFromFixture("zone_record_update.json"), servermock.CheckHeader(). WithContentTypeFromURLEncoded(), servermock.CheckForm().Strict(). With("config", `[{"zone_id":4,"spf_txt_domain_id":6,"records":null}] `)). Build(t) record := ZoneRecord{ SpfTxtDomainID: 6, ZoneID: 4, } err := client.UpdateZoneRecord(t.Context(), record) require.NoError(t, err) } func TestClient_UpdateZoneRecord_error(t *testing.T) { client := mockBuilder(). Route("PUT /dns/domain/4/records/SPF_TXT/6/", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized), servermock.CheckHeader(). WithContentTypeFromURLEncoded()). Build(t) record := ZoneRecord{ SpfTxtDomainID: 6, ZoneID: 4, } err := client.UpdateZoneRecord(t.Context(), record) require.Error(t, err) require.EqualError(t, err, "[status code: 401] Authentication credentials were not provided.") } ================================================ FILE: providers/dns/manageengine/internal/fixtures/error.json ================================================ { "detail": "Authentication credentials were not provided." } ================================================ FILE: providers/dns/manageengine/internal/fixtures/error_bad_request.json ================================================ { "error": "Invalid record format, Record should be in list." } ================================================ FILE: providers/dns/manageengine/internal/fixtures/zone_domains_all.json ================================================ [ { "zone_id": 1, "zone_name": "test.com.", "zone_ttl": 500, "zone_type": 0, "zone_targeting": true, "zone_logging": "{}", "zone_contact": "mathes.zoho.com", "refresh": 43200, "retry": 3600, "expiry": 1209600, "minimum": 180, "org": 2, "any_query": false, "dnssec": true, "vanity": false, "ns_id": 1, "serial": 2022042206, "ns": [ "ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net." ], "contact_group": [ "test_contact1", "test_contact2" ], "ds": [ { "record_id": 59, "keyTag": 36938, "algorithm": 13, "digestType": 1, "digest": "e9f03d176455d5d16f826b69f9ecb11f59be35e7", "domain_id": 30 }, { "record_id": 60, "keyTag": 36938, "algorithm": 13, "digestType": 2, "digest": "7ea640a8668eafd9d89a9b2e9994f5fcfb1dee0668d1e93ba556aa57ac047f96", "domain_id": 30 } ] }, { "zone_id": 2, "zone_name": "yourdomain.com.", "zone_ttl": 1000, "zone_type": 0, "zone_targeting": false, "zone_logging": "{}", "zone_contact": "contact.yourdomain.com", "refresh": 43200, "retry": 3600, "expiry": 1209600, "minimum": 180, "org": 2, "any_query": false, "dnssec": false, "vanity": true, "vanity_grp": "yourdomain", "ns_id": 1, "serial": 2022040608, "ns": [ "ns11.yourdomain.com.", "ns21.yourdomain.net.", "ns31.yourdomain.com.", "ns41.yourdomain.net." ] }, { "zone_id": 20, "zone_name": "hello45.com.", "zone_ttl": 3000, "zone_targeting": false, "zone_logging": "{}", "zone_contact": "mathes.zoho.com", "refresh": 43200, "retry": 3600, "expiry": 1209600, "minimum": 180, "org": 2, "any_query": false, "dnssec": false, "ns_id": 1, "serial": 2022040711, "ns": [ "ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net." ] }, { "zone_id": 22, "zone_name": "zohoaccl.com.", "zone_ttl": 300, "zone_type": 0, "zone_targeting": true, "zone_logging": "{}", "zone_contact": "networkone.zohocorp.com", "refresh": 43200, "retry": 3600, "expiry": 1209600, "minimum": 180, "org": 2, "any_query": false, "dnssec": false, "ns_id": 1, "serial": 2022042206, "ns": [ "ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net." ] }, { "zone_id": 23, "zone_name": "zohocal.com.", "zone_ttl": 300, "zone_type": 0, "zone_targeting": true, "zone_logging": "{}", "zone_contact": "mathes.zoho.com", "refresh": 43200, "retry": 3600, "expiry": 1209600, "minimum": 180, "org": 2, "any_query": false, "dnssec": false, "ns_id": 1, "serial": 2022041310, "ns": [ "ns11.zns-53.com.", "ns21.zns-53.net.", "ns31.zns-53.com.", "ns41.zns-53.net." ] } ] ================================================ FILE: providers/dns/manageengine/internal/fixtures/zone_record_create.json ================================================ { "message": "Record created successfully" } ================================================ FILE: providers/dns/manageengine/internal/fixtures/zone_record_delete.json ================================================ { "message": "Record deleted successfully" } ================================================ FILE: providers/dns/manageengine/internal/fixtures/zone_record_update.json ================================================ { "message": "Record updated successfully" } ================================================ FILE: providers/dns/manageengine/internal/fixtures/zone_records_all.json ================================================ [ { "spf_txt_domain_id": 6, "zone_id": 4, "domain_name": "spftest.example.com.", "domain_ttl": 300, "domain_location_id": 1, "record_type": "SPF", "records": [ { "record_id": 1, "value": [ "necwcltpwxbz-noelget3jush-vop2xxvapot3eyq_0" ], "disabled": false, "domain_id": 6 } ] }, { "spf_txt_domain_id": 13, "zone_id": 4, "domain_name": "txt.example.com.", "domain_ttl": 300, "domain_maxhost": 1, "domain_location_id": 1, "record_type": "TXT", "records": [ { "record_id": 1, "value": [ "v=spf1include:transmail.netinclude:example.com~all", "c-68e3oc4trm8w7piplscg7vgojmtkjrnrabr4king8" ], "disabled": false, "domain_id": 13 } ] } ] ================================================ FILE: providers/dns/manageengine/internal/identity.go ================================================ package internal import ( "context" "net/http" "golang.org/x/oauth2/clientcredentials" ) const defaultAuthURL = "https://clouddns.manageengine.com/oauth2/token/" func CreateOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client { config := &clientcredentials.Config{ TokenURL: defaultAuthURL, ClientID: clientID, ClientSecret: clientSecret, } return config.Client(ctx) } ================================================ FILE: providers/dns/manageengine/internal/types.go ================================================ package internal import ( "strings" ) type APIError struct { Message string `json:"error"` Detail string `json:"detail"` } func (a *APIError) Error() string { var msg []string if a.Message != "" { msg = append(msg, a.Message) } if a.Detail != "" { msg = append(msg, a.Detail) } return strings.Join(msg, " ") } type APIResponse struct { Message string `json:"message,omitempty"` } type ZoneRecord struct { ZoneID int `json:"zone_id,omitempty"` SpfTxtDomainID int `json:"spf_txt_domain_id,omitempty"` DomainName string `json:"domain_name,omitempty"` DomainTTL int `json:"domain_ttl,omitempty"` DomainLocationID int `json:"domain_location_id,omitempty"` RecordType string `json:"record_type,omitempty"` Records []Record `json:"records"` } type Record struct { ID int `json:"record_id,omitempty"` Values []string `json:"value,omitempty"` Disabled bool `json:"disabled,omitempty"` DomainID int `json:"domain_id,omitempty"` } type Zone struct { ZoneID int `json:"zone_id"` ZoneName string `json:"zone_name"` ZoneTTL int `json:"zone_ttl"` ZoneType int `json:"zone_type,omitempty"` ZoneTargeting bool `json:"zone_targeting"` Refresh int `json:"refresh"` Retry int `json:"retry"` Expiry int `json:"expiry"` Minimum int `json:"minimum"` Org int `json:"org"` AnyQuery bool `json:"any_query"` Vanity bool `json:"vanity,omitempty"` NsID int `json:"ns_id"` Serial int `json:"serial"` Nss []string `json:"ns"` } ================================================ FILE: providers/dns/manageengine/manageengine.go ================================================ // Package manageengine implements a DNS provider for solving the DNS-01 challenge using ManageEngine CloudDNS. package manageengine import ( "context" "errors" "fmt" "slices" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/manageengine/internal" ) // Environment variables names. const ( envNamespace = "MANAGEENGINE_" EnvClientID = envNamespace + "CLIENT_ID" EnvClientSecret = envNamespace + "CLIENT_SECRET" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) // Config is used to configure the creation of the DNSProvider. type Config struct { ClientID string ClientSecret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // 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), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for ManageEngine CloudDNS. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvClientID, EnvClientSecret) if err != nil { return nil, fmt.Errorf("manageengine: %w", err) } config := NewDefaultConfig() config.ClientID = values[EnvClientID] config.ClientSecret = values[EnvClientSecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ManageEngine CloudDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("manageengine: the configuration of the DNS provider is nil") } if config.ClientID == "" || config.ClientSecret == "" { return nil, errors.New("manageengine: credentials missing") } return &DNSProvider{ config: config, client: internal.NewClient( clientdebug.Wrap( internal.CreateOAuthClient(context.Background(), config.ClientID, config.ClientSecret), ), ), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("manageengine: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.findZoneID(ctx, authZone) if err != nil { return fmt.Errorf("manageengine: find zone ID: %w", err) } zoneRecord, err := d.findZoneRecord(ctx, zoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("manageengine: find zone record: %w", err) } // Update the existing zone record. if zoneRecord != nil { for _, record := range zoneRecord.Records { if slices.Contains(record.Values, info.Value) { continue } zr := internal.ZoneRecord{ ZoneID: zoneID, SpfTxtDomainID: zoneRecord.SpfTxtDomainID, DomainName: info.EffectiveFQDN, DomainTTL: d.config.TTL, RecordType: "TXT", Records: []internal.Record{{ Values: append(record.Values, info.Value), DomainID: zoneRecord.SpfTxtDomainID, }}, } // Update the zone record. err = d.client.UpdateZoneRecord(ctx, zr) if err != nil { return fmt.Errorf("manageengine: update zone record: %w", err) } return nil } return errors.New("manageengine: zone already contains the TXT record value") } // Create a new zone record. record := internal.ZoneRecord{ ZoneID: zoneID, DomainName: info.EffectiveFQDN, DomainTTL: d.config.TTL, RecordType: "TXT", Records: []internal.Record{{ Values: []string{info.Value}, }}, } err = d.client.CreateZoneRecord(ctx, zoneID, record) if err != nil { return fmt.Errorf("manageengine: create zone record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("manageengine: could not find zone for domain %q: %w", domain, err) } zoneID, err := d.findZoneID(ctx, authZone) if err != nil { return fmt.Errorf("manageengine: find zone ID: %w", err) } zoneRecord, err := d.findZoneRecord(ctx, zoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("manageengine: find zone record: %w", err) } for _, record := range zoneRecord.Records { if !slices.Contains(record.Values, info.Value) { continue } // Delete the zone record. if len(record.Values) <= 1 { err = d.client.DeleteZoneRecord(ctx, zoneID, zoneRecord.SpfTxtDomainID) if err != nil { return fmt.Errorf("manageengine: delete zone record: %w", err) } return nil } // Update the zone record. var values []string for _, value := range record.Values { if value != info.Value { values = append(values, value) } } zr := internal.ZoneRecord{ ZoneID: zoneID, SpfTxtDomainID: zoneRecord.SpfTxtDomainID, DomainName: info.EffectiveFQDN, DomainTTL: d.config.TTL, RecordType: "TXT", Records: []internal.Record{{ Values: values, DomainID: zoneRecord.SpfTxtDomainID, }}, } err = d.client.UpdateZoneRecord(ctx, zr) if err != nil { return fmt.Errorf("manageengine: create zone record: %w", err) } return nil } return 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 } func (d *DNSProvider) findZoneID(ctx context.Context, authZone string) (int, error) { zones, err := d.client.GetAllZones(ctx) if err != nil { return 0, fmt.Errorf("get all zone groups: %w", err) } for _, zone := range zones { if strings.EqualFold(zone.ZoneName, authZone) { return zone.ZoneID, nil } } return 0, fmt.Errorf("zone not found %s", authZone) } func (d *DNSProvider) findZoneRecord(ctx context.Context, zoneID int, fqdn string) (*internal.ZoneRecord, error) { zoneRecords, err := d.client.GetAllZoneRecords(ctx, zoneID) if err != nil { return nil, fmt.Errorf("get all zone records: %w", err) } for _, zoneRecord := range zoneRecords { if !strings.EqualFold(zoneRecord.DomainName, fqdn) { continue } if strings.EqualFold(zoneRecord.RecordType, "TXT") { return &zoneRecord, nil } } return nil, nil } ================================================ FILE: providers/dns/manageengine/manageengine.toml ================================================ Name = "ManageEngine CloudDNS" Description = '''''' URL = "https://clouddns.manageengine.com" Code = "manageengine" Since = "v4.21.0" Example = ''' MANAGEENGINE_CLIENT_ID="xxx" \ MANAGEENGINE_CLIENT_SECRET="yyy" \ lego --dns manageengine -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] MANAGEENGINE_CLIENT_ID = "Client ID" MANAGEENGINE_CLIENT_SECRET = "Client Secret" [Configuration.Additional] MANAGEENGINE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" MANAGEENGINE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" MANAGEENGINE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://pitstop.manageengine.com/portal/en/kb/articles/manageengine-clouddns-rest-api-documentation" ================================================ FILE: providers/dns/manageengine/manageengine_test.go ================================================ package manageengine import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvClientID, EnvClientSecret).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvClientID: "abc", EnvClientSecret: "secret", }, }, { desc: "missing client ID", envVars: map[string]string{ EnvClientID: "", EnvClientSecret: "secret", }, expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_ID", }, { desc: "missing client secret", envVars: map[string]string{ EnvClientID: "abc", EnvClientSecret: "", }, expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_SECRET", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "manageengine: some credentials information are missing: MANAGEENGINE_CLIENT_ID,MANAGEENGINE_CLIENT_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string clientID string clientSecret string expected string }{ { desc: "success", clientID: "abc", clientSecret: "secret", }, { desc: "missing client ID", clientSecret: "secret", expected: "manageengine: credentials missing", }, { desc: "missing client secret", clientID: "abc", expected: "manageengine: credentials missing", }, { desc: "missing credentials", expected: "manageengine: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.ClientID = test.clientID config.ClientSecret = test.clientSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/manual/manual.go ================================================ package manual import ( "github.com/go-acme/lego/v4/challenge/dns01" ) // DNSProvider is an implementation of the ChallengeProvider interface. type DNSProvider = dns01.DNSProviderManual // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { return &DNSProvider{}, nil } ================================================ FILE: providers/dns/manual/manual.toml ================================================ Name = "Manual" Description = '''Solving the DNS-01 challenge using CLI prompt.''' Code = "manual" Since = "v0.3.0" Example = ''' lego --dns manual -d '*.example.com' -d example.com run ''' Additional = ''' ## Example To start using the CLI prompt "provider", start lego with `--dns manual`: ```console $ lego --dns manual -d example.com run ``` What follows are a few log print-outs, interspersed with some prompts, asking for you to do perform some actions: ```txt No key found for account you@example.com. Generating a P256 key. Saved key to ./.lego/accounts/acme-v02.api.letsencrypt.org/you@example.com/keys/you@example.com.key Please review the TOS at https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf Do you accept the TOS? Y/n ``` If you accept the linked Terms of Service, hit `Enter`. ```txt [INFO] acme: Registering account for you@example.com !!!! HEADS UP !!!! Your account credentials have been saved in your configuration directory at "./.lego/accounts". You should make a secure backup of this folder now. This configuration directory will also contain private keys generated by lego and certificates obtained from the ACME server. Making regular backups of this folder is ideal. [INFO] [example.com] acme: Obtaining bundled SAN certificate [INFO] [example.com] AuthURL: https://acme-v02.api.letsencrypt.org/acme/authz-v3/2345678901 [INFO] [example.com] acme: Could not find solver for: tls-alpn-01 [INFO] [example.com] acme: Could not find solver for: http-01 [INFO] [example.com] acme: use dns-01 solver [INFO] [example.com] acme: Preparing to solve DNS-01 lego: Please create the following TXT record in your example.com. zone: _acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5q2JQ" lego: Press 'Enter' when you are done ``` Do as instructed, and create the TXT records, and hit `Enter`. ```txt [INFO] [example.com] acme: Trying to solve DNS-01 [INFO] [example.com] acme: Checking DNS record propagation using [192.168.8.1:53] [INFO] Wait for propagation [timeout: 1m0s, interval: 2s] [INFO] [example.com] acme: Waiting for DNS record propagation. [INFO] [example.com] The server validated our request [INFO] [example.com] acme: Cleaning DNS-01 challenge lego: You can now remove this TXT record from your example.com. zone: _acme-challenge.example.com. 120 IN TXT "hX0dPkG6Gfs9hUvBAchQclkyyoEKbShbpvJ9mY5q2JQ" [INFO] [example.com] acme: Validations succeeded; requesting certificates [INFO] [example.com] Server responded with a certificate. ``` As mentioned, you can now remove the TXT record again. ''' ================================================ FILE: providers/dns/manual/manual_test.go ================================================ package manual import ( "io" "os" "testing" "github.com/stretchr/testify/require" ) func TestDNSProviderManual(t *testing.T) { backupStdin := os.Stdin defer func() { os.Stdin = backupStdin }() testCases := []struct { desc string input string expectError bool }{ { desc: "Press enter", input: "ok\n", }, { desc: "Missing enter", input: "ok", expectError: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { file, err := os.CreateTemp(t.TempDir(), "lego_test") require.NoError(t, err) t.Cleanup(func() { _ = file.Close() }) _, err = file.WriteString(test.input) require.NoError(t, err) _, err = file.Seek(0, io.SeekStart) require.NoError(t, err) os.Stdin = file manualProvider, err := NewDNSProvider() require.NoError(t, err) err = manualProvider.Present("example.com", "", "") if test.expectError { require.Error(t, err) } else { require.NoError(t, err) err = manualProvider.CleanUp("example.com", "", "") require.NoError(t, err) } }) } } ================================================ FILE: providers/dns/metaname/metaname.go ================================================ // Package metaname implements a DNS provider for solving the DNS-01 challenge using Metaname. package metaname import ( "context" "errors" "fmt" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/nzdjb/go-metaname" ) // Environment variables names. const ( envNamespace = "METANAME_" EnvAccountReference = envNamespace + "ACCOUNT_REFERENCE" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AccountReference string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *metaname.MetanameClient records map[string]string recordsMu sync.Mutex } // NewDNSProvider returns a new DNS provider // using environment variable METANAME_API_KEY for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccountReference, EnvAPIKey) if err != nil { return nil, fmt.Errorf("metaname: %w", err) } config := NewDefaultConfig() config.AccountReference = values[EnvAccountReference] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Metaname. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("metaname: the configuration of the DNS provider is nil") } if config.AccountReference == "" { return nil, errors.New("metaname: missing account reference") } if config.APIKey == "" { return nil, errors.New("metaname: missing api key") } return &DNSProvider{ config: config, client: metaname.NewMetanameClient(config.AccountReference, config.APIKey), records: make(map[string]string), }, nil } func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("metaname: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("metaname: could not extract subDomain: %w", err) } ctx := context.Background() r := metaname.ResourceRecord{ Name: subDomain, Type: "TXT", Aux: nil, Ttl: d.config.TTL, Data: info.Value, } ref, err := d.client.CreateDnsRecord(ctx, authZone, r) if err != nil { return fmt.Errorf("metaname: add record: %w", err) } d.recordsMu.Lock() d.records[token] = ref d.recordsMu.Unlock() return nil } func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("metaname: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) ctx := context.Background() d.recordsMu.Lock() ref, ok := d.records[token] d.recordsMu.Unlock() if !ok { return fmt.Errorf("metaname: unknown ref for %s", info.EffectiveFQDN) } err = d.client.DeleteDnsRecord(ctx, authZone, ref) if err != nil { return fmt.Errorf("metaname: delete record: %w", err) } d.recordsMu.Lock() delete(d.records, token) d.recordsMu.Unlock() return 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 } ================================================ FILE: providers/dns/metaname/metaname.toml ================================================ Name = "Metaname" Description = '''''' URL = "https://metaname.net" Code = "metaname" Since = "v4.13.0" Example = ''' METANAME_ACCOUNT_REFERENCE=xxxx \ METANAME_API_KEY=yyyyyyy \ lego --dns metaname -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] METANAME_ACCOUNT_REFERENCE = "The four-digit reference of a Metaname account" METANAME_API_KEY = "API Key" [Configuration.Additional] METANAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" METANAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" METANAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://metaname.net/api/1.1/doc" GoClient = "https://github.com/nzdjb/go-metaname" ================================================ FILE: providers/dns/metaname/metaname_test.go ================================================ package metaname import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAccountReference, EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccountReference: "user", EnvAPIKey: "secret", }, }, { desc: "missing username", envVars: map[string]string{ EnvAccountReference: "", EnvAPIKey: "secret", }, expected: "metaname: some credentials information are missing: METANAME_ACCOUNT_REFERENCE", }, { desc: "missing password", envVars: map[string]string{ EnvAccountReference: "user", EnvAPIKey: "", }, expected: "metaname: some credentials information are missing: METANAME_API_KEY", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "metaname: some credentials information are missing: METANAME_ACCOUNT_REFERENCE,METANAME_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accountReference string apiKey string expected string }{ { desc: "success", accountReference: "user", apiKey: "secret", }, { desc: "missing username", apiKey: "secret", expected: "metaname: missing account reference", }, { desc: "missing password", accountReference: "user", expected: "metaname: missing api key", }, { desc: "missing all", expected: "metaname: missing account reference", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccountReference = test.accountReference config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/metaregistrar/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.metaregistrar.com" const tokenHeader = "token" // Client is a client to interact with the Metaregistrar API. type Client struct { token string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(token string) (*Client, error) { if token == "" { return nil, errors.New("token missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // UpdateDNSZone updates the DNS zone for a domain. // To add or remove a TXT record we make a PATCH request. // https://metaregistrar.dev/docu/metaapi/requests/patch_Update_dns_zone.html func (c *Client) UpdateDNSZone(ctx context.Context, domain string, updateRequest DNSZoneUpdateRequest) (*DNSZoneUpdateResponse, error) { endpoint := c.baseURL.JoinPath("dnszone", domain) req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, updateRequest) if err != nil { return nil, err } result := &DNSZoneUpdateResponse{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Add(tokenHeader, c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/metaregistrar/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With(tokenHeader, "secret")) } func TestClient_UpdateDNSZone(t *testing.T) { client := mockBuilder(). Route("PATCH /dnszone/example.com", servermock.ResponseFromFixture("update-dns-zone.json"), servermock.CheckRequestJSONBody(`{"add":[{"name":"@","type":"TXT","ttl":60,"content":"value"}]}`)). Build(t) updateRequest := DNSZoneUpdateRequest{ Add: []Record{{ Name: "@", Type: "TXT", TTL: 60, Content: "value", }}, } response, err := client.UpdateDNSZone(t.Context(), "example.com", updateRequest) require.NoError(t, err) expected := &DNSZoneUpdateResponse{ ResponseID: "mapi1_cb46ad8790b62b76535bd3102bd282aec83b894c", Status: "ok", Message: "Command completed successfully", } assert.Equal(t, expected, response) } func TestClient_UpdateDNSZone_error(t *testing.T) { testCases := []struct { desc string filename string expected string }{ { desc: "authentication error", filename: "error.json", expected: "invalid_token: the supplied token is invalid", }, { desc: "API error", filename: "error-response.json", expected: "error: does_not_exist: This server does not exist", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(). Route("PATCH /dnszone/example.com", servermock.ResponseFromFixture(test.filename). WithStatusCode(http.StatusUnprocessableEntity)). Build(t) updateRequest := DNSZoneUpdateRequest{ Add: []Record{{ Name: "@", Type: "TXT", TTL: 60, Content: "value", }}, } _, err := client.UpdateDNSZone(t.Context(), "example.com", updateRequest) require.EqualError(t, err, test.expected) }) } } ================================================ FILE: providers/dns/metaregistrar/internal/fixtures/error-response.json ================================================ { "responseId": "1_0a407cb0634a56374ba80f863fda53ae37fd0042", "status": "error", "errorCode": "does_not_exist", "errorMessage": "This server does not exist" } ================================================ FILE: providers/dns/metaregistrar/internal/fixtures/error.json ================================================ { "error": "invalid_token", "message": "the supplied token is invalid" } ================================================ FILE: providers/dns/metaregistrar/internal/fixtures/update-dns-zone.json ================================================ { "responseId": "mapi1_cb46ad8790b62b76535bd3102bd282aec83b894c", "status": "ok", "message": "Command completed successfully" } ================================================ FILE: providers/dns/metaregistrar/internal/types.go ================================================ package internal import ( "strings" ) // APIError It's a mix of documented and undocumented fields. // Note: the documentation is inconsistent: the names of property are not the same as the JSON sample. // https://metaregistrar.dev/docu/metaapi/requests/response_ErrorResponse.html type APIError struct { ResponseID string `json:"responseId,omitempty"` Status string `json:"status,omitempty"` Message string `json:"message,omitempty"` Err string `json:"error,omitempty"` ErrorCode string `json:"errorCode,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` } func (e *APIError) Error() string { var msg []string if e.Status != "" { msg = append(msg, e.Status) } if e.Err != "" { msg = append(msg, e.Err) } if e.ErrorCode != "" { msg = append(msg, e.ErrorCode) } if e.Message != "" { msg = append(msg, e.Message) } if e.ErrorMessage != "" { msg = append(msg, e.ErrorMessage) } return strings.Join(msg, ": ") } type Record struct { Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` TTL int `json:"ttl,omitempty"` Content string `json:"content,omitempty"` Priority int `json:"priority,omitempty"` Disabled bool `json:"disabled,omitempty"` } // DNSZoneUpdateRequest is the representation of DnszoneUpdateRequest object. // https://metaregistrar.dev/docu/metaapi/requests/request_DnszoneUpdateRequest.html type DNSZoneUpdateRequest struct { Add []Record `json:"add,omitempty"` Remove []Record `json:"rem,omitempty"` } // DNSZoneUpdateResponse is the representation of DnszoneUpdateResponse object. // https://metaregistrar.dev/docu/metaapi/requests/response_DnszoneUpdateResponse.html type DNSZoneUpdateResponse struct { ResponseID string `json:"responseId,omitempty"` Status string `json:"status,omitempty"` Message string `json:"message,omitempty"` } ================================================ FILE: providers/dns/metaregistrar/metaregistrar.go ================================================ // Package metaregistrar implements a DNS provider for solving the DNS-01 challenge using Metaregistrar. package metaregistrar import ( "context" "errors" "fmt" "net/http" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/metaregistrar/internal" ) // Environment variables names. const ( envNamespace = "METAREGISTRAR_" EnvToken = envNamespace + "API_TOKEN" 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 { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Metaregistrar. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("metaregistrar: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Metaregistrar. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("metaregistrar: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIToken) if err != nil { return nil, fmt.Errorf("metaregistrar: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("metaregistrar: could not find zone for domain %q: %w", domain, err) } updateRequest := internal.DNSZoneUpdateRequest{ Add: []internal.Record{{ Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", TTL: d.config.TTL, Content: info.Value, }}, } _, err = d.client.UpdateDNSZone(context.Background(), dns01.UnFqdn(authZone), updateRequest) if err != nil { return fmt.Errorf("metaregistrar: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("metaregistrar: could not find zone for domain %q: %w", domain, err) } updateRequest := internal.DNSZoneUpdateRequest{ Remove: []internal.Record{{ Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", TTL: d.config.TTL, Content: strconv.Quote(info.Value), }}, } _, err = d.client.UpdateDNSZone(context.Background(), dns01.UnFqdn(authZone), updateRequest) if err != nil { return fmt.Errorf("metaregistrar: %w", err) } return 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 } ================================================ FILE: providers/dns/metaregistrar/metaregistrar.toml ================================================ Name = "Metaregistrar" Description = '''''' URL = "https://metaregistrar.com/" Code = "metaregistrar" Since = "v4.23.0" Example = ''' METAREGISTRAR_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns metaregistrar -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] METAREGISTRAR_API_TOKEN = "The API token" [Configuration.Additional] METAREGISTRAR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" METAREGISTRAR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" METAREGISTRAR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" METAREGISTRAR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://metaregistrar.dev/docu/metaapi/" ================================================ FILE: providers/dns/metaregistrar/metaregistrar_test.go ================================================ package metaregistrar import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "token", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "metaregistrar: some credentials information are missing: METAREGISTRAR_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "token", }, { desc: "missing credentials", expected: "metaregistrar: token missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/mijnhost/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://mijn.host/api/v2/" const authorizationHeader = "API-Key" // Client a mijn.host DNS API client. type Client struct { apiKey string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // ListDomains Retrieve all domains from an account. // https://mijn.host/api/doc/api-3563872 func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { endpoint := c.baseURL.JoinPath("domains") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } var results Response[DomainData] err = c.do(req, &results) if err != nil { return nil, err } return results.Data.Domains, nil } // GetRecords Retrieve DNS records of specific domain. // https://mijn.host/api/doc/api-3563906 func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) { endpoint := c.baseURL.JoinPath("domains", domain, "dns") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } var results Response[RecordData] err = c.do(req, &results) if err != nil { return nil, err } return results.Data.Records, nil } // UpdateRecords Update DNS records of specific domain. // https://mijn.host/api/doc/api-3563907 func (c *Client) UpdateRecords(ctx context.Context, domain string, records []Record) error { endpoint := c.baseURL.JoinPath("domains", domain, "dns") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, RecordData{Records: records}) if err != nil { return fmt.Errorf("create request: %w", err) } err = c.do(req, nil) if err != nil { return err } return nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/mijnhost/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "secret" func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(apiKey) client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With(authorizationHeader, apiKey), ) } func TestClient_ListDomains(t *testing.T) { client := mockBuilder(). Route("GET /domains", servermock.ResponseFromFixture("list-domains.json")). Build(t) domains, err := client.ListDomains(t.Context()) require.NoError(t, err) expected := []Domain{{ ID: 1000, Domain: "example.com", RenewalDate: "2030-01-01", Status: "Active", StatusID: 1, Tags: []string{"my-tag"}, }} assert.Equal(t, expected, domains) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /domains/example.com/dns", servermock.ResponseFromFixture("get-dns-records.json")). Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ { Type: "A", Name: "example.com.", Value: "135.226.123.12", TTL: 900, }, { Type: "AAAA", Name: "example.com.", Value: "2009:21d0:322:6100::5:c92b", TTL: 900, }, { Type: "MX", Name: "example.com.", Value: "10 mail.example.com.", TTL: 900, }, { Type: "TXT", Name: "example.com.", Value: "v=spf1 include:spf.mijn.host ~all", TTL: 900, }, } assert.Equal(t, expected, records) } func TestClient_UpdateRecords(t *testing.T) { client := mockBuilder(). Route("PUT /domains/example.com/dns", servermock.ResponseFromFixture("update-dns-records.json"), servermock.CheckRequestJSONBody(`{"records":[{"type":"TXT","name":"foo","value":"value1","ttl":120}]}`)). Build(t) records := []Record{{ Type: "TXT", Name: "foo", Value: "value1", TTL: 120, }} err := client.UpdateRecords(t.Context(), "example.com", records) require.NoError(t, err) } ================================================ FILE: providers/dns/mijnhost/internal/fixtures/error.json ================================================ { "status": 400, "status_description": "Wrong request method" } ================================================ FILE: providers/dns/mijnhost/internal/fixtures/get-dns-records.json ================================================ { "status": 200, "status_description": "Request successful", "data": { "domain": "example.com", "records": [ { "type": "A", "name": "example.com.", "value": "135.226.123.12", "ttl": 900 }, { "type": "AAAA", "name": "example.com.", "value": "2009:21d0:322:6100::5:c92b", "ttl": 900 }, { "type": "MX", "name": "example.com.", "value": "10 mail.example.com.", "ttl": 900 }, { "type": "TXT", "name": "example.com.", "value": "v=spf1 include:spf.mijn.host ~all", "ttl": 900 } ] } } ================================================ FILE: providers/dns/mijnhost/internal/fixtures/list-domains.json ================================================ { "status": 200, "status_description": "Request successful", "data": { "domains": [ { "id": 1000, "domain": "example.com", "renewal_date": "2030-01-01", "status": "Active", "status_id": 1, "tags": [ "my-tag" ] } ] } } ================================================ FILE: providers/dns/mijnhost/internal/fixtures/update-dns-records.json ================================================ { "status": 200, "status_description": "DNS successfully updated" } ================================================ FILE: providers/dns/mijnhost/internal/types.go ================================================ package internal import "fmt" type APIError struct { Status int `json:"status,omitempty"` StatusDescription string `json:"status_description,omitempty"` } func (e APIError) Error() string { return fmt.Sprintf("%d: %s", e.Status, e.StatusDescription) } type Response[T any] struct { Status int `json:"status,omitempty"` StatusDescription string `json:"status_description,omitempty"` Data T `json:"data,omitempty"` } type RecordData struct { Domain string `json:"domain,omitempty"` Records []Record `json:"records,omitempty"` } type Record struct { Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` TTL int `json:"ttl,omitempty"` } type DomainData struct { Domains []Domain `json:"domains"` } type Domain struct { ID int `json:"id"` Domain string `json:"domain"` RenewalDate string `json:"renewal_date"` Status string `json:"status"` StatusID int `json:"status_id"` Tags []string `json:"tags"` } ================================================ FILE: providers/dns/mijnhost/mijnhost.go ================================================ // Package mijnhost implements a DNS provider for solving the DNS-01 challenge using mijn.host DNS. package mijnhost import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/mijnhost/internal" ) // Environment variables names. const ( envNamespace = "MIJNHOST_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const txtType = "TXT" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string TTL int PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval 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), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 5*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for mijn.host DNS. // MIJNHOST_API_KEY must be passed in the environment variables. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("mijnhost: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for mijn.host DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("mijnhost: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("mijnhost: APIKey is missing") } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) domains, err := d.client.ListDomains(ctx) if err != nil { return fmt.Errorf("mijnhost: list domains: %w", err) } dom, err := findDomain(domains, domain) if err != nil { return fmt.Errorf("mijnhost: find domain: %w", err) } records, err := d.client.GetRecords(ctx, dom.Domain) if err != nil { return fmt.Errorf("mijnhost: get records: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, dom.Domain) if err != nil { return fmt.Errorf("mijnhost: %w", err) } record := internal.Record{ Type: txtType, Name: subDomain, Value: info.Value, TTL: d.config.TTL, } // mijn.host doesn't support multiple values for a domain, // so we removed existing record for the subdomain. cleanedRecords := filterRecords(records, func(record internal.Record) bool { return record.Type == txtType && (record.Name == subDomain || record.Name == dns01.UnFqdn(info.EffectiveFQDN)) }) cleanedRecords = append(cleanedRecords, record) err = d.client.UpdateRecords(ctx, dom.Domain, cleanedRecords) if err != nil { return fmt.Errorf("mijnhost: update records: %w", err) } return nil } // CleanUp removes the TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) domains, err := d.client.ListDomains(ctx) if err != nil { return fmt.Errorf("mijnhost: list domains: %w", err) } dom, err := findDomain(domains, domain) if err != nil { return fmt.Errorf("mijnhost: find domain: %w", err) } records, err := d.client.GetRecords(ctx, dom.Domain) if err != nil { return fmt.Errorf("mijnhost: get records: %w", err) } cleanedRecords := filterRecords(records, func(record internal.Record) bool { return record.Type == txtType && record.Value == info.Value }) err = d.client.UpdateRecords(ctx, dom.Domain, cleanedRecords) if err != nil { return fmt.Errorf("mijnhost: update records: %w", err) } return nil } func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) { for domain := range dns01.UnFqdnDomainsSeq(fqdn) { for _, dom := range domains { if dom.Domain == domain { return dom, nil } } } return internal.Domain{}, fmt.Errorf("domain %s not found", fqdn) } func filterRecords(records []internal.Record, fn func(record internal.Record) bool) []internal.Record { var newRecords []internal.Record for _, record := range records { if record.Type == "TXT" && fn(record) { continue } newRecords = append(newRecords, record) } return newRecords } ================================================ FILE: providers/dns/mijnhost/mijnhost.toml ================================================ Name = "mijn.host" Description = '''''' URL = "https://mijn.host/" Code = "mijnhost" Since = "v4.18.0" Example = ''' MIJNHOST_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns mijnhost -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] MIJNHOST_API_KEY = "The API key" [Configuration.Additional] MIJNHOST_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" MIJNHOST_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" MIJNHOST_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" MIJNHOST_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" MIJNHOST_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://mijn.host/api/doc/" ================================================ FILE: providers/dns/mijnhost/mijnhost_test.go ================================================ package mijnhost import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", }, }, { desc: "missing API key", envVars: map[string]string{}, expected: "mijnhost: some credentials information are missing: MIJNHOST_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string ttl int expected string }{ { desc: "success", apiKey: "key", }, { desc: "missing API key", expected: "mijnhost: APIKey is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.TTL = test.ttl p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/mittwald/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.mittwald.de/v2/" const authorizationHeader = "Authorization" // Client the Mittwald client. type Client struct { token string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(token string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // ListDomains List Domains. // https://api.mittwald.de/v2/docs/#/Domain/domain-list-domains func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { endpoint := c.baseURL.JoinPath("domains") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []Domain err = c.do(req, &result) if err != nil { return nil, err } return result, nil } // GetDNSZone Get a DNSZone. // https://api.mittwald.de/v2/docs/#/Domain/dns-get-dns-zone func (c *Client) GetDNSZone(ctx context.Context, zoneID string) (*DNSZone, error) { endpoint := c.baseURL.JoinPath("dns-zones", zoneID) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := &DNSZone{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } // ListDNSZones List DNSZones belonging to a Project. // https://api.mittwald.de/v2/docs/#/Domain/dns-list-dns-zones func (c *Client) ListDNSZones(ctx context.Context, projectID string) ([]DNSZone, error) { endpoint := c.baseURL.JoinPath("projects", projectID, "dns-zones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result []DNSZone err = c.do(req, &result) if err != nil { return nil, err } return result, nil } // CreateDNSZone Create a DNSZone. // https://api.mittwald.de/v2/docs/#/Domain/dns-create-dns-zone func (c *Client) CreateDNSZone(ctx context.Context, zone CreateDNSZoneRequest) (*DNSZone, error) { endpoint := c.baseURL.JoinPath("dns-zones") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zone) if err != nil { return nil, err } result := &DNSZone{} err = c.do(req, result) if err != nil { return nil, err } return result, nil } // UpdateTXTRecord Update a record set on a DNSZone. // https://api.mittwald.de/v2/docs/#/Domain/dns-update-record-set func (c *Client) UpdateTXTRecord(ctx context.Context, zoneID string, record TXTRecord) error { endpoint := c.baseURL.JoinPath("dns-zones", zoneID, "record-sets", "txt") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record) if err != nil { return err } return c.do(req, nil) } // DeleteDNSZone Delete a DNSZone. // https://api.mittwald.de/v2/docs/#/Domain/dns-delete-dns-zone func (c *Client) DeleteDNSZone(ctx context.Context, zoneID string) error { endpoint := c.baseURL.JoinPath("dns-zones", zoneID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, "Bearer "+c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIError err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, response) } ================================================ FILE: providers/dns/mittwald/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestClient_ListDomains(t *testing.T) { client := mockBuilder(). Route("GET /domains", servermock.ResponseFromFixture("domain-list-domains.json")). Build(t) domains, err := client.ListDomains(t.Context()) require.NoError(t, err) require.Len(t, domains, 1) expected := []Domain{{ Domain: "string", DomainID: "3fa85f64-5717-4562-b3fc-2c963f66afa6", ProjectID: "3fa85f64-5717-4562-b3fc-2c963f66afa6", }} assert.Equal(t, expected, domains) } func TestClient_ListDomains_error(t *testing.T) { client := mockBuilder(). Route("GET /domains", servermock.ResponseFromFixture("error-client.json"). WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.ListDomains(t.Context()) require.EqualError(t, err, "[status code 400] ValidationError: Validation failed [format: should be string (.address.street, email)]") } func TestClient_ListDNSZones(t *testing.T) { client := mockBuilder(). Route("GET /projects/my-project-id/dns-zones", servermock.ResponseFromFixture("dns-list-dns-zones.json")). Build(t) zones, err := client.ListDNSZones(t.Context(), "my-project-id") require.NoError(t, err) require.Len(t, zones, 1) expected := []DNSZone{{ ID: "3fa85f64-5717-4562-b3fc-2c963f66afa6", Domain: "string", RecordSet: &RecordSet{ TXT: &TXTRecord{}, }, }} assert.Equal(t, expected, zones) } func TestClient_GetDNSZone(t *testing.T) { client := mockBuilder(). Route("GET /dns-zones/my-zone-id", servermock.ResponseFromFixture("dns-get-dns-zone.json")). Build(t) zone, err := client.GetDNSZone(t.Context(), "my-zone-id") require.NoError(t, err) expected := &DNSZone{ ID: "3fa85f64-5717-4562-b3fc-2c963f66afa6", Domain: "string", RecordSet: &RecordSet{ TXT: &TXTRecord{}, }, } assert.Equal(t, expected, zone) } func TestClient_CreateDNSZone(t *testing.T) { client := mockBuilder(). Route("POST /dns-zones", servermock.ResponseFromFixture("dns-create-dns-zone.json"), servermock.CheckRequestJSONBody(`{"name":"test","parentZoneId":"my-parent-zone-id"}`)). Build(t) request := CreateDNSZoneRequest{ Name: "test", ParentZoneID: "my-parent-zone-id", } zone, err := client.CreateDNSZone(t.Context(), request) require.NoError(t, err) expected := &DNSZone{ ID: "3fa85f64-5717-4562-b3fc-2c963f66afa6", } assert.Equal(t, expected, zone) } func TestClient_UpdateTXTRecord(t *testing.T) { client := mockBuilder(). Route("PUT /dns-zones/my-zone-id/record-sets/txt", servermock.Noop(). WithStatusCode(http.StatusNoContent), servermock.CheckRequestJSONBody(`{"settings":{"ttl":{"auto":true}},"entries":["txt"]}`)). Build(t) record := TXTRecord{ Settings: Settings{ TTL: TTL{Auto: true}, }, Entries: []string{"txt"}, } err := client.UpdateTXTRecord(t.Context(), "my-zone-id", record) require.NoError(t, err) } func TestClient_DeleteDNSZone(t *testing.T) { client := mockBuilder(). Route("DELETE /dns-zones/my-zone-id", servermock.Noop()). Build(t) err := client.DeleteDNSZone(t.Context(), "my-zone-id") require.NoError(t, err) } func TestClient_DeleteDNSZone_error(t *testing.T) { client := mockBuilder(). Route("DELETE /dns-zones/my-zone-id", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusInternalServerError)). Build(t) err := client.DeleteDNSZone(t.Context(), "my-zone-id") assert.EqualError(t, err, "[status code 500] InternalServerError: Something went wrong") } ================================================ FILE: providers/dns/mittwald/internal/fixtures/dns-create-dns-zone.json ================================================ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } ================================================ FILE: providers/dns/mittwald/internal/fixtures/dns-get-dns-zone.json ================================================ { "domain": "string", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "recordSet": { "cname": {}, "combinedARecords": {}, "mx": {}, "srv": {}, "txt": {} } } ================================================ FILE: providers/dns/mittwald/internal/fixtures/dns-list-dns-zones.json ================================================ [ { "domain": "string", "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "recordSet": { "cname": {}, "combinedARecords": {}, "mx": {}, "srv": {}, "txt": {} } } ] ================================================ FILE: providers/dns/mittwald/internal/fixtures/domain-list-domains.json ================================================ [ { "authCode": { "expires": "2024-06-04T15:11:59.964Z", "value": "string" }, "authCode2": { "expires": "2024-06-04T15:11:59.964Z" }, "connected": true, "deleted": true, "domain": "string", "domainId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "handles": { "adminC": { "current": { "handleFields": [ { "name": "string", "value": "jnoFDyCBDHC&70Zp&2JMErZBq(),fnsYIvn_bOed5e_.vmsrZ3-IH )Ms)Xc13KDWy2WMH((mJ.-uY_NEBu/3MO8)3" } ], "handleRef": "string" }, "desired": { "handleFields": [ { "name": "string", "value": "1odACmUIyjG Xa-uEX7R+f4,ykqpZ71FFLzkl8B87/+I@s0bVMxA" } ], "handleRef": "string" } }, "ownerC": { "current": { "handleFields": [ { "name": "string", "value": "oklq/PU.yBrSFq) .Qx_Uqb8NBZnwA(9jk@x4w Dp6lLd&+a-A.oG5sHw(jcRSOyv0" } ], "handleRef": "string" }, "desired": { "handleFields": [ { "name": "string", "value": "iwt.q,vygqXwZ0_HK+j3kuw/,A,Z)L1Jg&fNgIxWdBc1xnGj(pjj8YQX1DG 9M1/_Vaam," } ], "handleRef": "string" } } }, "nameservers": [ "string" ], "processes": [ { "error": "string", "lastUpdate": "2024-06-04T15:11:59.973Z", "processType": "UNSPECIFIED", "state": "UNSPECIFIED", "status": "string", "statusCode": "string", "transactionId": "string" } ], "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "transferInAuthCode": "string", "usesDefaultNameserver": true } ] ================================================ FILE: providers/dns/mittwald/internal/fixtures/error-client.json ================================================ { "type": "ValidationError", "message": "Validation failed", "validationErrors": [ { "message": "should be string", "path": ".address.street", "type": "format", "context": { "format": "email" } } ] } ================================================ FILE: providers/dns/mittwald/internal/fixtures/error.json ================================================ { "message": "Something went wrong", "type": "InternalServerError" } ================================================ FILE: providers/dns/mittwald/internal/types.go ================================================ package internal import ( "fmt" "strings" ) // https://api.mittwald.de/v2/docs/#/Domain/domain-list-domains type Domain struct { Domain string `json:"domain,omitempty"` DomainID string `json:"domainId,omitempty"` ProjectID string `json:"projectId,omitempty"` } // https://api.mittwald.de/v2/docs/#/Domain/dns-list-dns-zones type DNSZone struct { ID string `json:"id,omitempty"` Domain string `json:"domain,omitempty"` RecordSet *RecordSet `json:"recordSet,omitempty"` } type RecordSet struct { TXT *TXTRecord `json:"txt"` } // https://api.mittwald.de/v2/docs/#/Domain/dns-create-dns-zone type CreateDNSZoneRequest struct { Name string `json:"name,omitempty"` ParentZoneID string `json:"parentZoneId,omitempty"` } type NewDNSZone struct { ID string `json:"id"` } // https://api.mittwald.de/v2/docs/#/Domain/dns-update-record-set type TXTRecord struct { Settings Settings `json:"settings"` Entries []string `json:"entries,omitempty"` } type Settings struct { TTL TTL `json:"ttl"` } type TTL struct { Seconds int `json:"seconds,omitempty"` Auto bool `json:"auto,omitempty"` } // Error type APIError struct { Type string `json:"type,omitempty"` Message string `json:"message,omitempty"` ValidationErrors []ValidationError `json:"validationErrors,omitempty"` } func (a APIError) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "%s: %s", a.Type, a.Message) if len(a.ValidationErrors) > 0 { for _, validationError := range a.ValidationErrors { _, _ = fmt.Fprintf(msg, " [%s: %s (%s, %s)]", validationError.Type, validationError.Message, validationError.Path, validationError.Context.Format) } } return msg.String() } type ValidationError struct { Message string `json:"message,omitempty"` Path string `json:"path,omitempty"` Type string `json:"type,omitempty"` Context ValidationErrorContext `json:"context"` } type ValidationErrorContext struct { Format string `json:"format,omitempty"` } ================================================ FILE: providers/dns/mittwald/mittwald.go ================================================ // Package mittwald implements a DNS provider for solving the DNS-01 challenge using Mittwald. package mittwald import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/mittwald/internal" ) // Environment variables names. const ( envNamespace = "MITTWALD_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string TTL int PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 2*time.Minute), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client zoneIDs map[string]string zoneIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Mittwald. // Credentials must be passed in the environment variables: MITTWALD_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("mittwald: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Mittwald. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("mittwald: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("mittwald: some credentials information are missing") } if config.TTL < minTTL { return nil, fmt.Errorf("mittwald: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.Token) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, zoneIDs: map[string]string{}, }, 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getOrCreateZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("mittwald: get effective zone: %w", err) } record := internal.TXTRecord{ Settings: internal.Settings{ TTL: internal.TTL{Seconds: d.config.TTL}, }, Entries: []string{info.Value}, } err = d.client.UpdateTXTRecord(ctx, zone.ID, record) if err != nil { return fmt.Errorf("mittwald: update/add TXT record: %w", err) } d.zoneIDsMu.Lock() d.zoneIDs[token] = zone.ID d.zoneIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) // get the record's unique ID from when we created it d.zoneIDsMu.Lock() zoneID, ok := d.zoneIDs[token] d.zoneIDsMu.Unlock() if !ok { return fmt.Errorf("mittwald: unknown zone ID for '%s'", info.EffectiveFQDN) } record := internal.TXTRecord{Entries: make([]string, 0)} err := d.client.UpdateTXTRecord(ctx, zoneID, record) if err != nil { return fmt.Errorf("mittwald: update/delete TXT record: %w", err) } d.zoneIDsMu.Lock() delete(d.zoneIDs, token) d.zoneIDsMu.Unlock() return nil } func (d *DNSProvider) getOrCreateZone(ctx context.Context, fqdn string) (*internal.DNSZone, error) { domains, err := d.client.ListDomains(ctx) if err != nil { return nil, fmt.Errorf("list domains: %w", err) } dom, err := findDomain(domains, fqdn) if err != nil { return nil, fmt.Errorf("find domain: %w", err) } zones, err := d.client.ListDNSZones(ctx, dom.ProjectID) if err != nil { return nil, fmt.Errorf("list DNS zones: %w", err) } for _, zone := range zones { if zone.Domain == dns01.UnFqdn(fqdn) { return &zone, nil } } // Looking for parent zone to create a new zone for the subdomain. parentZone, err := findZone(zones, fqdn) if err != nil { return nil, fmt.Errorf("find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, parentZone.Domain) if err != nil { return nil, err } request := internal.CreateDNSZoneRequest{ Name: subDomain, ParentZoneID: parentZone.ID, } zone, err := d.client.CreateDNSZone(ctx, request) if err != nil { return nil, fmt.Errorf("create DNS zone: %w", err) } return zone, nil } func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) { for domain := range dns01.UnFqdnDomainsSeq(fqdn) { for _, dom := range domains { if dom.Domain == domain { return dom, nil } } } return internal.Domain{}, fmt.Errorf("domain %s not found", fqdn) } func findZone(zones []internal.DNSZone, fqdn string) (internal.DNSZone, error) { for domain := range dns01.UnFqdnDomainsSeq(fqdn) { for _, zon := range zones { if zon.Domain == domain { return zon, nil } } } return internal.DNSZone{}, fmt.Errorf("zone %s not found", fqdn) } ================================================ FILE: providers/dns/mittwald/mittwald.toml ================================================ Name = "Mittwald" Description = '''''' URL = "https://www.mittwald.de/" Code = "mittwald" Since = "v1.48.0" Example = ''' MITTWALD_TOKEN=my-token \ lego --dns mittwald -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] MITTWALD_TOKEN = "API token" [Configuration.Additional] MITTWALD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" MITTWALD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" MITTWALD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" MITTWALD_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)" MITTWALD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.mittwald.de/v2/docs/" ================================================ FILE: providers/dns/mittwald/mittwald_test.go ================================================ package mittwald import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/mittwald/internal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvToken: "", }, expected: "mittwald: some credentials information are missing: MITTWALD_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { assert.NoError(t, err) assert.NotNil(t, p) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string ttl int expected string }{ { desc: "success", token: "secret", }, { desc: "missing credentials", expected: "mittwald: some credentials information are missing", }, { desc: "invalid TTL", token: "secret", ttl: 10, expected: "mittwald: invalid TTL, TTL (10) must be greater than 300", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token if test.ttl > 0 { config.TTL = test.ttl } p, err := NewDNSProviderConfig(config) if test.expected == "" { assert.NoError(t, err) assert.NotNil(t, p) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func Test_findDomain(t *testing.T) { domains := []internal.Domain{ { Domain: "example.com", ProjectID: "a1", }, { Domain: "foo.example.com", ProjectID: "a2", }, { Domain: "example.org", ProjectID: "b1", }, { Domain: "foo.example.org", ProjectID: "b2", }, { Domain: "test.example.org", ProjectID: "b3", }, } testCases := []struct { desc string fqdn string expected internal.Domain }{ { desc: "exact match", fqdn: "example.org.", expected: internal.Domain{Domain: "example.org", ProjectID: "b1"}, }, { desc: "1 level parent", fqdn: "_acme-challenge.test.example.org.", expected: internal.Domain{Domain: "test.example.org", ProjectID: "b3"}, }, { desc: "2 levels parent", fqdn: "_acme-challenge.test.example.com.", expected: internal.Domain{Domain: "example.com", ProjectID: "a1"}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() domain, err := findDomain(domains, test.fqdn) require.NoError(t, err) assert.Equal(t, test.expected, domain) }) } } func Test_findZone(t *testing.T) { zones := []internal.DNSZone{ { Domain: "example.com", ID: "a1", }, { Domain: "foo.example.com", ID: "a2", }, { Domain: "example.org", ID: "b1", }, { Domain: "foo.example.org", ID: "b2", }, { Domain: "test.example.org", ID: "b3", }, } testCases := []struct { desc string fqdn string expected internal.DNSZone }{ { desc: "exact match", fqdn: "example.org.", expected: internal.DNSZone{Domain: "example.org", ID: "b1"}, }, { desc: "1 level parent", fqdn: "_acme-challenge.test.example.org.", expected: internal.DNSZone{Domain: "test.example.org", ID: "b3"}, }, { desc: "2 levels parent", fqdn: "_acme-challenge.test.example.com.", expected: internal.DNSZone{Domain: "example.com", ID: "a1"}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() zone, err := findZone(zones, test.fqdn) require.NoError(t, err) assert.Equal(t, test.expected, zone) }) } } ================================================ FILE: providers/dns/myaddr/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://myaddr.tools" // Client the myaddr.{tools,dev,io} API client. type Client struct { baseURL *url.URL HTTPClient *http.Client credentials map[string]string credMu sync.Mutex } // NewClient creates a new Client. func NewClient(credentials map[string]string) (*Client, error) { if len(credentials) == 0 { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, credentials: credentials, }, nil } func (c *Client) AddTXTRecord(ctx context.Context, subdomain, value string) error { c.credMu.Lock() privateKey, ok := c.credentials[subdomain] c.credMu.Unlock() if !ok { return fmt.Errorf("subdomain %s not found in credentials, check your credentials map", subdomain) } payload := ACMEChallenge{Key: privateKey, Data: value} req, err := newJSONRequest(ctx, http.MethodPost, c.baseURL.JoinPath("update"), payload) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/myaddr/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { credentials := map[string]string{ "example": "secret", } client, err := NewClient(credentials) if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /update", nil, servermock.CheckRequestJSONBody(`{"key":"secret","acme_challenge":"txt"}`)). Build(t) err := client.AddTXTRecord(t.Context(), "example", "txt") require.NoError(t, err) } func TestClient_AddTXTRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /update", servermock.ResponseFromFixture("error.txt"). WithStatusCode(http.StatusBadRequest)). Build(t) err := client.AddTXTRecord(t.Context(), "example", "txt") require.EqualError(t, err, `unexpected status code: [status code: 400] body: invalid value for "key"`) } func TestClient_AddTXTRecord_error_credentials(t *testing.T) { client := mockBuilder(). Route("POST /update", nil). Build(t) err := client.AddTXTRecord(t.Context(), "nx", "txt") require.EqualError(t, err, "subdomain nx not found in credentials, check your credentials map") } ================================================ FILE: providers/dns/myaddr/internal/fixtures/error.txt ================================================ invalid value for "key" ================================================ FILE: providers/dns/myaddr/internal/types.go ================================================ package internal type ACMEChallenge struct { Key string `json:"key"` Data string `json:"acme_challenge"` } ================================================ FILE: providers/dns/myaddr/myaddr.go ================================================ // Package myaddr implements a DNS provider for solving the DNS-01 challenge using myaddr.{tools,dev,io}. package myaddr import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/myaddr/internal" ) // Environment variables names. const ( envNamespace = "MYADDR_" EnvPrivateKeysMapping = envNamespace + "PRIVATE_KEYS_MAPPING" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { Credentials map[string]string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration TTL int 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), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for myaddr.{tools,dev,io}. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvPrivateKeysMapping) if err != nil { return nil, fmt.Errorf("myaddr: %w", err) } config := NewDefaultConfig() credentials, err := env.ParsePairs(values[EnvPrivateKeysMapping]) if err != nil { return nil, fmt.Errorf("myaddr: %w", err) } config.Credentials = credentials return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for myaddr.{tools,dev,io}. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("myaddr: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.Credentials) if err != nil { return nil, fmt.Errorf("myaddr: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("myaddr: could not find zone for domain %q: %w", domain, err) } fullSubdomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("myaddr: %w", err) } _, after, found := strings.Cut(fullSubdomain, ".") if !found { return fmt.Errorf("myaddr: subdomain not found in: %q (%s)", fullSubdomain, info.EffectiveFQDN) } err = d.client.AddTXTRecord(context.Background(), after, info.Value) if err != nil { return fmt.Errorf("myaddr: add TXT record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // There is no API endpoint to delete a TXT record: // TXT records are automatically removed after a few minutes. return 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/myaddr/myaddr.toml ================================================ Name = "myaddr.{tools,dev,io}" Description = '''''' URL = "https://myaddr.tools/" Code = "myaddr" Since = "v4.22.0" Example = ''' MYADDR_PRIVATE_KEYS_MAPPING="example:123,test:456" \ lego --dns myaddr -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] MYADDR_PRIVATE_KEYS_MAPPING = "Mapping between subdomains and private keys. The format is: `:,:,:`" [Configuration.Additional] MYADDR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" MYADDR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" MYADDR_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 2)" MYADDR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" MYADDR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://myaddr.tools/" ================================================ FILE: providers/dns/myaddr/myaddr_test.go ================================================ package myaddr import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvPrivateKeysMapping).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvPrivateKeysMapping: "example:123", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "myaddr: some credentials information are missing: MYADDR_PRIVATE_KEYS_MAPPING", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string credentials map[string]string expected string }{ { desc: "success", credentials: map[string]string{"example": "123"}, }, { desc: "missing credentials", expected: "myaddr: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Credentials = test.credentials p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/mydnsjp/internal/client.go ================================================ package internal import ( "context" "fmt" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://www.mydns.jp/directedit.html" // Client the MyDNS.jp client. type Client struct { masterID string password string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(masterID, password string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ masterID: masterID, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) AddTXTRecord(ctx context.Context, domain, value string) error { return c.doRequest(ctx, domain, value, "REGIST") } func (c *Client) DeleteTXTRecord(ctx context.Context, domain, value string) error { return c.doRequest(ctx, domain, value, "DELETE") } func (c *Client) buildRequest(ctx context.Context, domain, value, cmd string) (*http.Request, error) { params := url.Values{} params.Set("CERTBOT_DOMAIN", domain) params.Set("CERTBOT_VALIDATION", value) params.Set("EDIT_CMD", cmd) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL.String(), strings.NewReader(params.Encode())) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req, nil } func (c *Client) doRequest(ctx context.Context, domain, value, cmd string) error { req, err := c.buildRequest(ctx, domain, value, cmd) if err != nil { return err } req.SetBasicAuth(c.masterID, c.password) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } ================================================ FILE: providers/dns/mydnsjp/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("xxx", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded(). WithBasicAuth("xxx", "secret")) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /", nil, servermock.CheckForm().Strict(). With("CERTBOT_DOMAIN", "example.com"). With("CERTBOT_VALIDATION", "txt"). With("EDIT_CMD", "REGIST")). Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "txt") require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /", nil, servermock.CheckForm().Strict(). With("CERTBOT_DOMAIN", "example.com"). With("CERTBOT_VALIDATION", "txt"). With("EDIT_CMD", "DELETE")). Build(t) err := client.DeleteTXTRecord(t.Context(), "example.com", "txt") require.NoError(t, err) } ================================================ FILE: providers/dns/mydnsjp/mydnsjp.go ================================================ // Package mydnsjp implements a DNS provider for solving the DNS-01 challenge using MyDNS.jp. package mydnsjp import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/mydnsjp/internal" ) // Environment variables names. const ( envNamespace = "MYDNSJP_" EnvMasterID = envNamespace + "MASTER_ID" EnvPassword = envNamespace + "PASSWORD" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { MasterID string Password string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for MyDNS.jp. // Credentials must be passed in the environment variables: MYDNSJP_MASTER_ID and MYDNSJP_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvMasterID, EnvPassword) if err != nil { return nil, fmt.Errorf("mydnsjp: %w", err) } config := NewDefaultConfig() config.MasterID = values[EnvMasterID] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for MyDNS.jp. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("mydnsjp: the configuration of the DNS provider is nil") } if config.MasterID == "" || config.Password == "" { return nil, errors.New("mydnsjp: some credentials information are missing") } client := internal.NewClient(config.MasterID, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // TODO(ldez) replace domain by FQDN to follow CNAME. err := d.client.AddTXTRecord(context.Background(), domain, info.Value) if err != nil { return fmt.Errorf("mydnsjp: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // TODO(ldez) replace domain by FQDN to follow CNAME. err := d.client.DeleteTXTRecord(context.Background(), domain, info.Value) if err != nil { return fmt.Errorf("mydnsjp: %w", err) } return nil } ================================================ FILE: providers/dns/mydnsjp/mydnsjp.toml ================================================ Name = "MyDNS.jp" Description = '''''' URL = "https://www.mydns.jp" Code = "mydnsjp" Since = "v1.2.0" Example = ''' MYDNSJP_MASTER_ID=xxxxx \ MYDNSJP_PASSWORD=xxxxx \ lego --dns mydnsjp -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] MYDNSJP_MASTER_ID = "Master ID" MYDNSJP_PASSWORD = "Password" [Configuration.Additional] MYDNSJP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" MYDNSJP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" MYDNSJP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.mydns.jp/?MENU=030" ================================================ FILE: providers/dns/mydnsjp/mydnsjp_test.go ================================================ package mydnsjp import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvMasterID, EnvPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvMasterID: "test@example.com", EnvPassword: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvMasterID: "", EnvPassword: "", }, expected: "mydnsjp: some credentials information are missing: MYDNSJP_MASTER_ID,MYDNSJP_PASSWORD", }, { desc: "missing email", envVars: map[string]string{ EnvMasterID: "", EnvPassword: "key", }, expected: "mydnsjp: some credentials information are missing: MYDNSJP_MASTER_ID", }, { desc: "missing api key", envVars: map[string]string{ EnvMasterID: "awesome@possum.com", EnvPassword: "", }, expected: "mydnsjp: some credentials information are missing: MYDNSJP_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { assert.NoError(t, err) assert.NotNil(t, p) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string masterID string password string expected string }{ { desc: "success", masterID: "test@example.com", password: "123", }, { desc: "missing credentials", expected: "mydnsjp: some credentials information are missing", }, { desc: "missing email", password: "123", expected: "mydnsjp: some credentials information are missing", }, { desc: "missing api key", masterID: "test@example.com", expected: "mydnsjp: some credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.MasterID = test.masterID config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { assert.NoError(t, err) assert.NotNil(t, p) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/mythicbeasts/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // Default API endpoints. const ( APIBaseURL = "https://api.mythic-beasts.com/dns/v2" AuthBaseURL = "https://auth.mythic-beasts.com/login" ) // Client the Mythic Beasts API client. type Client struct { username string password string APIEndpoint *url.URL AuthEndpoint *url.URL HTTPClient *http.Client token *Token muToken sync.Mutex } // NewClient Creates a new Client. func NewClient(username, password string) *Client { apiEndpoint, _ := url.Parse(APIBaseURL) authEndpoint, _ := url.Parse(AuthBaseURL) return &Client{ username: username, password: password, APIEndpoint: apiEndpoint, AuthEndpoint: authEndpoint, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // CreateTXTRecord creates a TXT record. // https://www.mythic-beasts.com/support/api/dnsv2#ep-get-zoneszonerecords func (c *Client) CreateTXTRecord(ctx context.Context, zone, leaf, value string, ttl int) error { resp, err := c.createTXTRecord(ctx, zone, leaf, "TXT", value, ttl) if err != nil { return err } if resp.Added != 1 { return fmt.Errorf("did not add TXT record for some reason: %s", resp.Message) } // Success return nil } // RemoveTXTRecord removes a TXT records. // https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords func (c *Client) RemoveTXTRecord(ctx context.Context, zone, leaf, value string) error { resp, err := c.removeTXTRecord(ctx, zone, leaf, "TXT", value) if err != nil { return err } if resp.Removed != 1 { return fmt.Errorf("did not remove TXT record for some reason: %s", resp.Message) } // Success return nil } // https://www.mythic-beasts.com/support/api/dnsv2#ep-post-zoneszonerecords func (c *Client) createTXTRecord(ctx context.Context, zone, leaf, recordType, value string, ttl int) (*createTXTResponse, error) { endpoint := c.APIEndpoint.JoinPath("zones", zone, "records", leaf, recordType) createReq := createTXTRequest{ Records: []createTXTRecord{{ Host: leaf, TTL: ttl, Type: "TXT", Data: value, }}, } req, err := newJSONRequest(ctx, http.MethodPost, endpoint, createReq) if err != nil { return nil, err } resp := &createTXTResponse{} err = c.do(req, resp) if err != nil { return nil, err } return resp, nil } // https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords func (c *Client) removeTXTRecord(ctx context.Context, zone, leaf, recordType, value string) (*deleteTXTResponse, error) { endpoint := c.APIEndpoint.JoinPath("zones", zone, "records", leaf, recordType) query := endpoint.Query() query.Add("data", value) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return nil, err } resp := &deleteTXTResponse{} err = c.do(req, resp) if err != nil { return nil, err } return resp, nil } func (c *Client) do(req *http.Request, result any) error { tok := getToken(req.Context()) if tok != nil { req.Header.Set("Authorization", "Bearer "+tok.Token) } else { return errors.New("not logged in") } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/mythicbeasts/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.APIEndpoint, _ = url.Parse(server.URL) client.token = &Token{ Token: "secret", Lifetime: 60, TokenType: "bearer", Deadline: time.Now().Add(1 * time.Minute), } return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer "+fakeToken), ) } func TestClient_CreateTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /zones/example.com/records/foo/TXT", servermock.ResponseFromFixture("post-zoneszonerecords.json"), servermock.CheckRequestJSONBody(`{"records":[{"host":"foo","ttl":120,"type":"TXT","data":"txt"}]}`)). Build(t) err := client.CreateTXTRecord(mockContext(t), "example.com", "foo", "txt", 120) require.NoError(t, err) } func TestClient_RemoveTXTRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/example.com/records/foo/TXT", servermock.ResponseFromFixture("delete-zoneszonerecords.json"), servermock.CheckQueryParameter().Strict(). With("data", "txt")). Build(t) err := client.RemoveTXTRecord(mockContext(t), "example.com", "foo", "txt") require.NoError(t, err) } ================================================ FILE: providers/dns/mythicbeasts/internal/fixtures/delete-zoneszonerecords.json ================================================ { "records_removed": 1, "message": "1 record removed" } ================================================ FILE: providers/dns/mythicbeasts/internal/fixtures/post-zoneszonerecords.json ================================================ { "records_added": 1, "message": "1 record added" } ================================================ FILE: providers/dns/mythicbeasts/internal/fixtures/token.json ================================================ { "access_token": "xxx", "expires_in": 666, "token_type": "bearer" } ================================================ FILE: providers/dns/mythicbeasts/internal/identity.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) type token string const tokenKey token = "token" // obtainToken Logs into mythic beasts and acquires a bearer token for use in future API calls. // https://www.mythic-beasts.com/support/api/auth#sec-obtaining-a-token func (c *Client) obtainToken(ctx context.Context) (*Token, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.AuthEndpoint.String(), strings.NewReader("grant_type=client_credentials")) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(c.username, c.password) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } tok := Token{} err = json.Unmarshal(raw, &tok) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if tok.TokenType != "bearer" { return nil, fmt.Errorf("received unexpected token type: %s", tok.TokenType) } tok.Deadline = time.Now().Add(time.Duration(tok.Lifetime) * time.Second) return &tok, nil } func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { c.muToken.Lock() defer c.muToken.Unlock() if c.token != nil && time.Now().Before(c.token.Deadline) { // Already authenticated, stop now return context.WithValue(ctx, tokenKey, c.token), nil } tok, err := c.obtainToken(ctx) if err != nil { return nil, err } return context.WithValue(ctx, tokenKey, tok), nil } func parseError(req *http.Request, resp *http.Response) error { if resp.StatusCode < 400 || resp.StatusCode > 499 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, _ := io.ReadAll(resp.Body) errResp := &authResponseError{} err := json.Unmarshal(raw, errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("%d: %w", resp.StatusCode, errResp) } func getToken(ctx context.Context) *Token { tok, ok := ctx.Value(tokenKey).(*Token) if !ok { return nil } return tok } ================================================ FILE: providers/dns/mythicbeasts/internal/identity_test.go ================================================ package internal import ( "context" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const fakeToken = "xxx" func mockContext(t *testing.T) context.Context { t.Helper() return context.WithValue(t.Context(), tokenKey, &Token{Token: fakeToken}) } func mockBuilderIdentity() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.AuthEndpoint, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithBasicAuth("user", "secret"), servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func TestClient_obtainToken(t *testing.T) { client := mockBuilderIdentity(). Route("POST /", servermock.ResponseFromFixture("token.json"), servermock.CheckForm().Strict(). With("grant_type", "client_credentials")). Build(t) assert.Nil(t, client.token) tok, err := client.obtainToken(t.Context()) require.NoError(t, err) assert.NotNil(t, tok) assert.NotZero(t, tok.Deadline) assert.Equal(t, fakeToken, tok.Token) } func TestClient_CreateAuthenticatedContext(t *testing.T) { client := mockBuilderIdentity(). Route("POST /", servermock.ResponseFromFixture("token.json"), servermock.CheckForm().Strict(). With("grant_type", "client_credentials")). Build(t) assert.Nil(t, client.token) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) tok := getToken(ctx) assert.NotNil(t, tok) assert.NotZero(t, tok.Deadline) assert.Equal(t, fakeToken, tok.Token) } ================================================ FILE: providers/dns/mythicbeasts/internal/types.go ================================================ package internal import ( "fmt" "time" ) type Token struct { // The bearer token for use in API requests Token string `json:"access_token"` // The maximum lifetime of the token in seconds Lifetime int `json:"expires_in"` // The token type (must be 'bearer') TokenType string `json:"token_type"` Deadline time.Time `json:"-"` } type authResponseError struct { ErrorMsg string `json:"error"` ErrorDescription string `json:"error_description"` } func (a authResponseError) Error() string { return fmt.Sprintf("%s: %s", a.ErrorMsg, a.ErrorDescription) } type createTXTRequest struct { Records []createTXTRecord `json:"records"` } type createTXTRecord struct { Host string `json:"host"` TTL int `json:"ttl"` Type string `json:"type"` Data string `json:"data"` } type createTXTResponse struct { Added int `json:"records_added"` Removed int `json:"records_removed"` Message string `json:"message"` } type deleteTXTResponse struct { Removed int `json:"records_removed"` Message string `json:"message"` } ================================================ FILE: providers/dns/mythicbeasts/mythicbeasts.go ================================================ // Package mythicbeasts implements a DNS provider for solving the DNS-01 challenge using Mythic Beasts API. package mythicbeasts import ( "context" "errors" "fmt" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/mythicbeasts/internal" ) // Environment variables names. const ( envNamespace = "MYTHICBEASTS_" EnvUserName = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvAPIEndpoint = envNamespace + "API_ENDPOINT" EnvAuthAPIEndpoint = envNamespace + "AUTH_API_ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { UserName string Password string HTTPClient *http.Client PropagationTimeout time.Duration PollingInterval time.Duration APIEndpoint *url.URL AuthAPIEndpoint *url.URL TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() (*Config, error) { apiEndpoint, err := url.Parse(env.GetOrDefaultString(EnvAPIEndpoint, internal.APIBaseURL)) if err != nil { return nil, fmt.Errorf("mythicbeasts: Unable to parse API URL: %w", err) } authEndpoint, err := url.Parse(env.GetOrDefaultString(EnvAuthAPIEndpoint, internal.AuthBaseURL)) if err != nil { return nil, fmt.Errorf("mythicbeasts: Unable to parse AUTH API URL: %w", err) } return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), APIEndpoint: apiEndpoint, AuthAPIEndpoint: authEndpoint, HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, }, nil } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for mythicbeasts DNSv2 API. // Credentials must be passed in the environment variables: // MYTHICBEASTS_USERNAME and MYTHICBEASTS_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUserName, EnvPassword) if err != nil { return nil, fmt.Errorf("mythicbeasts: %w", err) } config, err := NewDefaultConfig() if err != nil { return nil, fmt.Errorf("mythicbeasts: %w", err) } config.UserName = values[EnvUserName] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for mythicbeasts DNSv2 API. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("mythicbeasts: the configuration of the DNS provider is nil") } if config.UserName == "" || config.Password == "" { return nil, errors.New("mythicbeasts: incomplete credentials, missing username and/or password") } client := internal.NewClient(config.UserName, config.Password) if config.APIEndpoint != nil { client.APIEndpoint = config.APIEndpoint } if config.AuthAPIEndpoint != nil { client.AuthEndpoint = config.AuthAPIEndpoint } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("mythicbeasts: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("mythicbeasts: %w", err) } authZone = dns01.UnFqdn(authZone) ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("mythicbeasts: login: %w", err) } err = d.client.CreateTXTRecord(ctx, authZone, subDomain, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("mythicbeasts: CreateTXTRecord: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("mythicbeasts: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("mythicbeasts: %w", err) } authZone = dns01.UnFqdn(authZone) ctx, err := d.client.CreateAuthenticatedContext(context.Background()) if err != nil { return fmt.Errorf("mythicbeasts: login: %w", err) } err = d.client.RemoveTXTRecord(ctx, authZone, subDomain, info.Value) if err != nil { return fmt.Errorf("mythicbeasts: RemoveTXTRecord: %w", err) } return 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 } ================================================ FILE: providers/dns/mythicbeasts/mythicbeasts.toml ================================================ Name = "MythicBeasts" Description = '''''' URL = "https://www.mythic-beasts.com/" Code = "mythicbeasts" Since = "v0.3.7" Example = ''' MYTHICBEASTS_USERNAME=myuser \ MYTHICBEASTS_PASSWORD=mypass \ lego --dns mythicbeasts -d '*.example.com' -d example.com run ''' Additional = ''' If you are using specific API keys, then the username is the API ID for your API key, and the password is the API secret. Your API key name is not needed to operate lego. ''' [Configuration] [Configuration.Credentials] MYTHICBEASTS_USERNAME = "User name" MYTHICBEASTS_PASSWORD = "Password" [Configuration.Additional] MYTHICBEASTS_API_ENDPOINT = "The endpoint for the API (must implement v2)" MYTHICBEASTS_AUTH_API_ENDPOINT = "The endpoint for Mythic Beasts' Authentication" MYTHICBEASTS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" MYTHICBEASTS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" MYTHICBEASTS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" MYTHICBEASTS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.mythic-beasts.com/support/api/dnsv2" APIAuth = "https://auth.mythic-beasts.com/login" ================================================ FILE: providers/dns/mythicbeasts/mythicbeasts_test.go ================================================ package mythicbeasts import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUserName, EnvPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUserName: "123", EnvPassword: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvUserName: "", EnvPassword: "", }, expected: "mythicbeasts: some credentials information are missing: MYTHICBEASTS_USERNAME,MYTHICBEASTS_PASSWORD", }, { desc: "missing api key", envVars: map[string]string{ EnvUserName: "", EnvPassword: "api_password", }, expected: "mythicbeasts: some credentials information are missing: MYTHICBEASTS_USERNAME", }, { desc: "missing secret key", envVars: map[string]string{ EnvUserName: "api_username", EnvPassword: "", }, expected: "mythicbeasts: some credentials information are missing: MYTHICBEASTS_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "api_username", password: "api_password", }, { desc: "missing credentials", expected: "mythicbeasts: incomplete credentials, missing username and/or password", }, { desc: "missing username", username: "", password: "api_password", expected: "mythicbeasts: incomplete credentials, missing username and/or password", }, { desc: "missing password", username: "api_username", password: "", expected: "mythicbeasts: incomplete credentials, missing username and/or password", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config, err := NewDefaultConfig() require.NoError(t, err) config.UserName = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/namecheap/internal/client.go ================================================ package internal import ( "context" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // Default API endpoints. const ( DefaultBaseURL = "https://api.namecheap.com/xml.response" SandboxBaseURL = "https://api.sandbox.namecheap.com/xml.response" ) // Client the API client for Namecheap. type Client struct { apiUser string apiKey string clientIP string BaseURL string HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiUser, apiKey, clientIP string) *Client { return &Client{ apiUser: apiUser, apiKey: apiKey, clientIP: clientIP, BaseURL: DefaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetHosts reads the full list of DNS host records. // https://www.namecheap.com/support/api/methods/domains-dns/get-hosts.aspx func (c *Client) GetHosts(ctx context.Context, sld, tld string) ([]Record, error) { request, err := c.newRequestGet(ctx, "namecheap.domains.dns.getHosts", addParam("SLD", sld), addParam("TLD", tld), ) if err != nil { return nil, err } var ghr getHostsResponse err = c.do(request, &ghr) if err != nil { return nil, err } if len(ghr.Errors) > 0 { return nil, ghr.Errors[0] } return ghr.Hosts, nil } // SetHosts writes the full list of DNS host records . // https://www.namecheap.com/support/api/methods/domains-dns/set-hosts.aspx func (c *Client) SetHosts(ctx context.Context, sld, tld string, hosts []Record) error { req, err := c.newRequestPost(ctx, "namecheap.domains.dns.setHosts", addParam("SLD", sld), addParam("TLD", tld), func(values url.Values) { for i, h := range hosts { ind := strconv.Itoa(i + 1) values.Add("HostName"+ind, h.Name) values.Add("RecordType"+ind, h.Type) values.Add("Address"+ind, h.Address) values.Add("MXPref"+ind, h.MXPref) values.Add("TTL"+ind, h.TTL) } }, ) if err != nil { return err } var shr setHostsResponse err = c.do(req, &shr) if err != nil { return err } if len(shr.Errors) > 0 { return shr.Errors[0] } if shr.Result.IsSuccess != "true" { return errors.New("setHosts failed") } return nil } func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusBadRequest { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } return xml.Unmarshal(raw, result) } func (c *Client) newRequestGet(ctx context.Context, cmd string, params ...func(url.Values)) (*http.Request, error) { query := c.makeQuery(cmd, params...) endpoint, err := url.Parse(c.BaseURL) if err != nil { return nil, err } endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } return req, nil } func (c *Client) newRequestPost(ctx context.Context, cmd string, params ...func(url.Values)) (*http.Request, error) { query := c.makeQuery(cmd, params...) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL, strings.NewReader(query.Encode())) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req, nil } func (c *Client) makeQuery(cmd string, params ...func(url.Values)) url.Values { queryParams := make(url.Values) queryParams.Set("ApiUser", c.apiUser) queryParams.Set("ApiKey", c.apiKey) queryParams.Set("UserName", c.apiUser) queryParams.Set("Command", cmd) queryParams.Set("ClientIp", c.clientIP) for _, param := range params { param(queryParams) } return queryParams } func addParam(key, value string) func(url.Values) { return func(values url.Values) { values.Set(key, value) } } ================================================ FILE: providers/dns/namecheap/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret", "127.0.0.1") client.HTTPClient = server.Client() client.BaseURL = server.URL return client, nil } func TestClient_GetHosts(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.ResponseFromFixture("getHosts.xml"), servermock.CheckQueryParameter().Strict(). With("ApiKey", "secret"). With("ApiUser", "user"). With("ClientIp", "127.0.0.1"). With("Command", "namecheap.domains.dns.getHosts"). With("SLD", "foo"). With("TLD", "example.com"). With("UserName", "user"), ). Build(t) hosts, err := client.GetHosts(t.Context(), "foo", "example.com") require.NoError(t, err) expected := []Record{ {Type: "A", Name: "@", Address: "1.2.3.4", MXPref: "10", TTL: "1800"}, {Type: "A", Name: "www", Address: "122.23.3.7", MXPref: "10", TTL: "1800"}, } assert.Equal(t, expected, hosts) } func TestClient_GetHosts_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.ResponseFromFixture("getHosts_errorBadAPIKey1.xml")). Build(t) _, err := client.GetHosts(t.Context(), "foo", "example.com") require.ErrorAs(t, err, &apiError{}) } func TestClient_SetHosts(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()). Route("POST /", servermock.ResponseFromFixture("setHosts.xml"), servermock.CheckForm().Strict(). With("ApiKey", "secret"). With("ApiUser", "user"). With("ClientIp", "127.0.0.1"). With("Command", "namecheap.domains.dns.setHosts"). With("SLD", "foo"). With("TLD", "example.com"). With("UserName", "user"). // entry 1 With("HostName1", "_acme-challenge.test.example.com"). With("RecordType1", "TXT"). With("Address1", "txtTXTtxt"). With("MXPref1", "10"). With("TTL1", "120"). // entry 2 With("HostName2", "_acme-challenge.test.example.org"). With("RecordType2", "TXT"). With("Address2", "txtTXTtxt"). With("MXPref2", "10"). With("TTL2", "120"), ). Build(t) records := []Record{ {Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, {Name: "_acme-challenge.test.example.org", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, } err := client.SetHosts(t.Context(), "foo", "example.com", records) require.NoError(t, err) } func TestClient_SetHosts_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /", servermock.ResponseFromFixture("setHosts_errorBadAPIKey1.xml")). Build(t) records := []Record{ {Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, {Name: "_acme-challenge.test.example.org", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, } err := client.SetHosts(t.Context(), "foo", "example.com", records) require.ErrorAs(t, err, &apiError{}) } ================================================ FILE: providers/dns/namecheap/internal/fixtures/getHosts.xml ================================================ namecheap.domains.dns.getHosts SERVER-NAME +5 32.76 ================================================ FILE: providers/dns/namecheap/internal/fixtures/getHosts_errorBadAPIKey1.xml ================================================ API Key is invalid or API access has not been enabled PHX01SBAPI01 --5:00 0 ================================================ FILE: providers/dns/namecheap/internal/fixtures/getHosts_success1.xml ================================================ namecheap.domains.dns.getHosts PHX01SBAPI01 --5:00 3.338 ================================================ FILE: providers/dns/namecheap/internal/fixtures/getHosts_success2.xml ================================================ namecheap.domains.dns.getHosts PHX01SBAPI01 --5:00 3.338 ================================================ FILE: providers/dns/namecheap/internal/fixtures/setHosts.xml ================================================ namecheap.domains.dns.setHosts SERVER-NAME +5 32.76 ================================================ FILE: providers/dns/namecheap/internal/fixtures/setHosts_errorBadAPIKey1.xml ================================================ API Key is invalid or API access has not been enabled PHX01SBAPI01 --5:00 0 ================================================ FILE: providers/dns/namecheap/internal/fixtures/setHosts_success1.xml ================================================ namecheap.domains.dns.setHosts PHX01SBAPI01 --5:00 2.347 ================================================ FILE: providers/dns/namecheap/internal/fixtures/setHosts_success2.xml ================================================ namecheap.domains.dns.setHosts PHX01SBAPI01 --5:00 2.347 ================================================ FILE: providers/dns/namecheap/internal/ip.go ================================================ package internal import ( "context" "fmt" "io" "net/http" "time" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const getIPURL = "https://dynamicdns.park-your-domain.com/getip" // GetClientIP returns the client's public IP address. // It uses namecheap's IP discovery service to perform the lookup. func GetClientIP(ctx context.Context, client *http.Client, debug bool) (addr string, err error) { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } req, err := http.NewRequestWithContext(ctx, http.MethodGet, getIPURL, http.NoBody) if err != nil { return "", fmt.Errorf("unable to create request: %w", err) } resp, err := client.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() clientIP, err := io.ReadAll(resp.Body) if err != nil { return "", errutils.NewReadResponseError(req, resp.StatusCode, err) } if debug { log.Println("Client IP:", string(clientIP)) } return string(clientIP), nil } ================================================ FILE: providers/dns/namecheap/internal/types.go ================================================ package internal import ( "encoding/xml" "fmt" ) // Record describes a DNS record returned by the Namecheap DNS gethosts API. // Namecheap uses the term "host" to refer to all DNS records that include // a host field (A, AAAA, CNAME, NS, TXT, URL). type Record struct { Type string `xml:",attr"` Name string `xml:",attr"` Address string `xml:",attr"` MXPref string `xml:",attr"` TTL string `xml:",attr"` } // apiError describes an error record in a namecheap API response. type apiError struct { Number int `xml:",attr"` Description string `xml:",innerxml"` } func (a apiError) Error() string { return fmt.Sprintf("%s [%d]", a.Description, a.Number) } type setHostsResponse struct { XMLName xml.Name `xml:"ApiResponse"` Status string `xml:"Status,attr"` Errors []apiError `xml:"Errors>Error"` Result struct { IsSuccess string `xml:",attr"` } `xml:"CommandResponse>DomainDNSSetHostsResult"` } type getHostsResponse struct { XMLName xml.Name `xml:"ApiResponse"` Status string `xml:"Status,attr"` Errors []apiError `xml:"Errors>Error"` Hosts []Record `xml:"CommandResponse>DomainDNSGetHostsResult>host"` } ================================================ FILE: providers/dns/namecheap/namecheap.go ================================================ // Package namecheap implements a DNS provider for solving the DNS-01 challenge using namecheap DNS. package namecheap import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/namecheap/internal" "golang.org/x/net/publicsuffix" ) // Notes about namecheap's tool API: // 1. Using the API requires registration. // Once registered, use your account name and API key to access the API. // 2. There is no API to add or modify a single DNS record. // Instead, you must read the entire list of records, make modifications, // and then write the entire updated list of records. (Yuck.) // 3. Namecheap's DNS updates can be slow to propagate. // I've seen them take as long as an hour. // 4. Namecheap requires you to whitelist the IP address from which you call its APIs. // It also requires all API calls to include the whitelisted IP address as a form or query string value. // This code uses a namecheap service to query the client's IP address. // Environment variables names. const ( envNamespace = "NAMECHEAP_" EnvAPIUser = envNamespace + "API_USER" EnvAPIKey = envNamespace + "API_KEY" EnvSandbox = envNamespace + "SANDBOX" EnvDebug = envNamespace + "DEBUG" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Debug bool BaseURL string APIUser string APIKey string ClientIP string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { baseURL := internal.DefaultBaseURL if env.GetOrDefaultBool(EnvSandbox, false) { baseURL = internal.SandboxBaseURL } return &Config{ BaseURL: baseURL, Debug: env.GetOrDefaultBool(EnvDebug, false), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, time.Hour), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute), Transport: defaultTransport(envNamespace), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for namecheap. // Credentials must be passed in the environment variables: // NAMECHEAP_API_USER and NAMECHEAP_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIUser, EnvAPIKey) if err != nil { return nil, fmt.Errorf("namecheap: %w", err) } config := NewDefaultConfig() config.APIUser = values[EnvAPIUser] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Namecheap. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("namecheap: the configuration of the DNS provider is nil") } if config.APIUser == "" || config.APIKey == "" { return nil, errors.New("namecheap: credentials missing") } if config.ClientIP == "" { clientIP, err := internal.GetClientIP(context.Background(), config.HTTPClient, config.Debug) if err != nil { return nil, fmt.Errorf("namecheap: %w", err) } config.ClientIP = clientIP } client := internal.NewClient(config.APIUser, config.APIKey, config.ClientIP) client.BaseURL = config.BaseURL if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Namecheap can sometimes take a long time to complete an update, so wait up to 60 minutes for the update to propagate. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present installs a TXT record for the DNS challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { // TODO(ldez) replace domain by FQDN to follow CNAME. pr, err := newPseudoRecord(domain, keyAuth) if err != nil { return fmt.Errorf("namecheap: %w", err) } ctx := context.Background() records, err := d.client.GetHosts(ctx, pr.sld, pr.tld) if err != nil { return fmt.Errorf("namecheap: %w", err) } record := internal.Record{ Name: pr.key, Type: "TXT", Address: pr.keyValue, MXPref: "10", TTL: strconv.Itoa(d.config.TTL), } records = append(records, record) if d.config.Debug { for _, h := range records { log.Printf("%-5.5s %-30.30s %-6s %-70.70s", h.Type, h.Name, h.TTL, h.Address) } } err = d.client.SetHosts(ctx, pr.sld, pr.tld, records) if err != nil { return fmt.Errorf("namecheap: %w", err) } return nil } // CleanUp removes a TXT record used for a previous DNS challenge. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // TODO(ldez) replace domain by FQDN to follow CNAME. pr, err := newPseudoRecord(domain, keyAuth) if err != nil { return fmt.Errorf("namecheap: %w", err) } ctx := context.Background() records, err := d.client.GetHosts(ctx, pr.sld, pr.tld) if err != nil { return fmt.Errorf("namecheap: %w", err) } // Find the challenge TXT record and remove it if found. var ( found bool newRecords []internal.Record ) for _, h := range records { if h.Name == pr.key && h.Type == "TXT" { found = true } else { newRecords = append(newRecords, h) } } if !found { return nil } err = d.client.SetHosts(ctx, pr.sld, pr.tld, newRecords) if err != nil { return fmt.Errorf("namecheap: %w", err) } return nil } // A pseudoRecord represents all the data needed to specify a dns-01 challenge to lets-encrypt. type pseudoRecord struct { domain string key string keyFqdn string keyValue string tld string sld string host string } // newPseudoRecord builds a challenge record from a domain name and a challenge authentication key. func newPseudoRecord(domain, keyAuth string) (*pseudoRecord, error) { domain = dns01.UnFqdn(domain) tld, _ := publicsuffix.PublicSuffix(domain) if tld == domain { return nil, fmt.Errorf("invalid domain name %q", domain) } parts := strings.Split(domain, ".") longest := len(parts) - strings.Count(tld, ".") - 1 sld := parts[longest-1] var host string if longest >= 1 { host = strings.Join(parts[:longest-1], ".") } info := dns01.GetChallengeInfo(domain, keyAuth) return &pseudoRecord{ domain: domain, key: "_acme-challenge." + host, keyFqdn: info.EffectiveFQDN, keyValue: info.Value, tld: tld, sld: sld, host: host, }, nil } ================================================ FILE: providers/dns/namecheap/namecheap.toml ================================================ Name = "Namecheap" URL = "https://www.namecheap.com" Code = "namecheap" Since = "v0.3.0" Description = ''' Configuration for [Namecheap](https://www.namecheap.com). **To enable API access on the Namecheap production environment, some opaque requirements must be met.** More information in the section [Enabling API Access](https://www.namecheap.com/support/api/intro/) of the Namecheap documentation. (2020-08: Account balance of $50+, 20+ domains in your account, or purchases totaling $50+ within the last 2 years.) ''' Example = ''' NAMECHEAP_API_USER=user \ NAMECHEAP_API_KEY=key \ lego --dns namecheap -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NAMECHEAP_API_USER = "API user" NAMECHEAP_API_KEY = "API key" [Configuration.Additional] NAMECHEAP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 15)" NAMECHEAP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 3600)" NAMECHEAP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" NAMECHEAP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" NAMECHEAP_SANDBOX = "Activate the sandbox (boolean)" [Links] API = "https://www.namecheap.com/support/api/methods.aspx" ================================================ FILE: providers/dns/namecheap/namecheap_test.go ================================================ package namecheap import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( envTestUser = "foo" envTestKey = "bar" envTestClientIP = "10.0.0.1" ) type testCase struct { name string domain string errString string getHostsResponse string setHostsResponse string } var testCases = []testCase{ { name: "Test:Success:1", domain: "test.example.com", getHostsResponse: "getHosts_success1.xml", setHostsResponse: "setHosts_success1.xml", }, { name: "Test:Success:2", domain: "example.com", getHostsResponse: "getHosts_success2.xml", setHostsResponse: "setHosts_success2.xml", }, { name: "Test:Error:BadApiKey:1", domain: "test.example.com", errString: "API Key is invalid or API access has not been enabled [1011102]", getHostsResponse: "getHosts_errorBadAPIKey1.xml", }, } func TestDNSProvider_Present(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { ch, _ := newPseudoRecord(test.domain, "") provider := mockBuilder(). Route("GET /", servermock.ResponseFromInternal(test.getHostsResponse), servermock.CheckForm().Strict(). With("ClientIp", "10.0.0.1"). With("Command", "namecheap.domains.dns.getHosts"). With("SLD", ch.sld). With("TLD", ch.tld). With("UserName", "foo"). With("ApiKey", "bar"). With("ApiUser", "foo"), ). Route("POST /", servermock.ResponseFromInternal(test.setHostsResponse), servermock.CheckForm(). With("ClientIp", "10.0.0.1"). With("Command", "namecheap.domains.dns.setHosts"). With("SLD", ch.sld). With("TLD", ch.tld). With("UserName", "foo"). With("ApiKey", "bar"). With("ApiUser", "foo"), ). Build(t) err := provider.Present(test.domain, "", "dummyKey") if test.errString != "" { assert.EqualError(t, err, "namecheap: "+test.errString) } else { assert.NoError(t, err) } }) } } func TestDNSProvider_CleanUp(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { ch, _ := newPseudoRecord(test.domain, "") provider := mockBuilder(). Route("GET /", servermock.ResponseFromInternal(test.getHostsResponse), servermock.CheckForm().Strict(). With("ClientIp", "10.0.0.1"). With("Command", "namecheap.domains.dns.getHosts"). With("SLD", ch.sld). With("TLD", ch.tld). With("UserName", "foo"). With("ApiKey", "bar"). With("ApiUser", "foo"), ). Route("POST /", servermock.ResponseFromInternal(test.setHostsResponse), servermock.CheckForm(). With("ClientIp", "10.0.0.1"). With("Command", "namecheap.domains.dns.setHosts"). With("SLD", ch.sld). With("TLD", ch.tld). With("UserName", "foo"). With("ApiKey", "bar"). With("ApiUser", "foo"), ). Build(t) err := provider.CleanUp(test.domain, "", "dummyKey") if test.errString != "" { assert.EqualError(t, err, "namecheap: "+test.errString) } else { assert.NoError(t, err) } }) } } func Test_newPseudoRecord_domainSplit(t *testing.T) { tests := []struct { domain string valid bool tld string sld string host string }{ {domain: "a.b.c.test.co.uk", valid: true, tld: "co.uk", sld: "test", host: "a.b.c"}, {domain: "test.co.uk", valid: true, tld: "co.uk", sld: "test"}, {domain: "test.com", valid: true, tld: "com", sld: "test"}, {domain: "test.co.com", valid: true, tld: "co.com", sld: "test"}, {domain: "www.test.com.au", valid: true, tld: "com.au", sld: "test", host: "www"}, {domain: "www.za.com", valid: true, tld: "za.com", sld: "www"}, {domain: "my.test.tf", valid: true, tld: "tf", sld: "test", host: "my"}, {}, {domain: "a"}, {domain: "com"}, {domain: "com.au"}, {domain: "co.com"}, {domain: "co.uk"}, {domain: "tf"}, {domain: "za.com"}, } for _, test := range tests { t.Run(test.domain, func(t *testing.T) { valid := true ch, err := newPseudoRecord(test.domain, "") if err != nil { valid = false } if test.valid && !valid { t.Errorf("Expected '%s' to split", test.domain) } else if !test.valid && valid { t.Errorf("Expected '%s' to produce error", test.domain) } if test.valid && valid { require.NotNil(t, ch) assert.Equal(t, test.domain, ch.domain, "domain") assert.Equal(t, test.tld, ch.tld, "tld") assert.Equal(t, test.sld, ch.sld, "sld") assert.Equal(t, test.host, ch.host, "host") } }) } } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.BaseURL = server.URL config.APIUser = envTestUser config.APIKey = envTestKey config.ClientIP = envTestClientIP return NewDNSProviderConfig(config) }) } ================================================ FILE: providers/dns/namecheap/transport.go ================================================ package namecheap import ( "net/http" "net/url" "strings" "sync" "github.com/go-acme/lego/v4/platform/config/env" "golang.org/x/net/http/httpproxy" ) const ( envHTTPProxy = "HTTP_PROXY" envHTTPProxyLower = "http_proxy" envHTTPSProxy = "HTTPS_PROXY" envHTTPSProxyLower = "https_proxy" envNoProxy = "NO_PROXY" envNoProxyLower = "no_proxy" envRequestMethod = "REQUEST_METHOD" ) // Allows lazy loading of the proxy. var ( envProxyOnce sync.Once envProxyFuncValue func(*url.URL) (*url.URL, error) ) func defaultTransport(namespace string) http.RoundTripper { tr, ok := http.DefaultTransport.(*http.Transport) if !ok { return nil } clone := tr.Clone() clone.Proxy = proxyFromEnvironment(namespace) return clone } // Inspired by: // - https://pkg.go.dev/net/http#ProxyFromEnvironment // - https://pkg.go.dev/golang.org/x/net/http/httpproxy#FromEnvironment func envProxyFunc(namespace string) func(*url.URL) (*url.URL, error) { envProxyOnce.Do(func() { cfg := &httpproxy.Config{ HTTPProxy: getEnv(namespace, envHTTPProxy, envHTTPProxyLower), HTTPSProxy: getEnv(namespace, envHTTPSProxy, envHTTPSProxyLower), NoProxy: getEnv(namespace, envNoProxy, envNoProxyLower), CGI: env.GetOneWithFallback(namespace+envRequestMethod, "", env.ParseString, envRequestMethod) != "", } envProxyFuncValue = cfg.ProxyFunc() }) return envProxyFuncValue } // Inspired by: // - https://pkg.go.dev/net/http#ProxyFromEnvironment // - https://pkg.go.dev/golang.org/x/net/http/httpproxy#FromEnvironment func proxyFromEnvironment(namespace string) func(req *http.Request) (*url.URL, error) { return func(req *http.Request) (*url.URL, error) { return envProxyFunc(namespace)(req.URL) } } func getEnv(namespace, baseEnvName, baseEnvNameLower string) string { return env.GetOneWithFallback(namespace+baseEnvName, "", env.ParseString, strings.ToLower(namespace)+baseEnvNameLower, baseEnvName, baseEnvNameLower) } ================================================ FILE: providers/dns/namecheap/transport_test.go ================================================ package namecheap import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_defaultTransport(t *testing.T) { client := servermock.NewBuilder( func(server *httptest.Server) (*http.Client, error) { cl := server.Client() t.Setenv("NAMECHEAP_HTTP_PROXY", server.URL) cl.Transport = defaultTransport(envNamespace) return cl, nil }). Route("/", servermock.Noop().WithStatusCode(http.StatusTeapot)). Build(t) req, err := http.NewRequest(http.MethodGet, "http://example.com", nil) require.NoError(t, err) resp, err := client.Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) assert.Equal(t, http.StatusTeapot, resp.StatusCode) } ================================================ FILE: providers/dns/namedotcom/namedotcom.go ================================================ // Package namedotcom implements a DNS provider for solving the DNS-01 challenge using Name.com's DNS service. package namedotcom import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/namedotcom/go/v4/namecom" ) // Environment variables names. const ( envNamespace = "NAMECOM_" EnvUsername = envNamespace + "USERNAME" EnvAPIToken = envNamespace + "API_TOKEN" EnvServer = envNamespace + "SERVER" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // according to https://www.name.com/api-docs/DNS#CreateRecord const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string APIToken string Server 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, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 20*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *namecom.NameCom config *Config } // NewDNSProvider returns a DNSProvider instance configured for namedotcom. // Credentials must be passed in the environment variables: // NAMECOM_USERNAME and NAMECOM_API_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvAPIToken) if err != nil { return nil, fmt.Errorf("namedotcom: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.APIToken = values[EnvAPIToken] config.Server = env.GetOrFile(EnvServer) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for namedotcom. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("namedotcom: the configuration of the DNS provider is nil") } if config.Username == "" { return nil, errors.New("namedotcom: username is required") } if config.APIToken == "" { return nil, errors.New("namedotcom: API token is required") } if config.TTL < minTTL { return nil, fmt.Errorf("namedotcom: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := namecom.New(config.Username, config.APIToken) if config.HTTPClient != nil { client.Client = config.HTTPClient } client.Client = clientdebug.Wrap(client.Client) if config.Server != "" { client.Server = config.Server } return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) if info.EffectiveFQDN != info.FQDN { domain = dns01.UnFqdn(info.EffectiveFQDN) } domainDetails, err := d.client.GetDomain(&namecom.GetDomainRequest{DomainName: domain}) if err != nil { return fmt.Errorf("namedotcom: API call failed: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, domainDetails.DomainName) if err != nil { return fmt.Errorf("namedotcom: %w", err) } request := &namecom.Record{ DomainName: domain, Host: subDomain, Type: "TXT", TTL: uint32(d.config.TTL), Answer: info.Value, } _, err = d.client.CreateRecord(request) if err != nil { return fmt.Errorf("namedotcom: API call failed: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) if info.EffectiveFQDN != info.FQDN { domain = dns01.UnFqdn(info.EffectiveFQDN) } records, err := d.getRecords(domain) if err != nil { return fmt.Errorf("namedotcom: %w", err) } for _, rec := range records { if rec.Fqdn == info.EffectiveFQDN && rec.Type == "TXT" { request := &namecom.DeleteRecordRequest{ DomainName: domain, ID: rec.ID, } _, err := d.client.DeleteRecord(request) if err != nil { return fmt.Errorf("namedotcom: %w", err) } } } return 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 } func (d *DNSProvider) getRecords(domain string) ([]*namecom.Record, error) { request := &namecom.ListRecordsRequest{ DomainName: domain, Page: 1, } var records []*namecom.Record for request.Page > 0 { response, err := d.client.ListRecords(request) if err != nil { return nil, err } records = append(records, response.Records...) request.Page = response.NextPage } return records, nil } ================================================ FILE: providers/dns/namedotcom/namedotcom.toml ================================================ Name = "Name.com" Description = '''''' URL = "https://www.name.com" Code = "namedotcom" Since = "v0.5.0" Example = ''' NAMECOM_USERNAME=foo.bar \ NAMECOM_API_TOKEN=a379a6f6eeafb9a55e378c118034e2751e682fab \ lego --dns namedotcom -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NAMECOM_USERNAME = "Username" NAMECOM_API_TOKEN = "API token" [Configuration.Additional] NAMECOM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 20)" NAMECOM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)" NAMECOM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" NAMECOM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.name.com/api-docs/DNS" GoClient = "https://github.com/namedotcom/go" ================================================ FILE: providers/dns/namedotcom/namedotcom_test.go ================================================ package namedotcom import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvAPIToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "A", EnvAPIToken: "B", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvUsername: "", EnvAPIToken: "", }, expected: "namedotcom: some credentials information are missing: NAMECOM_USERNAME,NAMECOM_API_TOKEN", }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvAPIToken: "B", }, expected: "namedotcom: some credentials information are missing: NAMECOM_USERNAME", }, { desc: "missing api token", envVars: map[string]string{ EnvUsername: "A", EnvAPIToken: "", }, expected: "namedotcom: some credentials information are missing: NAMECOM_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string username string expected string }{ { desc: "success", apiToken: "A", username: "B", }, { desc: "missing credentials", expected: "namedotcom: username is required", }, { desc: "missing API token", apiToken: "", username: "B", expected: "namedotcom: API token is required", }, { desc: "missing username", apiToken: "A", username: "", expected: "namedotcom: username is required", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/namesilo/namesilo.go ================================================ // Package namesilo implements a DNS provider for solving the DNS-01 challenge using namesilo DNS. package namesilo import ( "context" "errors" "fmt" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/namesilo" ) // Environment variables names. const ( envNamespace = "NAMESILO_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) const ( defaultTTL = 3600 maxTTL = 2592000 ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *namesilo.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for namesilo. // API_KEY must be passed in the environment variables: NAMESILO_API_KEY. // // See: https://www.namesilo.com/api_reference.php func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("namesilo: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Namesilo. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("namesilo: the configuration of the DNS provider is nil") } if config.TTL < defaultTTL || config.TTL > maxTTL { return nil, fmt.Errorf("namesilo: TTL should be in [%d, %d]", defaultTTL, maxTTL) } if config.APIKey == "" { return nil, errors.New("namesilo: credentials missing") } client := namesilo.NewClient(config.APIKey) client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("namesilo: could not find zone for domain %q: %w", domain, err) } zoneName := dns01.UnFqdn(zone) subdomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zoneName) if err != nil { return fmt.Errorf("namesilo: %w", err) } _, err = d.client.DnsAddRecord(context.Background(), &namesilo.DnsAddRecordParams{ Domain: zoneName, Type: "TXT", Host: subdomain, Value: info.Value, TTL: d.config.TTL, }) if err != nil { return fmt.Errorf("namesilo: failed to add record %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("namesilo: could not find zone for domain %q: %w", domain, err) } zoneName := dns01.UnFqdn(zone) resp, err := d.client.DnsListRecords(ctx, &namesilo.DnsListRecordsParams{Domain: zoneName}) if err != nil { return fmt.Errorf("namesilo: %w", err) } subdomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zoneName) if err != nil { return fmt.Errorf("namesilo: %w", err) } for _, r := range resp.Reply.ResourceRecord { if r.Type == "TXT" && r.Value == info.Value && (r.Host == subdomain || r.Host == dns01.UnFqdn(info.EffectiveFQDN)) { _, err := d.client.DnsDeleteRecord(ctx, &namesilo.DnsDeleteRecordParams{Domain: zoneName, ID: r.RecordID}) if err != nil { return fmt.Errorf("namesilo: %w", err) } return nil } } return fmt.Errorf("namesilo: no TXT record to delete for %s (%s)", info.EffectiveFQDN, info.Value) } // 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 } ================================================ FILE: providers/dns/namesilo/namesilo.toml ================================================ Name = "Namesilo" Description = '''''' URL = "https://www.namesilo.com/" Code = "namesilo" Since = "v2.7.0" Example = ''' NAMESILO_API_KEY=b9841238feb177a84330febba8a83208921177bffe733 \ lego --dns namesilo -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NAMESILO_API_KEY = "Client ID" [Configuration.Additional] NAMESILO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NAMESILO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60), it is better to set larger than 15 minutes" NAMESILO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600), should be in [3600, 2592000]" [Links] API = "https://www.namesilo.com/api_reference.php" GoClient = "https://github.com/nrdcg/namesilo" ================================================ FILE: providers/dns/namesilo/namesilo_test.go ================================================ package namesilo import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvTTL, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "A", }, }, { desc: "missing API key", envVars: map[string]string{}, expected: "namesilo: some credentials information are missing: NAMESILO_API_KEY", }, { desc: "unsupported TTL", envVars: map[string]string{ EnvAPIKey: "A", EnvTTL: "180", }, expected: "namesilo: TTL should be in [3600, 2592000]", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string ttl int expected string }{ { desc: "success", apiKey: "A", ttl: defaultTTL, }, { desc: "missing API key", ttl: defaultTTL, expected: "namesilo: credentials missing", }, { desc: "unavailable TTL", apiKey: "A", ttl: 100, expected: "namesilo: TTL should be in [3600, 2592000]", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.TTL = test.ttl p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/namesurfer/internal/client.go ================================================ package internal import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "slices" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) type Client struct { apiKey string apiSecret string BaseURL *url.URL HTTPClient *http.Client } func NewClient(baseURL, apiKey, apiSecret string) (*Client, error) { if apiKey == "" || apiSecret == "" { return nil, errors.New("credentials missing") } if baseURL == "" { return nil, errors.New("base URL missing") } apiEndpoint, err := url.Parse(baseURL) if err != nil { return nil, err } return &Client{ apiKey: apiKey, apiSecret: apiSecret, BaseURL: apiEndpoint.JoinPath("jsonrpc10"), HTTPClient: &http.Client{ Timeout: 5 * time.Second, }, }, nil } // AddDNSRecord adds a DNS record. // http://95.128.3.201:8053/API/NSService_10#addDNSRecord func (d *Client) AddDNSRecord(ctx context.Context, zoneName, viewName string, record DNSNode) error { digest := d.computeDigest( zoneName, viewName, record.Name, record.Type, strconv.Itoa(record.TTL), record.Data, ) // JSON-RPC 1.0 requires positional parameters array params := []any{ digest, zoneName, viewName, record, } var ok bool err := d.doRequest(ctx, "addDNSRecord", params, &ok) if err != nil { return err } if !ok { return errors.New("addDNSRecord failed") } return nil } // UpdateDNSHost updates a DNS host record. // Passing an empty newNode removes the oldNode. // http://95.128.3.201:8053/API/NSService_10#updateDNSHost func (d *Client) UpdateDNSHost(ctx context.Context, zoneName, viewName string, oldNode, newNode DNSNode) error { digest := d.computeDigest(zoneName, viewName) // JSON-RPC 1.0 requires positional parameters array params := []any{ digest, zoneName, viewName, oldNode, newNode, } var ok bool err := d.doRequest(ctx, "updateDNSHost", params, &ok) if err != nil { return err } if !ok { return errors.New("updateDNSHost failed") } return nil } // SearchDNSHosts searches for DNS host records. // http://95.128.3.201:8053/API/NSService_10#searchDNSHosts func (d *Client) SearchDNSHosts(ctx context.Context, pattern string) ([]DNSNode, error) { digest := d.computeDigest(pattern) // JSON-RPC 1.0 requires positional parameters array params := []any{ digest, pattern, } var nodes []DNSNode err := d.doRequest(ctx, "searchDNSHosts", params, &nodes) if err != nil { return nil, err } return nodes, nil } // ListZones lists DNS zones. // http://95.128.3.201:8053/API/NSService_10#listZones func (d *Client) ListZones(ctx context.Context, mode string) ([]DNSZone, error) { digest := d.computeDigest() // JSON-RPC 1.0 requires positional parameters array params := []any{ digest, mode, } var zones []DNSZone err := d.doRequest(ctx, "listZones", params, &zones) if err != nil { return nil, err } return zones, nil } func (d *Client) doRequest(ctx context.Context, method string, params []any, result any) error { payload := APIRequest{ ID: 1, Method: method, Params: slices.Concat([]any{d.apiKey}, params), } buf := new(bytes.Buffer) err := json.NewEncoder(buf).Encode(payload) if err != nil { return fmt.Errorf("failed to create request JSON body: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.BaseURL.String(), buf) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := d.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } var rpcResp APIResponse err = json.Unmarshal(raw, &rpcResp) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if rpcResp.Error != nil { return rpcResp.Error } err = json.Unmarshal(rpcResp.Result, result) if err != nil { return fmt.Errorf("unable to unmarshal response: %w: %s", err, rpcResp.Result) } return nil } func (d *Client) computeDigest(parts ...string) string { params := []string{d.apiKey} params = append(params, parts...) params = append(params, d.apiSecret) mac := hmac.New(sha256.New, []byte(d.apiSecret)) mac.Write([]byte(strings.Join(params, "&"))) return hex.EncodeToString(mac.Sum(nil)) } ================================================ FILE: providers/dns/namesurfer/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "user", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestClient_AddDNSRecord(t *testing.T) { client := mockBuilder(). Route("POST /jsonrpc10", servermock.ResponseFromFixture("addDNSRecord.json"), servermock.CheckRequestJSONBodyFromFixture("addDNSRecord-request.json"), ). Build(t) record := DNSNode{ Name: "_acme-challenge", Type: "TXT", Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300, } err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record) require.NoError(t, err) } func TestClient_AddDNSRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /jsonrpc10", servermock.ResponseFromFixture("error.json"), ). Build(t) record := DNSNode{ Name: "_acme-challenge", Type: "TXT", Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300, } err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record) require.EqualError(t, err, "code: Server.Keyfailure, "+ "filename: service, line: 13, "+ "message: Unknown keyname user, "+ `detail: Traceback (most recent call last): File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 159, in dispatch_request result = self.call_method(method,req_dict,tc,export_dict,log_line) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 96, in call_method result = getattr(service_class_instance,req_dict['methodname'])(*args) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py", line 77, in injector res = f(*args,**kw) File "/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py", line 502, in addDNSRecord key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data]) File "/usr/local/namesurfer/webui2/webui/service/base/implementation.py", line 63, in validate_key raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname) ApiFault: service(13): Unknown keyname user `) } func TestClient_UpdateDNSHost(t *testing.T) { client := mockBuilder(). Route("POST /jsonrpc10", servermock.ResponseFromFixture("updateDNSHost.json"), servermock.CheckRequestJSONBodyFromFixture("updateDNSHost-request.json"), ). Build(t) record := DNSNode{ Name: "_acme-challenge", Type: "TXT", Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300, } err := client.UpdateDNSHost(t.Context(), "example.com", "viewA", record, DNSNode{}) require.NoError(t, err) } func TestClient_SearchDNSHosts(t *testing.T) { client := mockBuilder(). Route("POST /jsonrpc10", servermock.ResponseFromFixture("searchDNSHosts.json"), servermock.CheckRequestJSONBodyFromFixture("searchDNSHosts-request.json"), ). Build(t) records, err := client.SearchDNSHosts(t.Context(), "value") require.NoError(t, err) expected := []DNSNode{ {Name: "foo", Type: "TXT", Data: "xxx", TTL: 300}, {Name: "_acme-challenge", Type: "TXT", Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300}, {Name: "bar", Type: "A", Data: "yyy", TTL: 300}, } assert.Equal(t, expected, records) } func TestClient_ListZones(t *testing.T) { client := mockBuilder(). Route("POST /jsonrpc10", servermock.ResponseFromFixture("listZones.json"), servermock.CheckRequestJSONBodyFromFixture("listZones-request.json"), ). Build(t) zones, err := client.ListZones(t.Context(), "value") require.NoError(t, err) expected := []DNSZone{ {Name: "example.com", View: "viewA"}, {Name: "example.org", View: "viewB"}, {Name: "example.net", View: "viewC"}, } assert.Equal(t, expected, zones) } func TestClient_computeDigest(t *testing.T) { client, err := NewClient("https://test.example.com", "testkey", "testsecret") require.NoError(t, err) testCases := []struct { desc string parts []string expected string }{ { desc: "no parts", parts: []string{}, expected: "99b5dcdc19bfc0ce2af3fe848f4bcb6f7beb352e9599e8ba50544d86de567282", }, { desc: "parts", parts: []string{"zone.example.com", "default"}, expected: "94efef76383889b1ae620582a25d1c3aa9bd9ba9ac4bdccdf4aefbc3ae6e8329", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() digest := client.computeDigest(test.parts...) assert.Equal(t, test.expected, digest) }) } } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json ================================================ { "id": 1, "method": "addDNSRecord", "params": [ "user", "4fcc5fa29531709b0381c8debea127a6a26e71cb9491727876819cf5805c4990", "example.com", "viewA", { "name": "_acme-challenge", "type": "TXT", "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 300 } ] } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/addDNSRecord.json ================================================ { "id": 1, "result": true } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/error.json ================================================ { "result": null, "error": { "filename": "service", "lineno": 13, "code": "Server.Keyfailure", "string": "Unknown keyname user", "detail": [ "Traceback (most recent call last):", " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 159, in dispatch_request", " result = self.call_method(method,req_dict,tc,export_dict,log_line)", " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 96, in call_method", " result = getattr(service_class_instance,req_dict['methodname'])(*args)", " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py\", line 77, in injector", " res = f(*args,**kw)", " File \"/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py\", line 502, in addDNSRecord", " key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data])", " File \"/usr/local/namesurfer/webui2/webui/service/base/implementation.py\", line 63, in validate_key", " raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname)", "ApiFault: service(13): Unknown keyname user", "" ] } } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/listZones-request.json ================================================ { "id": 1, "method": "listZones", "params": [ "user", "2739461ea1a3dc51302993f724f40228409c53b78025d8d7b1d7bba3c1bf2d66", "value" ] } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/listZones.json ================================================ { "id": 1, "result": [ { "name": "example.com", "view": "viewA" }, { "name": "example.org", "view": "viewB" }, { "name": "example.net", "view": "viewC" } ] } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json ================================================ { "id": 1, "method": "searchDNSHosts", "params": [ "user", "02cf1a2f6e124507d16738d595f583932185313fc96afc2d8404960acaec29b4", "value" ] } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json ================================================ { "id": 1, "result": [ { "name": "foo", "type": "TXT", "data": "xxx", "ttl": 300 }, { "name": "_acme-challenge", "type": "TXT", "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 300 }, { "name": "bar", "type": "A", "data": "yyy", "ttl": 300 } ] } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json ================================================ { "id": 1, "method": "updateDNSHost", "params": [ "user", "510e63288ac874c1d5ba313a9411591daa346e5621fb0153263adc278794e378", "example.com", "viewA", { "name": "_acme-challenge", "type": "TXT", "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 300 }, { "name": "", "type": "", "data": "", "ttl": 0 } ] } ================================================ FILE: providers/dns/namesurfer/internal/fixtures/updateDNSHost.json ================================================ { "id": 1, "result": true } ================================================ FILE: providers/dns/namesurfer/internal/types.go ================================================ package internal import ( "encoding/json" "fmt" "strings" ) // DNSNode represents a DNS record. // http://95.128.3.201:8053/API/NSService_10#DNSNode type DNSNode struct { Name string `json:"name"` Type string `json:"type"` Data string `json:"data"` TTL int `json:"ttl"` } // DNSZone represents a DNS zone. // http://95.128.3.201:8053/API/NSService_10#DNSZone type DNSZone struct { Name string `json:"name,omitempty"` View string `json:"view,omitempty"` } // APIRequest represents a JSON-RPC request. // https://www.jsonrpc.org/specification_v1#a1.1Requestmethodinvocation type APIRequest struct { ID any `json:"id"` // Can be int or string depending on API Method string `json:"method"` Params []any `json:"params"` } // APIResponse represents a JSON-RPC response. // https://www.jsonrpc.org/specification_v1#a1.2Response type APIResponse struct { ID any `json:"id"` // Can be int or string depending on API Result json.RawMessage `json:"result"` Error *APIError `json:"error"` } // APIError represents an error. type APIError struct { Code any `json:"code"` // Can be int or string depending on API Filename string `json:"filename"` LineNumber int `json:"lineno"` Message string `json:"string"` Detail []string `json:"detail"` } func (e *APIError) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "code: %v", e.Code) if e.Filename != "" { _, _ = fmt.Fprintf(msg, ", filename: %s", e.Filename) } if e.LineNumber > 0 { _, _ = fmt.Fprintf(msg, ", line: %d", e.LineNumber) } if e.Message != "" { _, _ = fmt.Fprintf(msg, ", message: %s", e.Message) } if len(e.Detail) > 0 { _, _ = fmt.Fprintf(msg, ", detail: %v", strings.Join(e.Detail, " ")) } return msg.String() } ================================================ FILE: providers/dns/namesurfer/namesurfer.go ================================================ // Package namesurfer implements a DNS provider for solving the DNS-01 challenge using FusionLayer NameSurfer API. package namesurfer import ( "context" "crypto/tls" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/namesurfer/internal" ) // Environment variables names. const ( envNamespace = "NAMESURFER_" EnvBaseURL = envNamespace + "BASE_URL" EnvAPIKey = envNamespace + "API_KEY" EnvAPISecret = envNamespace + "API_SECRET" EnvView = envNamespace + "VIEW" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" ) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIKey string APISecret string View string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client zones map[string]string zonesMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for FusionLayer NameSurfer. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvBaseURL, EnvAPIKey, EnvAPISecret) if err != nil { return nil, fmt.Errorf("namesurfer: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvBaseURL] config.APIKey = values[EnvAPIKey] config.APISecret = values[EnvAPISecret] config.View = env.GetOrDefaultString(EnvView, "") if env.GetOrDefaultBool(EnvInsecureSkipVerify, false) { config.HTTPClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for FusionLayer NameSurfer. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("namesurfer: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.BaseURL, config.APIKey, config.APISecret) if err != nil { return nil, fmt.Errorf("namesurfer: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, zones: make(map[string]string), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.findZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("namesurfer: %w", err) } d.zonesMu.Lock() d.zones[token] = zone d.zonesMu.Unlock() subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) if err != nil { return fmt.Errorf("namesurfer: %w", err) } record := internal.DNSNode{ Name: subDomain, Type: "TXT", TTL: d.config.TTL, Data: info.Value, } err = d.client.AddDNSRecord(ctx, zone, d.config.View, record) if err != nil { return fmt.Errorf("namesurfer: add DNS record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) d.zonesMu.Lock() zone, ok := d.zones[token] d.zonesMu.Unlock() if !ok { return fmt.Errorf("namesurfer: unknown zone for '%s'", info.EffectiveFQDN) } d.zonesMu.Lock() delete(d.zones, token) d.zonesMu.Unlock() existing, err := d.client.SearchDNSHosts(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("namesurfer: search DNS hosts: %w", err) } for _, node := range existing { if node.Type != "TXT" || node.Data != info.Value { continue } err = d.client.UpdateDNSHost(ctx, zone, d.config.View, node, internal.DNSNode{}) if err != nil { return fmt.Errorf("namesurfer: update DNS host: %w", err) } } return nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { zones, err := d.client.ListZones(ctx, "forward") if err != nil { return "", fmt.Errorf("list zones: %w", err) } domain := dns01.UnFqdn(fqdn) var zoneName string for _, zone := range zones { if strings.HasSuffix(domain, zone.Name) && len(zone.Name) > len(zoneName) { zoneName = zone.Name } } if zoneName == "" { return "", fmt.Errorf("no zone found for %s", fqdn) } return zoneName, nil } ================================================ FILE: providers/dns/namesurfer/namesurfer.toml ================================================ Name = "FusionLayer NameSurfer" Description = '''''' URL = "https://www.fusionlayer.com/" Code = "namesurfer" Since = "v4.32.0" Example = ''' NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \ NAMESURFER_API_KEY=xxx \ NAMESURFER_API_SECRET=yyy \ lego --dns namesurfer -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NAMESURFER_BASE_URL = "The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)" NAMESURFER_API_KEY = "API key name" NAMESURFER_API_SECRET = "API secret" [Configuration.Additional] NAMESURFER_VIEW = "DNS view name (optional, default: empty string)" NAMESURFER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NAMESURFER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" NAMESURFER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" NAMESURFER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" NAMESURFER_INSECURE_SKIP_VERIFY = "Whether to verify the API certificate" [Links] API = "https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10" ================================================ FILE: providers/dns/namesurfer/namesurfer_test.go ================================================ package namesurfer import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvBaseURL, EnvAPIKey, EnvAPISecret, EnvView, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvBaseURL: "https://example.com", EnvAPIKey: "user", EnvAPISecret: "secret", }, }, { desc: "missing base URL", envVars: map[string]string{ EnvBaseURL: "", EnvAPIKey: "user", EnvAPISecret: "secret", }, expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL", }, { desc: "missing API key", envVars: map[string]string{ EnvBaseURL: "https://example.com", EnvAPIKey: "", EnvAPISecret: "secret", }, expected: "namesurfer: some credentials information are missing: NAMESURFER_API_KEY", }, { desc: "missing API secret", envVars: map[string]string{ EnvBaseURL: "https://example.com", EnvAPIKey: "user", EnvAPISecret: "", }, expected: "namesurfer: some credentials information are missing: NAMESURFER_API_SECRET", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL,NAMESURFER_API_KEY,NAMESURFER_API_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string baseURL string apiKey string apiSecret string expected string }{ { desc: "success", baseURL: "https://example.com", apiKey: "user", apiSecret: "secret", }, { desc: "missing base URL", apiKey: "user", apiSecret: "secret", expected: "namesurfer: base URL missing", }, { desc: "missing API key", baseURL: "https://example.com", apiSecret: "secret", expected: "namesurfer: credentials missing", }, { desc: "missing API secret", baseURL: "https://example.com", apiKey: "user", expected: "namesurfer: credentials missing", }, { desc: "missing credentials", expected: "namesurfer: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.BaseURL = test.baseURL config.APIKey = test.apiKey config.APISecret = test.apiSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/nearlyfreespeech/internal/client.go ================================================ package internal import ( "context" "crypto/sha1" "encoding/json" "fmt" "io" "math/rand" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const apiURL = "https://api.nearlyfreespeech.net" const authenticationHeader = "X-NFSN-Authentication" const saltBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" type Client struct { login string apiKey string signer *Signer baseURL *url.URL HTTPClient *http.Client } func NewClient(login, apiKey string) *Client { baseURL, _ := url.Parse(apiURL) return &Client{ login: login, apiKey: apiKey, signer: NewSigner(), baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "addRR") params, err := querystring.Values(record) if err != nil { return err } return c.doRequest(ctx, endpoint, params) } func (c *Client) RemoveRecord(ctx context.Context, domain string, record Record) error { endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "removeRR") params, err := querystring.Values(record) if err != nil { return err } return c.doRequest(ctx, endpoint, params) } func (c *Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Values) error { payload := params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(payload)) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set(authenticationHeader, c.signer.Sign(endpoint.Path, payload, c.login, c.apiKey)) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } return nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIError{} err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errAPI } type Signer struct { saltShaker func() []byte clock func() time.Time } func NewSigner() *Signer { return &Signer{saltShaker: getRandomSalt, clock: time.Now} } func (c Signer) Sign(uri, body, login, apiKey string) string { // Header is "login;timestamp;salt;hash". // hash is SHA1("login;timestamp;salt;api-key;request-uri;body-hash") // and body-hash is SHA1(body). bodyHash := sha1.Sum([]byte(body)) timestamp := strconv.FormatInt(c.clock().Unix(), 10) // Workaround for https://golang.org/issue/58605 uri = "/" + strings.TrimLeft(uri, "/") salt := c.saltShaker() hashInput := fmt.Sprintf("%s;%s;%s;%s;%s;%02x", login, timestamp, salt, apiKey, uri, bodyHash) return fmt.Sprintf("%s;%s;%s;%02x", login, timestamp, salt, sha1.Sum([]byte(hashInput))) } func getRandomSalt() []byte { // This is the only part of this that needs to be serialized. salt := make([]byte, 16) for i := range 16 { salt[i] = saltBytes[rand.Intn(len(saltBytes))] } return salt } ================================================ FILE: providers/dns/nearlyfreespeech/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) client.signer.saltShaker = func() []byte { return []byte("0123456789ABCDEF") } client.signer.clock = func() time.Time { return time.Unix(1692475113, 0) } return client, nil } func TestClient_AddRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader(). WithContentTypeFromURLEncoded(). With(authenticationHeader, "user;1692475113;0123456789ABCDEF;24a32faf74c7bd0525f560ff12a1c1fb6545bafc"), ). Route("POST /dns/example.com/addRR", nil, servermock.CheckForm().Strict(). With("data", "txtTXTtxt"). With("name", "sub"). With("type", "TXT"). With("ttl", "30"), ). Build(t) record := Record{ Name: "sub", Type: "TXT", Data: "txtTXTtxt", TTL: 30, } err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader(). WithContentTypeFromURLEncoded(). With(authenticationHeader, "user;1692475113;0123456789ABCDEF;24a32faf74c7bd0525f560ff12a1c1fb6545bafc"), ). Route("POST /dns/example.com/addRR", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) record := Record{ Name: "sub", Type: "TXT", Data: "txtTXTtxt", TTL: 30, } err := client.AddRecord(t.Context(), "example.com", record) require.Error(t, err) } func TestClient_RemoveRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader(). WithContentTypeFromURLEncoded(). With(authenticationHeader, "user;1692475113;0123456789ABCDEF;699f01f077ca487bd66ac370d6dfc5b122c65522"), ). Route("POST /dns/example.com/removeRR", nil, servermock.CheckForm().Strict(). With("data", "txtTXTtxt"). With("name", "sub"). With("type", "TXT"), ). Build(t) record := Record{ Name: "sub", Type: "TXT", Data: "txtTXTtxt", } err := client.RemoveRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader(). WithContentTypeFromURLEncoded(). With(authenticationHeader, "user;1692475113;0123456789ABCDEF;699f01f077ca487bd66ac370d6dfc5b122c65522"), ). Route("POST /dns/example.com/removeRR", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) record := Record{ Name: "sub", Type: "TXT", Data: "txtTXTtxt", } err := client.RemoveRecord(t.Context(), "example.com", record) require.Error(t, err) } func TestSigner_Sign(t *testing.T) { testCases := []struct { desc string path string now int64 salt string expected string }{ { desc: "basic", path: "/path", now: 1692475113, salt: "0123456789ABCDEF", expected: "user;1692475113;0123456789ABCDEF;417a9988c7ad7919b297884dd120b5808d8a1e6f", }, { desc: "another date", path: "/path", now: 1692567766, salt: "0123456789ABCDEF", expected: "user;1692567766;0123456789ABCDEF;b5c28286fd2e1a45a7c576dc2a6430116f721502", }, { desc: "another salt", path: "/path", now: 1692475113, salt: "FEDCBA9876543210", expected: "user;1692475113;FEDCBA9876543210;0f766822bda4fdc09829be4e1ea5e27ae3ae334e", }, { desc: "empty path", path: "", now: 1692475113, salt: "0123456789ABCDEF", expected: "user;1692475113;0123456789ABCDEF;c7c241a4d15d04d92805631d58d4d72ac1c339a1", }, { desc: "root path", path: "/", now: 1692475113, salt: "0123456789ABCDEF", expected: "user;1692475113;0123456789ABCDEF;c7c241a4d15d04d92805631d58d4d72ac1c339a1", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() signer := NewSigner() signer.saltShaker = func() []byte { return []byte(test.salt) } signer.clock = func() time.Time { return time.Unix(test.now, 0) } sign := signer.Sign(test.path, "data", "user", "secret") assert.Equal(t, test.expected, sign) }) } } ================================================ FILE: providers/dns/nearlyfreespeech/internal/fixtures/error.json ================================================ { "error": "The API request could not be authenticated.", "debug": "The X-NFSN-Authentication header is not present." } ================================================ FILE: providers/dns/nearlyfreespeech/internal/types.go ================================================ package internal import "fmt" type Record struct { Name string `url:"name,omitempty"` Type string `url:"type,omitempty"` Data string `url:"data,omitempty"` TTL int `url:"ttl,omitempty"` } type APIError struct { Message string `json:"error"` Debug string `json:"debug"` } func (a APIError) Error() string { return fmt.Sprintf("%s: %s", a.Message, a.Debug) } ================================================ FILE: providers/dns/nearlyfreespeech/nearlyfreespeech.go ================================================ // Package nearlyfreespeech implements a DNS provider for solving the DNS-01 challenge using NearlyFreeSpeech.NET. package nearlyfreespeech import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/nearlyfreespeech/internal" ) // Environment variables names. const ( envNamespace = "NEARLYFREESPEECH_" EnvLogin = envNamespace + "LOGIN" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string Login string TTL int PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 3600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for NearlyFreeSpeech.NET. // Credentials must be passed in the environment variable: NEARLYFREESPEECH_LOGIN, NEARLYFREESPEECH_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvLogin) if err != nil { return nil, fmt.Errorf("nearlyfreespeech: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.Login = values[EnvLogin] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for NearlyFreeSpeech.NET. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("nearlyfreespeech: the configuration of the DNS provider is nil") } if config.Login == "" || config.APIKey == "" { return nil, errors.New("nearlyfreespeech: API credentials are missing") } client := internal.NewClient(config.Login, config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("nearlyfreespeech: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("nearlyfreespeech: %w", err) } record := internal.Record{ Name: recordName, Type: "TXT", Data: info.Value, TTL: d.config.TTL, } err = d.client.AddRecord(context.Background(), authZone, record) if err != nil { return fmt.Errorf("nearlyfreespeech: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("nearlyfreespeech: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("nearlyfreespeech: %w", err) } record := internal.Record{ Name: recordName, Type: "TXT", Data: info.Value, } err = d.client.RemoveRecord(context.Background(), domain, record) if err != nil { return fmt.Errorf("nearlyfreespeech: %w", err) } return nil } ================================================ FILE: providers/dns/nearlyfreespeech/nearlyfreespeech.toml ================================================ Name = "NearlyFreeSpeech.NET" Description = '''''' URL = "https://nearlyfreespeech.net/" Code = "nearlyfreespeech" Since = "v4.8.0" Example = ''' NEARLYFREESPEECH_API_KEY=xxxxxx \ NEARLYFREESPEECH_LOGIN=xxxx \ lego --dns nearlyfreespeech -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NEARLYFREESPEECH_API_KEY = "API Key for API requests" NEARLYFREESPEECH_LOGIN = "Username for API requests" [Configuration.Additional] NEARLYFREESPEECH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NEARLYFREESPEECH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" NEARLYFREESPEECH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" NEARLYFREESPEECH_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" NEARLYFREESPEECH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://members.nearlyfreespeech.net/wiki/API/Reference" ================================================ FILE: providers/dns/nearlyfreespeech/nearlyfreespeech_test.go ================================================ package nearlyfreespeech import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvLogin).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvLogin: "testuser", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvLogin: "", }, expected: "nearlyfreespeech: some credentials information are missing: NEARLYFREESPEECH_API_KEY,NEARLYFREESPEECH_LOGIN", }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", EnvLogin: "testuser", }, expected: "nearlyfreespeech: some credentials information are missing: NEARLYFREESPEECH_API_KEY", }, { desc: "missing login", envVars: map[string]string{ EnvAPIKey: "123", EnvLogin: "", }, expected: "nearlyfreespeech: some credentials information are missing: NEARLYFREESPEECH_LOGIN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string login string apikey string expected string }{ { desc: "success", login: "login", apikey: "apikey", }, { desc: "missing credentials", expected: "nearlyfreespeech: API credentials are missing", }, { desc: "missing login", login: "", apikey: "apikey", expected: "nearlyfreespeech: API credentials are missing", }, { desc: "missing key", login: "login", apikey: "", expected: "nearlyfreespeech: API credentials are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apikey config.Login = test.login p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/neodigit/neodigit.go ================================================ // Package neodigit implements a DNS provider for solving the DNS-01 challenge using Neodigit DNS. package neodigit import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica" ) // Environment variables names. const ( envNamespace = "NEODIGIT_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://api.neodigit.net/v1" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = tecnocratica.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Neodigit. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("neodigit: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Neodigit. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("neodigit: the configuration of the DNS provider is nil") } provider, err := tecnocratica.NewDNSProviderConfig(config, defaultBaseURL) if err != nil { return nil, fmt.Errorf("neodigit: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("neodigit: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("neodigit: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/neodigit/neodigit.toml ================================================ Name = "Neodigit" Description = '''''' URL = "https://www.neodigit.net" Code = "neodigit" Since = "v4.30.0" Example = ''' NEODIGIT_TOKEN=xxxxxx \ lego --dns neodigit -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NEODIGIT_TOKEN = "API token" [Configuration.Additional] NEODIGIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" NEODIGIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" NEODIGIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" NEODIGIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.neodigit.net/#dns" ================================================ FILE: providers/dns/neodigit/neodigit_test.go ================================================ package neodigit import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "secret", }, }, { desc: "missing credentials: token", envVars: map[string]string{ EnvToken: "", }, expected: "neodigit: some credentials information are missing: NEODIGIT_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "secret", }, { desc: "missing token", expected: "neodigit: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/netcup/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // defaultBaseURL for reaching the jSON-based API-Endpoint of netcup. const defaultBaseURL = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON" // Client netcup DNS client. type Client struct { customerNumber string apiKey string apiPassword string baseURL string HTTPClient *http.Client } // NewClient creates a netcup DNS client. func NewClient(customerNumber, apiKey, apiPassword string) (*Client, error) { if customerNumber == "" || apiKey == "" || apiPassword == "" { return nil, errors.New("credentials missing") } return &Client{ customerNumber: customerNumber, apiKey: apiKey, apiPassword: apiPassword, baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // UpdateDNSRecord performs an update of the DNSRecords as specified by the netcup WSDL. // https://ccp.netcup.net/run/webservice/servers/endpoint.php func (c *Client) UpdateDNSRecord(ctx context.Context, domainName string, records []DNSRecord) error { payload := &Request{ Action: "updateDnsRecords", Param: UpdateDNSRecordsRequest{ DomainName: domainName, CustomerNumber: c.customerNumber, APIKey: c.apiKey, APISessionID: getSessionID(ctx), ClientRequestID: "", DNSRecordSet: DNSRecordSet{DNSRecords: records}, }, } err := c.doRequest(ctx, payload, nil) if err != nil { return fmt.Errorf("error when sending the request: %w", err) } return nil } // GetDNSRecords retrieves all dns records of an DNS-Zone as specified by the netcup WSDL // returns an array of DNSRecords. // https://ccp.netcup.net/run/webservice/servers/endpoint.php func (c *Client) GetDNSRecords(ctx context.Context, hostname string) ([]DNSRecord, error) { payload := &Request{ Action: "infoDnsRecords", Param: InfoDNSRecordsRequest{ DomainName: hostname, CustomerNumber: c.customerNumber, APIKey: c.apiKey, APISessionID: getSessionID(ctx), ClientRequestID: "", }, } var responseData InfoDNSRecordsResponse err := c.doRequest(ctx, payload, &responseData) if err != nil { return nil, fmt.Errorf("error when sending the request: %w", err) } return responseData.DNSRecords, nil } // doRequest marshals given body to JSON, send the request to netcup API // and returns body of response. func (c *Client) doRequest(ctx context.Context, payload, result any) error { req, err := newJSONRequest(ctx, http.MethodPost, c.baseURL, payload) if err != nil { return err } req.Close = true resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusMultipleChoices { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } respMsg, err := unmarshalResponseMsg(req, resp) if err != nil { return err } if respMsg.Status != success { return respMsg } if result == nil { return nil } err = json.Unmarshal(respMsg.ResponseData, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, respMsg.ResponseData, err) } return nil } // GetDNSRecordIdx searches a given array of DNSRecords for a given DNSRecord // equivalence is determined by Destination and RecortType attributes // returns index of given DNSRecord in given array of DNSRecords. func GetDNSRecordIdx(records []DNSRecord, record DNSRecord) (int, error) { for index, element := range records { if record.Destination == element.Destination && record.RecordType == element.RecordType { return index, nil } } return -1, errors.New("no DNS Record found") } func newJSONRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint, buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func unmarshalResponseMsg(req *http.Request, resp *http.Response) (*ResponseMsg, error) { raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var respMsg ResponseMsg err = json.Unmarshal(raw, &respMsg) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &respMsg, nil } ================================================ FILE: providers/dns/netcup/internal/client_live_test.go ================================================ package internal import ( "fmt" "strconv" "strings" "testing" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest( "NETCUP_CUSTOMER_NUMBER", "NETCUP_API_KEY", "NETCUP_API_PASSWORD"). WithDomain("NETCUP_DOMAIN") func TestClient_GetDNSRecords_Live(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } // Setup envTest.RestoreEnv() client, err := NewClient( envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), envTest.GetValue("NETCUP_API_KEY"), envTest.GetValue("NETCUP_API_PASSWORD")) require.NoError(t, err) ctx, err := client.CreateSessionContext(t.Context()) require.NoError(t, err) info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) require.NoError(t, err) zone = dns01.UnFqdn(zone) // TestMethod _, err = client.GetDNSRecords(ctx, zone) require.NoError(t, err) // Tear down err = client.Logout(ctx) require.NoError(t, err) } func TestClient_UpdateDNSRecord_Live(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } // Setup envTest.RestoreEnv() client, err := NewClient( envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), envTest.GetValue("NETCUP_API_KEY"), envTest.GetValue("NETCUP_API_PASSWORD")) require.NoError(t, err) ctx, err := client.CreateSessionContext(t.Context()) require.NoError(t, err) info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) require.NotErrorIs(t, err, fmt.Errorf("error finding DNSZone, %w", err)) hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1) record := DNSRecord{ Hostname: hostname, RecordType: "TXT", Destination: "asdf5678", DeleteRecord: false, } // test zone = dns01.UnFqdn(zone) err = client.UpdateDNSRecord(ctx, zone, []DNSRecord{record}) require.NoError(t, err) records, err := client.GetDNSRecords(ctx, zone) require.NoError(t, err) recordIdx, err := GetDNSRecordIdx(records, record) require.NoError(t, err) assert.Equal(t, record.Hostname, records[recordIdx].Hostname) assert.Equal(t, record.RecordType, records[recordIdx].RecordType) assert.Equal(t, record.Destination, records[recordIdx].Destination) assert.Equal(t, record.DeleteRecord, records[recordIdx].DeleteRecord) records[recordIdx].DeleteRecord = true // Tear down err = client.UpdateDNSRecord(ctx, envTest.GetDomain(), []DNSRecord{records[recordIdx]}) require.NoError(t, err) err = client.Logout(ctx) require.NoError(t, err) } func TestLiveClientAuth(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } // Setup envTest.RestoreEnv() client, err := NewClient( envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), envTest.GetValue("NETCUP_API_KEY"), envTest.GetValue("NETCUP_API_PASSWORD")) require.NoError(t, err) for i := range 4 { t.Run("Test_"+strconv.Itoa(i+1), func(t *testing.T) { t.Parallel() ctx, err := client.CreateSessionContext(t.Context()) require.NoError(t, err) err = client.Logout(ctx) require.NoError(t, err) }) } } ================================================ FILE: providers/dns/netcup/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("a", "b", "c") if err != nil { return nil, err } client.baseURL = server.URL client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestGetDNSRecordIdx(t *testing.T) { records := []DNSRecord{ { ID: 12345, Hostname: "asdf", RecordType: "TXT", Priority: "0", Destination: "randomtext", DeleteRecord: false, State: "yes", }, { ID: 23456, Hostname: "@", RecordType: "A", Priority: "0", Destination: "127.0.0.1", DeleteRecord: false, State: "yes", }, { ID: 34567, Hostname: "dfgh", RecordType: "CNAME", Priority: "0", Destination: "example.com", DeleteRecord: false, State: "yes", }, { ID: 45678, Hostname: "fghj", RecordType: "MX", Priority: "10", Destination: "mail.example.com", DeleteRecord: false, State: "yes", }, } testCases := []struct { desc string record DNSRecord expectError bool }{ { desc: "simple", record: DNSRecord{ ID: 12345, Hostname: "asdf", RecordType: "TXT", Priority: "0", Destination: "randomtext", DeleteRecord: false, State: "yes", }, }, { desc: "wrong Destination", record: DNSRecord{ ID: 12345, Hostname: "asdf", RecordType: "TXT", Priority: "0", Destination: "wrong", DeleteRecord: false, State: "yes", }, expectError: true, }, { desc: "record type CNAME", record: DNSRecord{ ID: 12345, Hostname: "asdf", RecordType: "CNAME", Priority: "0", Destination: "randomtext", DeleteRecord: false, State: "yes", }, expectError: true, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() idx, err := GetDNSRecordIdx(records, test.record) if test.expectError { assert.Error(t, err) assert.Equal(t, -1, idx) } else { assert.NoError(t, err) assert.Equal(t, records[idx], test.record) } }) } } func TestClient_GetDNSRecords(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("get_dns_records.json"), servermock.CheckRequestJSONBodyFromFixture("get_dns_records-request.json")). Build(t) expected := []DNSRecord{{ ID: 1, Hostname: "example.com", RecordType: "TXT", Priority: "1", Destination: "bGVnbzE=", DeleteRecord: false, State: "yes", }, { ID: 2, Hostname: "example2.com", RecordType: "TXT", Priority: "1", Destination: "bGVnbw==", DeleteRecord: false, State: "yes", }} records, err := client.GetDNSRecords(t.Context(), "example.com") require.NoError(t, err) assert.Equal(t, expected, records) } func TestClient_GetDNSRecords_errors(t *testing.T) { testCases := []struct { desc string handler http.Handler expected string }{ { desc: "HTTP error", handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), expected: `error when sending the request: unexpected status code: [status code: 500] body: `, }, { desc: "API error", handler: servermock.ResponseFromFixture("get_dns_records_error.json"), expected: `error when sending the request: an error occurred during the action infoDnsRecords: [Status=error, StatusCode=4013, ShortMessage=Validation Error., LongMessage=Message is empty.]`, }, { desc: "responsedata marshaling error", handler: servermock.ResponseFromFixture("get_dns_records_error_unmarshal.json"), expected: `error when sending the request: unable to unmarshal response: [status code: 200] body: "" error: json: cannot unmarshal string into Go value of type internal.InfoDNSRecordsResponse`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route("POST /", test.handler). Build(t) records, err := client.GetDNSRecords(t.Context(), "example.com") require.EqualError(t, err, test.expected) assert.Empty(t, records) }) } } ================================================ FILE: providers/dns/netcup/internal/fixtures/get_dns_records-request.json ================================================ { "action": "infoDnsRecords", "param": { "domainname": "example.com", "customernumber": "a", "apikey": "b", "apisessionid": "" } } ================================================ FILE: providers/dns/netcup/internal/fixtures/get_dns_records.json ================================================ { "serverrequestid": "srv-request-id", "clientrequestid": "", "action": "infoDnsRecords", "status": "success", "statuscode": 2000, "shortmessage": "Login successful", "longmessage": "Session has been created successful.", "responsedata": { "apisessionid": "api-session-id", "dnsrecords": [ { "id": "1", "hostname": "example.com", "type": "TXT", "priority": "1", "destination": "bGVnbzE=", "state": "yes", "ttl": 300 }, { "id": "2", "hostname": "example2.com", "type": "TXT", "priority": "1", "destination": "bGVnbw==", "state": "yes", "ttl": 300 } ] } } ================================================ FILE: providers/dns/netcup/internal/fixtures/get_dns_records_error.json ================================================ { "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", "clientrequestid":"", "action":"infoDnsRecords", "status":"error", "statuscode":4013, "shortmessage":"Validation Error.", "longmessage":"Message is empty.", "responsedata":"" } ================================================ FILE: providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json ================================================ { "serverrequestid":"srv-request-id", "clientrequestid":"", "action":"infoDnsRecords", "status":"success", "statuscode":2000, "shortmessage":"Login successful", "longmessage":"Session has been created successful.", "responsedata":"" } ================================================ FILE: providers/dns/netcup/internal/fixtures/login-request.json ================================================ { "action": "login", "param": { "customernumber": "a", "apikey": "b", "apipassword": "c" } } ================================================ FILE: providers/dns/netcup/internal/fixtures/login.json ================================================ { "serverrequestid": "srv-request-id", "clientrequestid": "", "action": "login", "status": "success", "statuscode": 2000, "shortmessage": "Login successful", "longmessage": "Session has been created successful.", "responsedata": { "apisessionid": "api-session-id" } } ================================================ FILE: providers/dns/netcup/internal/fixtures/login_error.json ================================================ { "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", "clientrequestid":"", "action":"login", "status":"error", "statuscode":4013, "shortmessage":"Validation Error.", "longmessage":"Message is empty.", "responsedata":"" } ================================================ FILE: providers/dns/netcup/internal/fixtures/login_error_unmarshal.json ================================================ { "serverrequestid": "srv-request-id", "clientrequestid": "", "action": "login", "status": "success", "statuscode": 2000, "shortmessage": "Login successful", "longmessage": "Session has been created successful.", "responsedata": "" } ================================================ FILE: providers/dns/netcup/internal/fixtures/logout-request.json ================================================ { "action": "logout", "param": { "customernumber": "a", "apikey": "b", "apisessionid": "session-id" } } ================================================ FILE: providers/dns/netcup/internal/fixtures/logout.json ================================================ { "serverrequestid": "request-id", "clientrequestid": "", "action": "logout", "status": "success", "statuscode": 2000, "shortmessage": "Logout successful", "longmessage": "Session has been terminated successful.", "responsedata": "" } ================================================ FILE: providers/dns/netcup/internal/fixtures/logout_error.json ================================================ { "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", "clientrequestid":"", "action":"logout", "status":"error", "statuscode":4013, "shortmessage":"Validation Error.", "longmessage":"Message is empty.", "responsedata":"" } ================================================ FILE: providers/dns/netcup/internal/session.go ================================================ package internal import ( "context" "fmt" ) type sessionKey string const sessionIDKey sessionKey = "sessionID" // login performs the login as specified by the netcup WSDL // returns sessionID needed to perform remaining actions. // https://ccp.netcup.net/run/webservice/servers/endpoint.php func (c *Client) login(ctx context.Context) (string, error) { payload := &Request{ Action: "login", Param: &LoginRequest{ CustomerNumber: c.customerNumber, APIKey: c.apiKey, APIPassword: c.apiPassword, ClientRequestID: "", }, } var responseData LoginResponse err := c.doRequest(ctx, payload, &responseData) if err != nil { return "", fmt.Errorf("loging error: %w", err) } return responseData.APISessionID, nil } // Logout performs the logout with the supplied sessionID as specified by the netcup WSDL. // https://ccp.netcup.net/run/webservice/servers/endpoint.php func (c *Client) Logout(ctx context.Context) error { payload := &Request{ Action: "logout", Param: &LogoutRequest{ CustomerNumber: c.customerNumber, APIKey: c.apiKey, APISessionID: getSessionID(ctx), ClientRequestID: "", }, } err := c.doRequest(ctx, payload, nil) if err != nil { return fmt.Errorf("logout error: %w", err) } return nil } func (c *Client) CreateSessionContext(ctx context.Context) (context.Context, error) { sessID, err := c.login(ctx) if err != nil { return nil, err } return context.WithValue(ctx, sessionIDKey, sessID), nil } func getSessionID(ctx context.Context) string { sessID, ok := ctx.Value(sessionIDKey).(string) if !ok { return "" } return sessID } ================================================ FILE: providers/dns/netcup/internal/session_test.go ================================================ package internal import ( "context" "net/http" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockContext(t *testing.T) context.Context { t.Helper() return context.WithValue(t.Context(), sessionIDKey, "session-id") } func TestClient_Login(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("login.json"), servermock.CheckRequestJSONBodyFromFixture("login-request.json")). Build(t) sessionID, err := client.login(t.Context()) require.NoError(t, err) assert.Equal(t, "api-session-id", sessionID) } func TestClient_Login_errors(t *testing.T) { testCases := []struct { desc string handler http.Handler expected string }{ { desc: "HTTP error", handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), expected: `loging error: unexpected status code: [status code: 500] body: `, }, { desc: "API error", handler: servermock.ResponseFromFixture("login_error.json"), expected: `loging error: an error occurred during the action login: [Status=error, StatusCode=4013, ShortMessage=Validation Error., LongMessage=Message is empty.]`, }, { desc: "responsedata marshaling error", handler: servermock.ResponseFromFixture("login_error_unmarshal.json"), expected: `loging error: unable to unmarshal response: [status code: 200] body: "" error: json: cannot unmarshal string into Go value of type internal.LoginResponse`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route("POST /", test.handler). Build(t) sessionID, err := client.login(t.Context()) assert.EqualError(t, err, test.expected) assert.Empty(t, sessionID) }) } } func TestClient_Logout(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("logout.json"), servermock.CheckRequestJSONBodyFromFixture("logout-request.json")). Build(t) err := client.Logout(mockContext(t)) require.NoError(t, err) } func TestClient_Logout_errors(t *testing.T) { testCases := []struct { desc string handler http.Handler expected string }{ { desc: "HTTP error", handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), }, { desc: "API error", handler: servermock.ResponseFromFixture("login_error.json"), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route("POST /", test.handler). Build(t) err := client.Logout(t.Context()) require.Error(t, err) }) } } ================================================ FILE: providers/dns/netcup/internal/types.go ================================================ package internal import ( "encoding/json" "fmt" ) // success response status. const success = "success" // Request wrapper as specified in netcup wiki // needed for every request to netcup API around *Msg. // https://www.netcup-wiki.de/wiki/CCP_API#Anmerkungen_zu_JSON-Requests type Request struct { Action string `json:"action"` Param any `json:"param"` } // LoginRequest as specified in netcup WSDL. // https://ccp.netcup.net/run/webservice/servers/endpoint.php#login type LoginRequest struct { CustomerNumber string `json:"customernumber"` APIKey string `json:"apikey"` APIPassword string `json:"apipassword"` ClientRequestID string `json:"clientrequestid,omitempty"` } // LogoutRequest as specified in netcup WSDL. // https://ccp.netcup.net/run/webservice/servers/endpoint.php#logout type LogoutRequest struct { CustomerNumber string `json:"customernumber"` APIKey string `json:"apikey"` APISessionID string `json:"apisessionid"` ClientRequestID string `json:"clientrequestid,omitempty"` } // UpdateDNSRecordsRequest as specified in netcup WSDL. // https://ccp.netcup.net/run/webservice/servers/endpoint.php#updateDnsRecords type UpdateDNSRecordsRequest struct { DomainName string `json:"domainname"` CustomerNumber string `json:"customernumber"` APIKey string `json:"apikey"` APISessionID string `json:"apisessionid"` ClientRequestID string `json:"clientrequestid,omitempty"` DNSRecordSet DNSRecordSet `json:"dnsrecordset"` } // DNSRecordSet as specified in netcup WSDL. // needed in UpdateDNSRecordsRequest. // https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecordset type DNSRecordSet struct { DNSRecords []DNSRecord `json:"dnsrecords"` } // InfoDNSRecordsRequest as specified in netcup WSDL. // https://ccp.netcup.net/run/webservice/servers/endpoint.php#infoDnsRecords type InfoDNSRecordsRequest struct { DomainName string `json:"domainname"` CustomerNumber string `json:"customernumber"` APIKey string `json:"apikey"` APISessionID string `json:"apisessionid"` ClientRequestID string `json:"clientrequestid,omitempty"` } // DNSRecord as specified in netcup WSDL. // https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecord type DNSRecord struct { ID int `json:"id,string,omitempty"` Hostname string `json:"hostname"` RecordType string `json:"type"` Priority string `json:"priority,omitempty"` Destination string `json:"destination"` DeleteRecord bool `json:"deleterecord,omitempty"` State string `json:"state,omitempty"` } // ResponseMsg as specified in netcup WSDL. // https://ccp.netcup.net/run/webservice/servers/endpoint.php#Responsemessage type ResponseMsg struct { ServerRequestID string `json:"serverrequestid"` ClientRequestID string `json:"clientrequestid,omitempty"` Action string `json:"action"` Status string `json:"status"` StatusCode int `json:"statuscode"` ShortMessage string `json:"shortmessage"` LongMessage string `json:"longmessage"` ResponseData json.RawMessage `json:"responsedata,omitempty"` } func (r *ResponseMsg) Error() string { return fmt.Sprintf("an error occurred during the action %s: [Status=%s, StatusCode=%d, ShortMessage=%s, LongMessage=%s]", r.Action, r.Status, r.StatusCode, r.ShortMessage, r.LongMessage) } // LoginResponse response to login action. type LoginResponse struct { APISessionID string `json:"apisessionid"` } // InfoDNSRecordsResponse response to infoDnsRecords action. type InfoDNSRecordsResponse struct { APISessionID string `json:"apisessionid"` DNSRecords []DNSRecord `json:"dnsrecords,omitempty"` } ================================================ FILE: providers/dns/netcup/netcup.go ================================================ // Package netcup implements a DNS Provider for solving the DNS-01 challenge using the netcup DNS API. package netcup import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/netcup/internal" ) // Environment variables names. const ( envNamespace = "NETCUP_" EnvCustomerNumber = envNamespace + "CUSTOMER_NUMBER" EnvAPIKey = envNamespace + "API_KEY" EnvAPIPassword = envNamespace + "API_PASSWORD" // Deprecated: the TTL is not configurable on record. EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Key string Password string Customer string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client // Deprecated: the TTL is not configurable on record. TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *internal.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for netcup. // Credentials must be passed in the environment variables: // NETCUP_CUSTOMER_NUMBER, NETCUP_API_KEY, NETCUP_API_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvCustomerNumber, EnvAPIKey, EnvAPIPassword) if err != nil { return nil, fmt.Errorf("netcup: %w", err) } config := NewDefaultConfig() config.Customer = values[EnvCustomerNumber] config.Key = values[EnvAPIKey] config.Password = values[EnvAPIPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for netcup. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("netcup: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.Customer, config.Key, config.Password) if err != nil { return nil, fmt.Errorf("netcup: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("netcup: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateSessionContext(context.Background()) if err != nil { return fmt.Errorf("netcup: %w", err) } defer func() { err = d.client.Logout(ctx) if err != nil { log.Printf("netcup: %v", err) } }() hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1) record := internal.DNSRecord{ Hostname: hostname, RecordType: "TXT", Destination: info.Value, } zone = dns01.UnFqdn(zone) records, err := d.client.GetDNSRecords(ctx, zone) if err != nil { // skip no existing records log.Infof("no existing records, error ignored: %v", err) } records = append(records, record) err = d.client.UpdateDNSRecord(ctx, zone, records) if err != nil { return fmt.Errorf("netcup: failed to add TXT-Record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("netcup: could not find zone for domain %q: %w", domain, err) } ctx, err := d.client.CreateSessionContext(context.Background()) if err != nil { return fmt.Errorf("netcup: %w", err) } defer func() { err = d.client.Logout(ctx) if err != nil { log.Printf("netcup: %v", err) } }() hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1) zone = dns01.UnFqdn(zone) records, err := d.client.GetDNSRecords(ctx, zone) if err != nil { return fmt.Errorf("netcup: %w", err) } record := internal.DNSRecord{ Hostname: hostname, RecordType: "TXT", Destination: info.Value, } idx, err := internal.GetDNSRecordIdx(records, record) if err != nil { return fmt.Errorf("netcup: %w", err) } records[idx].DeleteRecord = true err = d.client.UpdateDNSRecord(ctx, zone, []internal.DNSRecord{records[idx]}) if err != nil { return fmt.Errorf("netcup: %w", err) } return 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 } ================================================ FILE: providers/dns/netcup/netcup.toml ================================================ Name = "Netcup" Description = '''''' URL = "https://www.netcup.eu/" Code = "netcup" Since = "v1.1.0" Example = ''' NETCUP_CUSTOMER_NUMBER=xxxx \ NETCUP_API_KEY=yyyy \ NETCUP_API_PASSWORD=zzzz \ lego --dns netcup -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NETCUP_CUSTOMER_NUMBER = "Customer number" NETCUP_API_KEY = "API key" NETCUP_API_PASSWORD = "API password" [Configuration.Additional] NETCUP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" NETCUP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)" NETCUP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.netcup-wiki.de/wiki/DNS_API" ================================================ FILE: providers/dns/netcup/netcup_test.go ================================================ package netcup import ( "fmt" "testing" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvCustomerNumber, EnvAPIKey, EnvAPIPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvCustomerNumber: "A", EnvAPIKey: "B", EnvAPIPassword: "C", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvCustomerNumber: "", EnvAPIKey: "", EnvAPIPassword: "", }, expected: "netcup: some credentials information are missing: NETCUP_CUSTOMER_NUMBER,NETCUP_API_KEY,NETCUP_API_PASSWORD", }, { desc: "missing customer number", envVars: map[string]string{ EnvCustomerNumber: "", EnvAPIKey: "B", EnvAPIPassword: "C", }, expected: "netcup: some credentials information are missing: NETCUP_CUSTOMER_NUMBER", }, { desc: "missing API key", envVars: map[string]string{ EnvCustomerNumber: "A", EnvAPIKey: "", EnvAPIPassword: "C", }, expected: "netcup: some credentials information are missing: NETCUP_API_KEY", }, { desc: "missing api password", envVars: map[string]string{ EnvCustomerNumber: "A", EnvAPIKey: "B", EnvAPIPassword: "", }, expected: "netcup: some credentials information are missing: NETCUP_API_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string customer string key string password string expected string }{ { desc: "success", customer: "A", key: "B", password: "C", }, { desc: "missing credentials", expected: "netcup: credentials missing", }, { desc: "missing customer", customer: "", key: "B", password: "C", expected: "netcup: credentials missing", }, { desc: "missing key", customer: "A", key: "", password: "C", expected: "netcup: credentials missing", }, { desc: "missing password", customer: "A", key: "B", password: "", expected: "netcup: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Customer = test.customer config.Key = test.key config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresentAndCleanup(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() p, err := NewDNSProvider() require.NoError(t, err) info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) require.NoError(t, err) zone = dns01.UnFqdn(zone) testCases := []string{ zone, "sub." + zone, "*." + zone, "*.sub." + zone, } for _, test := range testCases { t.Run(fmt.Sprintf("domain(%s)", test), func(t *testing.T) { err = p.Present(test, "987d", "123d==") require.NoError(t, err) err = p.CleanUp(test, "987d", "123d==") require.NoError(t, err) }) } } ================================================ FILE: providers/dns/netlify/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) const defaultBaseURL = "https://api.netlify.com/api/v1" // Client Netlify API client. type Client struct { baseURL *url.URL httpClient *http.Client } // NewClient creates a new Client. func NewClient(hc *http.Client) *Client { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 5 * time.Second} } return &Client{baseURL: baseURL, httpClient: hc} } // GetRecords gets a DNS records. func (c *Client) GetRecords(ctx context.Context, zoneID string) ([]DNSRecord, error) { endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "dns_records") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var records []DNSRecord err = json.Unmarshal(raw, &records) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return records, nil } // CreateRecord creates a DNS records. func (c *Client) CreateRecord(ctx context.Context, zoneID string, record DNSRecord) (*DNSRecord, error) { endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "dns_records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var recordResp DNSRecord err = json.Unmarshal(raw, &recordResp) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &recordResp, nil } // RemoveRecord removes a DNS records. func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "dns_records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json; charset=utf-8") } return req, nil } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/netlify/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(token string) func(server *httptest.Server) (*Client, error) { return func(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), token)) client.baseURL, _ = url.Parse(server.URL) return client, nil } } func TestClient_GetRecords(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient("tokenA"), servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer tokenA"), ). Route("GET /dns_zones/zoneID/dns_records", servermock.ResponseFromFixture("get_records.json")). Build(t) records, err := client.GetRecords(t.Context(), "zoneID") require.NoError(t, err) expected := []DNSRecord{ {ID: "u6b433c15a27a2d79c6616d6", Hostname: "example.org", TTL: 3600, Type: "A", Value: "10.10.10.10"}, {ID: "u6b4764216f272872ac0ff71", Hostname: "test.example.org", TTL: 300, Type: "TXT", Value: "txtxtxtxtxtxt"}, } assert.Equal(t, expected, records) } func TestClient_CreateRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient("tokenB"), servermock.CheckHeader(). WithAccept("application/json"). WithContentType("application/json; charset=utf-8"). WithAuthorization("Bearer tokenB"), ). Route("POST /dns_zones/zoneID/dns_records", servermock.ResponseFromFixture("create_record.json"). WithStatusCode(http.StatusCreated)). Build(t) record := DNSRecord{ Hostname: "_acme-challenge.example.com", TTL: 300, Type: "TXT", Value: "txtxtxtxtxtxt", } result, err := client.CreateRecord(t.Context(), "zoneID", record) require.NoError(t, err) expected := &DNSRecord{ ID: "u6b4764216f272872ac0ff71", Hostname: "test.example.org", TTL: 300, Type: "TXT", Value: "txtxtxtxtxtxt", } assert.Equal(t, expected, result) } func TestClient_RemoveRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient("tokenC"), servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer tokenC"), ). Route("DELETE /dns_zones/zoneID/dns_records/recordID", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.RemoveRecord(t.Context(), "zoneID", "recordID") require.NoError(t, err) } ================================================ FILE: providers/dns/netlify/internal/fixtures/create_record.json ================================================ { "hostname": "test.example.org", "type": "TXT", "ttl": 300, "priority": null, "weight": null, "port": null, "flag": null, "tag": null, "id": "u6b4764216f272872ac0ff71", "site_id": null, "dns_zone_id": "u6b4336178f002e0a06bb0b6", "errors": [], "managed": false, "value": "txtxtxtxtxtxt" } ================================================ FILE: providers/dns/netlify/internal/fixtures/get_records.json ================================================ [ { "hostname": "example.org", "type": "A", "ttl": 3600, "priority": null, "weight": null, "port": null, "flag": null, "tag": null, "id": "u6b433c15a27a2d79c6616d6", "site_id": null, "dns_zone_id": "u6b4336178f002e0a06bb0b6", "errors": [], "managed": false, "value": "10.10.10.10" }, { "hostname": "test.example.org", "type": "TXT", "ttl": 300, "priority": null, "weight": null, "port": null, "flag": null, "tag": null, "id": "u6b4764216f272872ac0ff71", "site_id": null, "dns_zone_id": "u6b4336178f002e0a06bb0b6", "errors": [], "managed": false, "value": "txtxtxtxtxtxt" } ] ================================================ FILE: providers/dns/netlify/internal/types.go ================================================ package internal // DNSRecord DNS record representation. type DNSRecord struct { ID string `json:"id,omitempty"` Hostname string `json:"hostname,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value,omitempty"` } ================================================ FILE: providers/dns/netlify/netlify.go ================================================ // Package netlify implements a DNS provider for solving the DNS-01 challenge using Netlify. package netlify import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/netlify/internal" ) // Environment variables names. const ( envNamespace = "NETLIFY_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Netlify. // Credentials must be passed in the environment variable: NETLIFY_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("netlify: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Netlify. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("netlify: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("netlify: incomplete credentials, missing token") } client := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.Token), ), ) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("netlify: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) record := internal.DNSRecord{ Hostname: dns01.UnFqdn(info.EffectiveFQDN), TTL: d.config.TTL, Type: "TXT", Value: info.Value, } resp, err := d.client.CreateRecord(context.Background(), strings.ReplaceAll(authZone, ".", "_"), record) if err != nil { return fmt.Errorf("netlify: failed to create TXT records: fqdn=%s, authZone=%s: %w", info.EffectiveFQDN, authZone, err) } d.recordIDsMu.Lock() d.recordIDs[token] = resp.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("netlify: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("netlify: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.RemoveRecord(context.Background(), strings.ReplaceAll(authZone, ".", "_"), recordID) if err != nil { return fmt.Errorf("netlify: failed to delete TXT records: fqdn=%s, authZone=%s, recordID=%s: %w", info.EffectiveFQDN, authZone, recordID, err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/netlify/netlify.toml ================================================ Name = "Netlify" Description = '''''' URL = "https://www.netlify.com" Code = "netlify" Since = "v3.7.0" Example = ''' NETLIFY_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns netlify -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NETLIFY_TOKEN = "Token" [Configuration.Additional] NETLIFY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NETLIFY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" NETLIFY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" NETLIFY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://open-api.netlify.com/" ================================================ FILE: providers/dns/netlify/netlify_test.go ================================================ package netlify import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvToken: "", }, expected: "netlify: some credentials information are missing: NETLIFY_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string token string }{ { desc: "success", token: "api_key", }, { desc: "missing credentials", expected: "netlify: incomplete credentials, missing token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/nicmanager/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/pquerna/otp/totp" ) const ( defaultBaseURL = "https://api.nicmanager.com/v1" headerTOTPToken = "X-Auth-Token" ) // Modes. const ( ModeAnycast = "anycast" ModeZone = "zones" ) // Options the Client options. type Options struct { Login string Username string Email string Password string OTP string Mode string } // Client a nicmanager DNS client. type Client struct { username string password string otp string mode string baseURL *url.URL HTTPClient *http.Client } // NewClient create a new Client. func NewClient(opts Options) *Client { c := &Client{ mode: ModeAnycast, username: opts.Email, password: opts.Password, otp: opts.OTP, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } c.baseURL, _ = url.Parse(defaultBaseURL) if opts.Mode != "" { c.mode = opts.Mode } if opts.Login != "" && opts.Username != "" { c.username = fmt.Sprintf("%s.%s", opts.Login, opts.Username) } return c } func (c *Client) GetZone(ctx context.Context, name string) (*Zone, error) { endpoint := c.baseURL.JoinPath(c.mode, name) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var zone Zone err = c.do(req, http.StatusOK, &zone) if err != nil { return nil, err } return &zone, nil } func (c *Client) AddRecord(ctx context.Context, zone string, payload RecordCreateUpdate) error { endpoint := c.baseURL.JoinPath(c.mode, zone, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return err } err = c.do(req, http.StatusAccepted, nil) if err != nil { return err } return nil } func (c *Client) DeleteRecord(ctx context.Context, zone string, record int) error { endpoint := c.baseURL.JoinPath(c.mode, zone, "records", strconv.Itoa(record)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } err = c.do(req, http.StatusAccepted, nil) if err != nil { return err } return nil } func (c *Client) do(req *http.Request, expectedStatusCode int, result any) error { req.SetBasicAuth(c.username, c.password) if c.otp != "" { tan, err := totp.GenerateCode(c.otp, time.Now()) if err != nil { return err } req.Header.Set(headerTOTPToken, tan) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != expectedStatusCode { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return err } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := APIError{StatusCode: resp.StatusCode} if err := json.Unmarshal(raw, &errAPI); err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errAPI } ================================================ FILE: providers/dns/nicmanager/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { opts := Options{ Login: "l", Username: "u", Password: "p", OTP: "2hsn", } client := NewClient(opts) client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("l.u", "p"). WithRegexp(headerTOTPToken, `\d{6}`)) } func TestClient_GetZone(t *testing.T) { client := mockBuilder(). Route("GET /anycast/nicmanager-anycastdns4.net", servermock.ResponseFromFixture("zone.json")). Build(t) zone, err := client.GetZone(t.Context(), "nicmanager-anycastdns4.net") require.NoError(t, err) expected := &Zone{ Name: "nicmanager-anycastdns4.net", Active: true, Records: []Record{ { ID: 186, Name: "nicmanager-anycastdns4.net", Type: "A", Content: "123.123.123.123", TTL: 3600, }, }, } assert.Equal(t, expected, zone) } func TestClient_GetZone_error(t *testing.T) { client := mockBuilder(). Route("GET /anycast/foo", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusNotFound)). Build(t) _, err := client.GetZone(t.Context(), "foo") require.EqualError(t, err, "404: Not Found") } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /anycast/zonedomain.tld/records", servermock.Noop(). WithStatusCode(http.StatusAccepted)). Build(t) record := RecordCreateUpdate{ Type: "TXT", Name: "lego", Value: "content", TTL: 3600, } err := client.AddRecord(t.Context(), "zonedomain.tld", record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /anycast/zonedomain.tld/records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) record := RecordCreateUpdate{ Type: "TXT", Name: "zonedomain.tld", Value: "content", TTL: 3600, } err := client.AddRecord(t.Context(), "zonedomain.tld", record) require.EqualError(t, err, "401: Not Found") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /anycast/zonedomain.tld/records/6", servermock.Noop(). WithStatusCode(http.StatusAccepted)). Build(t) err := client.DeleteRecord(t.Context(), "zonedomain.tld", 6) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /anycast/zonedomain.tld/records/6", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusNotFound)). Build(t) err := client.DeleteRecord(t.Context(), "zonedomain.tld", 6) require.EqualError(t, err, "404: Not Found") } ================================================ FILE: providers/dns/nicmanager/internal/fixtures/error.json ================================================ { "message": "Not Found" } ================================================ FILE: providers/dns/nicmanager/internal/fixtures/zone.json ================================================ { "order_id": 9053, "name": "nicmanager-anycastdns4.net", "order_status": "active", "event_status": "done", "active": true, "dnssec": "inactive", "master1": null, "master2": null, "soa": { "primary": "ns1.nic53.net", "mail": "hostmaster.nicmanager.de", "serial": 1481109046, "refresh": 14400, "retry": 1800, "expire": 1209600, "default": 3600, "ttl": 86400 }, "updated_datetime": "2016-09-02T13:52:18Z", "order_datetime": "2016-09-02T13:52:18Z", "records": [ { "id": 186, "name": "nicmanager-anycastdns4.net", "type": "A", "content": "123.123.123.123", "ttl": 3600, "priority": 0, "active": true, "updated_datetime": "2016-09-02T13:52:18Z" } ], "redirects": [ { "id": 10, "name": "test.nicmanager-anycastdns4.net", "target": "https:\/\/www.nicmanager.com\/", "type": "frame", "updated_datetime": "2016-12-05T14:40:47Z", "request_uri": true, "ssl": false, "meta": { "title": "My frame", "keywords": "foo,bar", "description": "Just a Test" }, "subdomain": "test" } ] } ================================================ FILE: providers/dns/nicmanager/internal/types.go ================================================ package internal import "fmt" type Record struct { ID int `json:"id"` Name string `json:"name"` Type string `json:"type"` Content string `json:"content"` TTL int `json:"ttl"` } type Zone struct { Name string `json:"name"` Active bool `json:"active"` Records []Record `json:"records"` } type RecordCreateUpdate struct { Name string `json:"name"` Value string `json:"value"` TTL int `json:"ttl"` Type string `json:"type"` } type APIError struct { Message string `json:"message"` StatusCode int `json:"-"` } func (a APIError) Error() string { return fmt.Sprintf("%d: %s", a.StatusCode, a.Message) } ================================================ FILE: providers/dns/nicmanager/nicmanager.go ================================================ // Package nicmanager implements a DNS provider for solving the DNS-01 challenge using nicmanager DNS. package nicmanager import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/nicmanager/internal" ) // Environment variables names. const ( envNamespace = "NICMANAGER_" EnvLogin = envNamespace + "API_LOGIN" EnvUsername = envNamespace + "API_USERNAME" EnvEmail = envNamespace + "API_EMAIL" EnvPassword = envNamespace + "API_PASSWORD" EnvOTP = envNamespace + "API_OTP" EnvMode = envNamespace + "API_MODE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 900 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Login string Username string Email string Password string OTPSecret string Mode string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *internal.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for nicmanager. // Credentials must be passed in the environment variables: // NICMANAGER_API_LOGIN, NICMANAGER_API_USERNAME // NICMANAGER_API_EMAIL // NICMANAGER_API_PASSWORD // NICMANAGER_API_OTP // NICMANAGER_API_MODE. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvPassword) if err != nil { return nil, fmt.Errorf("nicmanager: %w", err) } config := NewDefaultConfig() config.Password = values[EnvPassword] config.Mode = env.GetOneWithFallback(EnvMode, internal.ModeAnycast, env.ParseString, envNamespace+"MODE") config.Username = env.GetOrFile(EnvUsername) config.Login = env.GetOrFile(EnvLogin) config.Email = env.GetOrFile(EnvEmail) config.OTPSecret = env.GetOrFile(EnvOTP) if config.TTL < minTTL { return nil, fmt.Errorf("TTL must be higher than %d: %d", minTTL, config.TTL) } return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for nicmanager. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("nicmanager: the configuration of the DNS provider is nil") } opts := internal.Options{ Password: config.Password, OTP: config.OTPSecret, Mode: config.Mode, } switch { case config.Password == "": return nil, errors.New("nicmanager: credentials missing") case config.Email != "": opts.Email = config.Email case config.Login != "" && config.Username != "": opts.Login = config.Login opts.Username = config.Username default: return nil, errors.New("nicmanager: credentials missing") } client := internal.NewClient(opts) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.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 { info := dns01.GetChallengeInfo(domain, keyAuth) rootDomain, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("nicmanager: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() zone, err := d.client.GetZone(ctx, dns01.UnFqdn(rootDomain)) if err != nil { return fmt.Errorf("nicmanager: failed to get zone %q: %w", rootDomain, err) } // The way nic manager deals with record with multiple values is that they are completely different records with unique ids // Hence we don't check for an existing record here, but rather just create one record := internal.RecordCreateUpdate{ Name: info.EffectiveFQDN, Type: "TXT", TTL: d.config.TTL, Value: info.Value, } err = d.client.AddRecord(ctx, zone.Name, record) if err != nil { return fmt.Errorf("nicmanager: failed to create record [zone: %q, fqdn: %q]: %w", zone.Name, info.EffectiveFQDN, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) rootDomain, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("nicmanager: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() zone, err := d.client.GetZone(ctx, dns01.UnFqdn(rootDomain)) if err != nil { return fmt.Errorf("nicmanager: failed to get zone %q: %w", rootDomain, err) } name := dns01.UnFqdn(info.EffectiveFQDN) var ( existingRecord internal.Record existingRecordFound bool ) for _, record := range zone.Records { if strings.EqualFold(record.Type, "TXT") && strings.EqualFold(record.Name, name) && record.Content == info.Value { existingRecord = record existingRecordFound = true } } if existingRecordFound { err = d.client.DeleteRecord(ctx, zone.Name, existingRecord.ID) if err != nil { return fmt.Errorf("nicmanager: failed to delete record [zone: %q, domain: %q]: %w", zone.Name, name, err) } } return errors.New("nicmanager: no record found to clean up") } ================================================ FILE: providers/dns/nicmanager/nicmanager.toml ================================================ Name = "Nicmanager" Description = '''''' URL = "https://www.nicmanager.com/" Code = "nicmanager" Since = "v4.5.0" Example = ''' ## Login using email NICMANAGER_API_EMAIL = "you@example.com" \ NICMANAGER_API_PASSWORD = "password" \ # Optionally, if your account has TOTP enabled, set the secret here NICMANAGER_API_OTP = "long-secret" \ lego --dns nicmanager -d '*.example.com' -d example.com run ## Login using account name + username NICMANAGER_API_LOGIN = "myaccount" \ NICMANAGER_API_USERNAME = "myuser" \ NICMANAGER_API_PASSWORD = "password" \ # Optionally, if your account has TOTP enabled, set the secret here NICMANAGER_API_OTP = "long-secret" \ lego --dns nicmanager -d '*.example.com' -d example.com run ''' Additional = ''' ## Description You can log in using your account name + username or using your email address. Optionally, if TOTP is configured for your account, set `NICMANAGER_API_OTP`. ''' [Configuration] [Configuration.Credentials] NICMANAGER_API_LOGIN = "Login, used for Username-based login" NICMANAGER_API_USERNAME = "Username, used for Username-based login" NICMANAGER_API_EMAIL = "Email-based login" NICMANAGER_API_PASSWORD = "Password, always required" [Configuration.Additional] NICMANAGER_API_OTP = "TOTP Secret (optional)" NICMANAGER_API_MODE = "mode: 'anycast' or 'zones' (for FreeDNS) (default: 'anycast')" NICMANAGER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NICMANAGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" NICMANAGER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 900)" NICMANAGER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://api.nicmanager.com/docs/v1/" ================================================ FILE: providers/dns/nicmanager/nicmanager_test.go ================================================ package nicmanager import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvLogin, EnvEmail, EnvPassword, EnvOTP). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success (email)", envVars: map[string]string{ EnvEmail: "foo@example.com", EnvPassword: "secret", }, }, { desc: "success (login.username)", envVars: map[string]string{ EnvLogin: "foo", EnvUsername: "bar", EnvPassword: "secret", }, }, { desc: "missing credentials", expected: "nicmanager: some credentials information are missing: NICMANAGER_API_PASSWORD", }, { desc: "missing password", envVars: map[string]string{ EnvEmail: "foo@example.com", }, expected: "nicmanager: some credentials information are missing: NICMANAGER_API_PASSWORD", }, { desc: "missing username", envVars: map[string]string{ EnvLogin: "foo", EnvPassword: "secret", }, expected: "nicmanager: credentials missing", }, { desc: "missing login", envVars: map[string]string{ EnvUsername: "bar", EnvPassword: "secret", }, expected: "nicmanager: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string login string username string email string password string otpSecret string expected string }{ { desc: "success (email)", email: "foo@example.com", password: "secret", }, { desc: "success (login.username)", login: "john", username: "doe", password: "secret", }, { desc: "missing credentials", expected: "nicmanager: credentials missing", }, { desc: "missing password", email: "foo@example.com", expected: "nicmanager: credentials missing", }, { desc: "missing login", login: "", username: "doe", password: "secret", expected: "nicmanager: credentials missing", }, { desc: "missing username", login: "john", username: "", password: "secret", expected: "nicmanager: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Login = test.login config.Username = test.username config.Email = test.email config.Password = test.password config.OTPSecret = test.otpSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/nicru/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/xml" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const ( apiBaseURL = "https://api.nic.ru/dns-master" tokenURL = "https://api.nic.ru/oauth/token" ) const successStatus = "success" // Trimmer trim all XML fields. type Trimmer struct { decoder *xml.Decoder } func (tr Trimmer) Token() (xml.Token, error) { t, err := tr.decoder.Token() if cd, ok := t.(xml.CharData); ok { t = xml.CharData(bytes.TrimSpace(cd)) } return t, err } type Client struct { baseURL *url.URL httpClient *http.Client } func NewClient(httpClient *http.Client) (*Client, error) { if httpClient == nil { httpClient = &http.Client{Timeout: 5 * time.Second} } baseURL, _ := url.Parse(apiBaseURL) return &Client{ baseURL: baseURL, httpClient: httpClient, }, nil } func (c *Client) GetServices(ctx context.Context) ([]Service, error) { endpoint := c.baseURL.JoinPath("services") req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } apiResponse, err := c.do(req) if err != nil { return nil, err } if apiResponse.Data == nil { return nil, nil } return apiResponse.Data.Service, nil } func (c *Client) ListZones(ctx context.Context) ([]Zone, error) { endpoint := c.baseURL.JoinPath("zones") req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } apiResponse, err := c.do(req) if err != nil { return nil, err } if apiResponse.Data == nil { return nil, nil } return apiResponse.Data.Zone, nil } func (c *Client) GetZonesByService(ctx context.Context, serviceName string) ([]Zone, error) { endpoint := c.baseURL.JoinPath("services", serviceName, "zones") req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } apiResponse, err := c.do(req) if err != nil { return nil, err } if apiResponse.Data == nil { return nil, nil } return apiResponse.Data.Zone, nil } func (c *Client) GetRecords(ctx context.Context, serviceName, zoneName string) ([]RR, error) { endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records") req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } apiResponse, err := c.do(req) if err != nil { return nil, err } if apiResponse.Data == nil { return nil, nil } var records []RR for _, zone := range apiResponse.Data.Zone { records = append(records, zone.RR...) } return records, nil } func (c *Client) DeleteRecord(ctx context.Context, serviceName, zoneName, id string) error { endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records", id) req, err := newXMLRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } _, err = c.do(req) if err != nil { return err } return nil } func (c *Client) CommitZone(ctx context.Context, serviceName, zoneName string) error { endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "commit") req, err := newXMLRequest(ctx, http.MethodPost, endpoint, nil) if err != nil { return err } _, err = c.do(req) if err != nil { return err } return nil } func (c *Client) AddRecords(ctx context.Context, serviceName, zoneName string, rrs []RR) ([]Zone, error) { endpoint := c.baseURL.JoinPath("services", serviceName, "zones", zoneName, "records") payload := &Request{RRList: &RRList{RR: rrs}} req, err := newXMLRequest(ctx, http.MethodPut, endpoint, payload) if err != nil { return nil, err } apiResponse, err := c.do(req) if err != nil { return nil, err } if apiResponse.Data == nil { return nil, nil } return apiResponse.Data.Zone, nil } func (c *Client) do(req *http.Request) (*Response, error) { resp, err := c.httpClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() apiResponse := &Response{} raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } decoder := xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))}) err = decoder.Decode(apiResponse) if err != nil { return nil, fmt.Errorf("[status code=%d] decode XML response: %s", resp.StatusCode, string(raw)) } if apiResponse.Status != successStatus { return nil, fmt.Errorf("[status code=%d] %s: %w", resp.StatusCode, apiResponse.Status, apiResponse.Errors.Error) } return apiResponse, nil } func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { body := new(bytes.Buffer) if payload != nil { body.WriteString(xml.Header) encoder := xml.NewEncoder(body) encoder.Indent("", " ") err := encoder.Encode(payload) if err != nil { return nil, err } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "text/xml") if payload != nil { req.Header.Set("Content-Type", "text/xml") } return req, nil } ================================================ FILE: providers/dns/nicru/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.Client()) if err != nil { return nil, err } client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithAccept("text/xml"), ) } func TestClient_GetServices(t *testing.T) { client := mockBuilder(). Route("GET /services", servermock.ResponseFromFixture("services_GET.xml")). Build(t) zones, err := client.GetServices(t.Context()) require.NoError(t, err) expected := []Service{ { Admin: "123/NIC-REG", DomainsLimit: "12", DomainsNum: "5", Enable: "true", HasPrimary: "false", Name: "testservice", Payer: "123/NIC-REG", Tariff: "Secondary L", }, { Admin: "123/NIC-REG", DomainsLimit: "150", DomainsNum: "10", Enable: "true", HasPrimary: "true", Name: "myservice", Payer: "123/NIC-REG", Tariff: "DNS-master XXL", RRLimit: "7500", RRNum: "1000", }, } assert.Equal(t, expected, zones) } func TestClient_ListZones(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("zones_all_GET.xml")). Build(t) zones, err := client.ListZones(t.Context()) require.NoError(t, err) expected := []Zone{ { Admin: "123/NIC-REG", Enable: "true", HasChanges: "false", HasPrimary: "true", ID: "227645", IDNName: "тест.рф", Name: "xn—e1aybc.xn--p1ai", Payer: "123/NIC-REG", Service: "myservice", }, { Admin: "123/NIC-REG", Enable: "true", HasChanges: "false", HasPrimary: "true", ID: "227642", IDNName: "example.ru", Name: "example.ru", Payer: "123/NIC-REG", Service: "myservice", }, { Admin: "123/NIC-REG", Enable: "true", HasChanges: "false", HasPrimary: "true", ID: "227643", IDNName: "test.su", Name: "test.su", Payer: "123/NIC-REG", Service: "myservice", }, } assert.Equal(t, expected, zones) } func TestClient_ListZones_error(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("errors.xml")). Build(t) _, err := client.ListZones(t.Context()) require.ErrorIs(t, err, Error{ Text: "Access token expired or not found", Code: "4097", }) } func TestClient_GetZonesByService(t *testing.T) { client := mockBuilder(). Route("GET /services/test/zones", servermock.ResponseFromFixture("zones_GET.xml")). Build(t) zones, err := client.GetZonesByService(t.Context(), "test") require.NoError(t, err) expected := []Zone{ { Admin: "123/NIC-REG", Enable: "true", HasChanges: "false", HasPrimary: "true", ID: "227645", IDNName: "тест.рф", Name: "xn—e1aybc.xn--p1ai", Payer: "123/NIC-REG", Service: "myservice", }, { Admin: "123/NIC-REG", Enable: "true", HasChanges: "false", HasPrimary: "true", ID: "227642", IDNName: "example.ru", Name: "example.ru", Payer: "123/NIC-REG", Service: "myservice", }, { Admin: "123/NIC-REG", Enable: "true", HasChanges: "false", HasPrimary: "true", ID: "227643", IDNName: "test.su", Name: "test.su", Payer: "123/NIC-REG", Service: "myservice", }, } assert.Equal(t, expected, zones) } func TestClient_GetZonesByService_error(t *testing.T) { client := mockBuilder(). Route("GET /services/test/zones", servermock.ResponseFromFixture("errors.xml")). Build(t) _, err := client.GetZonesByService(t.Context(), "test") require.ErrorIs(t, err, Error{ Text: "Access token expired or not found", Code: "4097", }) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /services/test/zones/example.com./records", servermock.ResponseFromFixture("records_GET.xml")). Build(t) records, err := client.GetRecords(t.Context(), "test", "example.com.") require.NoError(t, err) expected := []RR{ { ID: "210074", Name: "@", IDNName: "@", TTL: "", Type: "SOA", SOA: &SOA{ MName: &MName{ Name: "ns3-l2.nic.ru.", IDNName: "ns3-l2.nic.ru.", }, RName: &RName{ Name: "dns.nic.ru.", IDNName: "dns.nic.ru.", }, Serial: "2011112002", Refresh: "1440", Retry: "3600", Expire: "2592000", Minimum: "600", }, }, { ID: "210075", Name: "@", IDNName: "@", Type: "NS", NS: &NS{ Name: "ns3-l2.nic.ru.", IDNName: "ns3- l2.nic.ru.", }, }, { ID: "210076", Name: "@", IDNName: "@", Type: "NS", NS: &NS{ Name: "ns4-l2.nic.ru.", IDNName: "ns4-l2.nic.ru.", }, }, { ID: "210077", Name: "@", IDNName: "@", Type: "NS", NS: &NS{ Name: "ns8-l2.nic.ru.", IDNName: "ns8- l2.nic.ru.", }, }, } assert.Equal(t, expected, records) } func TestClient_GetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /services/test/zones/example.com./records", servermock.ResponseFromFixture("errors.xml")). Build(t) _, err := client.GetRecords(t.Context(), "test", "example.com.") require.ErrorIs(t, err, Error{ Text: "Access token expired or not found", Code: "4097", }) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("PUT /services/test/zones/example.com./records", servermock.ResponseFromFixture("records_PUT.xml"), servermock.CheckHeader(). WithContentType("text/xml")). Build(t) rrs := []RR{ { Name: "@", Type: "NS", NS: &NS{Name: "ns4-l2.nic.ru."}, }, { Name: "@", Type: "NS", NS: &NS{Name: "ns8-l2.nic.ru."}, }, } response, err := client.AddRecords(t.Context(), "test", "example.com.", rrs) require.NoError(t, err) expected := []Zone{ { Admin: "123/NIC-REG", HasChanges: "true", ID: "228095", IDNName: "test.ru", Name: "test.ru", Service: "testservice", RR: []RR{ { ID: "210076", Name: "@", IDNName: "@", Type: "NS", NS: &NS{ Name: "ns4-l2.nic.ru.", IDNName: "ns4-l2.nic.ru.", }, }, { ID: "210077", Name: "@", IDNName: "@", Type: "NS", NS: &NS{ Name: "ns8-l2.nic.ru.", IDNName: "ns8-l2.nic.ru.", }, }, }, }, } assert.Equal(t, expected, response) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("PUT /services/test/zones/example.com./records", servermock.ResponseFromFixture("errors.xml"), servermock.CheckHeader(). WithContentType("text/xml")). Build(t) rrs := []RR{ { Name: "@", Type: "NS", NS: &NS{Name: "ns4-l2.nic.ru."}, }, { Name: "@", Type: "NS", NS: &NS{Name: "ns8-l2.nic.ru."}, }, } _, err := client.AddRecords(t.Context(), "test", "example.com.", rrs) require.ErrorIs(t, err, Error{ Text: "Access token expired or not found", Code: "4097", }) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /services/test/zones/example.com./records/123", servermock.ResponseFromFixture("record_DELETE.xml")). Build(t) err := client.DeleteRecord(t.Context(), "test", "example.com.", "123") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /services/test/zones/example.com./records/123", servermock.ResponseFromFixture("errors.xml")). Build(t) err := client.DeleteRecord(t.Context(), "test", "example.com.", "123") require.ErrorIs(t, err, Error{ Text: "Access token expired or not found", Code: "4097", }) } func TestClient_CommitZone(t *testing.T) { client := mockBuilder(). Route("POST /services/test/zones/example.com./commit", servermock.ResponseFromFixture("commit_POST.xml")). Build(t) err := client.CommitZone(t.Context(), "test", "example.com.") require.NoError(t, err) } func TestClient_CommitZone_error(t *testing.T) { client := mockBuilder(). Route("POST /services/test/zones/example.com./commit", servermock.ResponseFromFixture("errors.xml")). Build(t) err := client.CommitZone(t.Context(), "test", "example.com.") require.ErrorIs(t, err, Error{ Text: "Access token expired or not found", Code: "4097", }) } ================================================ FILE: providers/dns/nicru/internal/fixtures/commit_POST.xml ================================================ success ================================================ FILE: providers/dns/nicru/internal/fixtures/errors.xml ================================================ fail Access token expired or not found ================================================ FILE: providers/dns/nicru/internal/fixtures/record_DELETE.xml ================================================ success ================================================ FILE: providers/dns/nicru/internal/fixtures/records_GET.xml ================================================ success @ @ SOA ns3-l2.nic.ru. ns3-l2.nic.ru. dns.nic.ru. dns.nic.ru. 2011112002 1440 3600 2592000 600 @ @ NS ns3-l2.nic.ru. ns3- l2.nic.ru. @ @ NS ns4-l2.nic.ru. ns4-l2.nic.ru. @ @ NS ns8-l2.nic.ru. ns8- l2.nic.ru. ================================================ FILE: providers/dns/nicru/internal/fixtures/records_PUT.xml ================================================ success @@NSns4-l2.nic.ru.ns4-l2.nic.ru. @@NSns8-l2.nic.ru.ns8-l2.nic.ru. ================================================ FILE: providers/dns/nicru/internal/fixtures/services_GET.xml ================================================ success ================================================ FILE: providers/dns/nicru/internal/fixtures/zones_GET.xml ================================================ success ================================================ FILE: providers/dns/nicru/internal/fixtures/zones_all_GET.xml ================================================ success ================================================ FILE: providers/dns/nicru/internal/identity.go ================================================ package internal import ( "context" "errors" "fmt" "net/http" "golang.org/x/oauth2" ) // OauthConfiguration credentials. type OauthConfiguration struct { OAuth2ClientID string OAuth2SecretID string Username string Password string } func (config *OauthConfiguration) Validate() error { msg := " is missing in credentials information" if config.Username == "" { return errors.New("username" + msg) } if config.Password == "" { return errors.New("password" + msg) } if config.OAuth2ClientID == "" { return errors.New("serviceID" + msg) } if config.OAuth2SecretID == "" { return errors.New("secret" + msg) } return nil } func NewOauthClient(ctx context.Context, config *OauthConfiguration) (*http.Client, error) { err := config.Validate() if err != nil { return nil, err } oauth2Config := oauth2.Config{ ClientID: config.OAuth2ClientID, ClientSecret: config.OAuth2SecretID, Endpoint: oauth2.Endpoint{ TokenURL: tokenURL, AuthStyle: oauth2.AuthStyleInParams, }, Scopes: []string{".+:/dns-master/.+"}, } oauth2Token, err := oauth2Config.PasswordCredentialsToken(ctx, config.Username, config.Password) if err != nil { return nil, fmt.Errorf("failed to create oauth2 token: %w", err) } return oauth2Config.Client(ctx, oauth2Token), nil } ================================================ FILE: providers/dns/nicru/internal/types.go ================================================ package internal import ( "encoding/xml" "fmt" ) type Request struct { XMLName xml.Name `xml:"request"` Text string `xml:",chardata"` RRList *RRList `xml:"rr-list"` } type RRList struct { Text string `xml:",chardata"` RR []RR `xml:"rr"` } type RR struct { Text string `xml:",chardata"` ID string `xml:"id,attr,omitempty"` Name string `xml:"name"` IDNName string `xml:"idn-name"` TTL string `xml:"ttl"` Type string `xml:"type"` SOA *SOA `xml:"soa,omitempty"` A string `xml:"a,omitempty"` AAAA string `xml:"aaaa,omitempty"` CName *CName `xml:"cname,omitempty"` NS *NS `xml:"ns,omitempty"` MX *MX `xml:"mx,omitempty"` SRV *SRV `xml:"srv,omitempty"` PTR *PTR `xml:"ptr,omitempty"` TXT *TXT `xml:"txt,omitempty"` DName *DName `xml:"dname,omitempty"` HInfo *HInfo `xml:"hinfo,omitempty"` NAPTR *NAPTR `xml:"naptr,omitempty"` RP *RP `xml:"rp,omitempty"` } type SOA struct { Text string `xml:",chardata"` MName *MName `xml:"mname"` RName *RName `xml:"rname"` Serial string `xml:"serial"` Refresh string `xml:"refresh"` Retry string `xml:"retry"` Expire string `xml:"expire"` Minimum string `xml:"minimum"` } type MName struct { Text string `xml:",chardata"` Name string `xml:"name"` IDNName string `xml:"idn-name,omitempty"` } type RName struct { Text string `xml:",chardata"` Name string `xml:"name"` IDNName string `xml:"idn-name,omitempty"` } type NS struct { Text string `xml:",chardata"` Name string `xml:"name"` IDNName string `xml:"idn-name,omitempty"` } type MX struct { Text string `xml:",chardata"` Preference string `xml:"preference"` Exchange *Exchange `xml:"exchange"` } type Exchange struct { Name string `xml:"name"` } type SRV struct { Text string `xml:",chardata"` Priority string `xml:"priority"` Weight string `xml:"weight"` Port string `xml:"port"` Target *Target `xml:"target"` } type Target struct { Text string `xml:",chardata"` Name string `xml:"name"` } type PTR struct { Text string `xml:",chardata"` Name string `xml:"name"` } type HInfo struct { Text string `xml:",chardata"` Hardware string `xml:"hardware"` OS string `xml:"os"` } type NAPTR struct { Text string `xml:",chardata"` Order string `xml:"order"` Preference string `xml:"preference"` Flags string `xml:"flags"` Service string `xml:"service"` Regexp string `xml:"regexp"` Replacement *Replacement `xml:"replacement"` } type Replacement struct { Text string `xml:",chardata"` Name string `xml:"name"` } type RP struct { Text string `xml:",chardata"` MboxDName *MboxDName `xml:"mbox-dname"` TxtDName *TxtDName `xml:"txt-dname"` } type MboxDName struct { Text string `xml:",chardata"` Name string `xml:"name"` } type TxtDName struct { Text string `xml:",chardata"` Name string `xml:"name"` } type CName struct { Text string `xml:",chardata"` Name string `xml:"name"` IDNName string `xml:"idn-name,omitempty"` } type DName struct { Text string `xml:",chardata"` Name string `xml:"name"` } type TXT struct { Text string `xml:",chardata"` String string `xml:"string"` } type Response struct { XMLName xml.Name `xml:"response"` Text string `xml:",chardata"` Status string `xml:"status"` Data *Data `xml:"data"` Errors Errors `xml:"errors"` } type Data struct { Text string `xml:",chardata"` Service []Service `xml:"service"` Zone []Zone `xml:"zone"` Address []string `xml:"address"` Revision []Revision `xml:"revision"` } type Errors struct { Text string `xml:",chardata"` Error Error `xml:"error"` } type Error struct { Text string `xml:",chardata"` Code string `xml:"code,attr"` } func (e Error) Error() string { return fmt.Sprintf("%s (code %s)", e.Text, e.Code) } type Service struct { Text string `xml:",chardata"` Admin string `xml:"admin,attr"` DomainsLimit string `xml:"domains-limit,attr"` DomainsNum string `xml:"domains-num,attr"` Enable string `xml:"enable,attr"` HasPrimary string `xml:"has-primary,attr"` Name string `xml:"name,attr"` Payer string `xml:"payer,attr"` Tariff string `xml:"tariff,attr"` RRLimit string `xml:"rr-limit,attr"` RRNum string `xml:"rr-num,attr"` } type Zone struct { Text string `xml:",chardata"` Admin string `xml:"admin,attr"` Enable string `xml:"enable,attr"` HasChanges string `xml:"has-changes,attr"` HasPrimary string `xml:"has-primary,attr"` ID string `xml:"id,attr"` IDNName string `xml:"idn-name,attr"` Name string `xml:"name,attr"` Payer string `xml:"payer,attr"` Service string `xml:"service,attr"` RR []RR `xml:"rr"` } type Revision struct { Text string `xml:",chardata"` Date string `xml:"date,attr"` IP string `xml:"ip,attr"` Number string `xml:"number,attr"` } ================================================ FILE: providers/dns/nicru/nicru.go ================================================ // Package nicru implements a DNS provider for solving the DNS-01 challenge using RU Center. package nicru import ( "context" "errors" "fmt" "strconv" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/nicru/internal" ) // Environment variables names. const ( envNamespace = "NICRU_" EnvUsername = envNamespace + "USER" EnvPassword = envNamespace + "PASSWORD" EnvServiceID = envNamespace + "SERVICE_ID" EnvSecret = envNamespace + "SECRET" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { TTL int Username string Password string ServiceID string Secret string PropagationTimeout time.Duration PollingInterval time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 30), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 1*time.Minute), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *internal.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for RU Center. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword, EnvServiceID, EnvSecret) if err != nil { return nil, fmt.Errorf("nicru: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.ServiceID = values[EnvServiceID] config.Secret = values[EnvSecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for RU Center. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("nicru: the configuration of the DNS provider is nil") } clientCfg := &internal.OauthConfiguration{ OAuth2ClientID: config.ServiceID, OAuth2SecretID: config.Secret, Username: config.Username, Password: config.Password, } oauthClient, err := internal.NewOauthClient(context.Background(), clientCfg) if err != nil { return nil, fmt.Errorf("nicru: %w", err) } client, err := internal.NewClient(clientdebug.Wrap(oauthClient)) if err != nil { return nil, fmt.Errorf("nicru: unable to build API client: %w", err) } return &DNSProvider{ client: client, config: config, }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("nicru: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) zone, err := d.findZone(ctx, authZone) if err != nil { return fmt.Errorf("nicru: find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("nicru: %w", err) } records, err := d.client.GetRecords(ctx, zone.Service, authZone) if err != nil { return fmt.Errorf("nicru: get records: %w", err) } for _, record := range records { if record.TXT == nil { continue } if record.TXT.Text == subDomain && record.TXT.String == info.Value { return nil } } rrs := []internal.RR{{ Name: subDomain, TTL: strconv.Itoa(d.config.TTL), Type: "TXT", TXT: &internal.TXT{String: info.Value}, }} _, err = d.client.AddRecords(ctx, zone.Service, authZone, rrs) if err != nil { return fmt.Errorf("nicru: add records: %w", err) } err = d.client.CommitZone(ctx, zone.Service, authZone) if err != nil { return fmt.Errorf("nicru: commit zone: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("nicru: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) zone, err := d.findZone(ctx, authZone) if err != nil { return fmt.Errorf("nicru: find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("nicru: %w", err) } records, err := d.client.GetRecords(ctx, zone.Service, authZone) if err != nil { return fmt.Errorf("nicru: get records: %w", err) } subDomain = dns01.UnFqdn(subDomain) for _, record := range records { if record.TXT == nil { continue } if record.Name != subDomain || record.TXT.String != info.Value { continue } err = d.client.DeleteRecord(ctx, zone.Service, authZone, record.ID) if err != nil { return fmt.Errorf("nicru: delete record: %w", err) } } err = d.client.CommitZone(ctx, zone.Service, authZone) if err != nil { return fmt.Errorf("nicru: commit zone: %w", err) } return 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 } func (d *DNSProvider) findZone(ctx context.Context, authZone string) (*internal.Zone, error) { zones, err := d.client.ListZones(ctx) if err != nil { return nil, fmt.Errorf("unable to fetch dns zones: %w", err) } if len(zones) == 0 { return nil, errors.New("no zones found") } for _, zone := range zones { if zone.Name == authZone { return &zone, nil } } return nil, fmt.Errorf("zone not found for %s", authZone) } ================================================ FILE: providers/dns/nicru/nicru.toml ================================================ Name = "RU CENTER" Description = '''''' URL = "https://nic.ru/" Code = "nicru" Since = "v4.24.0" Example = ''' NICRU_USER="" \ NICRU_PASSWORD="" \ NICRU_SERVICE_ID="" \ NICRU_SECRET="" \ lego --dns nicru -d '*.example.com' -d example.com run ''' Additional = ''' ## Credential information You can find information about service ID and secret https://www.nic.ru/manager/oauth.cgi?step=oauth.app_list | ENV Variable | Parameter from page | Example | |---------------------|--------------------------------|-------------------| | NICRU_USER | Username (Number of agreement) | NNNNNNN/NIC-D | | NICRU_PASSWORD | Password account | | | NICRU_SERVICE_ID | Application ID | hex-based, len 32 | | NICRU_SECRET | Identity endpoint | string len 91 | ''' [Configuration] [Configuration.Credentials] NICRU_USER = "Agreement for an account in RU CENTER" NICRU_PASSWORD = "Password for an account in RU CENTER" NICRU_SERVICE_ID = "Service ID for application in DNS-hosting RU CENTER" NICRU_SECRET = "Secret for application in DNS-hosting RU CENTER" NICRU_SERVICE_NAME = "Service Name for DNS-hosting RU CENTER" [Configuration.Additional] NICRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" NICRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" NICRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" [Links] API = "https://www.nic.ru/help/api-dns-hostinga_3643.html" ================================================ FILE: providers/dns/nicru/nicru_test.go ================================================ package nicru import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const ( fakeServiceID = "2519234972459cdfa23423adf143324f" fakeSecret = "oo5ahrie0aiPho3Vee4siupoPhahdahCh1thiesohru" fakeUsername = "1234567/NIC-D" fakePassword = "einge8Goo2eBaiXievuj" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvServiceID, EnvSecret).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvServiceID: fakeServiceID, EnvSecret: fakeSecret, EnvUsername: fakeUsername, EnvPassword: fakePassword, }, expected: "nicru: failed to create oauth2 token: oauth2: \"unauthorized_client\"", }, { desc: "missing serviceID", envVars: map[string]string{ EnvSecret: fakeSecret, EnvUsername: fakeUsername, EnvPassword: fakePassword, }, expected: "nicru: some credentials information are missing: NICRU_SERVICE_ID", }, { desc: "missing secret", envVars: map[string]string{ EnvServiceID: fakeServiceID, EnvUsername: fakeUsername, EnvPassword: fakePassword, }, expected: "nicru: some credentials information are missing: NICRU_SECRET", }, { desc: "missing username", envVars: map[string]string{ EnvServiceID: fakeServiceID, EnvSecret: fakeSecret, EnvPassword: fakePassword, }, expected: "nicru: some credentials information are missing: NICRU_USER", }, { desc: "missing password", envVars: map[string]string{ EnvServiceID: fakeServiceID, EnvSecret: fakeSecret, EnvUsername: fakeUsername, }, expected: "nicru: some credentials information are missing: NICRU_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string config *Config expected string }{ { desc: "success", config: &Config{ ServiceID: fakeServiceID, Secret: fakeSecret, Username: fakeUsername, Password: fakePassword, }, expected: "nicru: failed to create oauth2 token: oauth2: \"unauthorized_client\"", }, { desc: "nil config", config: nil, expected: "nicru: the configuration of the DNS provider is nil", }, { desc: "missing username", config: &Config{ ServiceID: fakeServiceID, Password: fakePassword, }, expected: "nicru: username is missing in credentials information", }, { desc: "missing password", config: &Config{ ServiceID: fakeServiceID, Secret: fakeSecret, Username: fakeUsername, }, expected: "nicru: password is missing in credentials information", }, { desc: "missing secret", config: &Config{ ServiceID: fakeServiceID, Username: fakeUsername, Password: fakePassword, }, expected: "nicru: secret is missing in credentials information", }, { desc: "missing serviceID", config: &Config{ Secret: fakeSecret, Username: fakeUsername, Password: fakePassword, }, expected: "nicru: serviceID is missing in credentials information", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p, err := NewDNSProviderConfig(test.config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/nifcloud/internal/client.go ================================================ package internal import ( "bytes" "context" "crypto/hmac" "crypto/sha1" "encoding/base64" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const ( defaultBaseURL = "https://dns.api.nifcloud.com" apiVersion = "2012-12-12N2013-12-16" // XMLNs XML NS of Route53. XMLNs = "https://route53.amazonaws.com/doc/2012-12-12/" ) // Client the API client for NIFCLOUD DNS. type Client struct { accessKey string secretKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new client of NIFCLOUD DNS. func NewClient(accessKey, secretKey string) (*Client, error) { if accessKey == "" || secretKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ accessKey: accessKey, secretKey: secretKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // ChangeResourceRecordSets Call ChangeResourceRecordSets API and return response. func (c *Client) ChangeResourceRecordSets(ctx context.Context, hostedZoneID string, input ChangeResourceRecordSetsRequest) (*ChangeResourceRecordSetsResponse, error) { endpoint := c.BaseURL.JoinPath(apiVersion, "hostedzone", hostedZoneID, "rrset") req, err := newXMLRequest(ctx, http.MethodPost, endpoint, input) if err != nil { return nil, err } output := &ChangeResourceRecordSetsResponse{} err = c.do(req, output) if err != nil { return nil, err } return output, nil } // GetChange Call GetChange API and return response. func (c *Client) GetChange(ctx context.Context, statusID string) (*GetChangeResponse, error) { endpoint := c.BaseURL.JoinPath(apiVersion, "change", statusID) req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } output := &GetChangeResponse{} err = c.do(req, output) if err != nil { return nil, err } return output, nil } func (c *Client) do(req *http.Request, result any) error { err := c.sign(req) if err != nil { return fmt.Errorf("an error occurred during the creation of the signature: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = xml.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) sign(req *http.Request) error { if req.Header.Get("Date") == "" { req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) } if req.URL.Path == "" { req.URL.Path += "/" } mac := hmac.New(sha1.New, []byte(c.secretKey)) _, err := mac.Write([]byte(req.Header.Get("Date"))) if err != nil { return err } hashed := mac.Sum(nil) signature := base64.StdEncoding.EncodeToString(hashed) auth := fmt.Sprintf("NIFTY3-HTTPS NiftyAccessKeyId=%s,Algorithm=HmacSHA1,Signature=%s", c.accessKey, signature) req.Header.Set("X-Nifty-Authorization", auth) return nil } func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { body := new(bytes.Buffer) if payload != nil { body.WriteString(xml.Header) err := xml.NewEncoder(body).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request XML body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } if payload != nil { req.Header.Set("Content-Type", "text/xml; charset=utf-8") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errResp := &ErrorResponse{} err := xml.Unmarshal(raw, errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errResp.Error } ================================================ FILE: providers/dns/nifcloud/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("A", "B") if err != nil { return nil, err } client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithRegexp("X-Nifty-Authorization", "NIFTY3-HTTPS NiftyAccessKeyId=A,Algorithm=HmacSHA1,Signature=.+"), ) } func TestClient_ChangeResourceRecordSets(t *testing.T) { responseBody := ` xxxxx INSYNC 2015-08-05T00:00:00.000Z ` client := mockBuilder(). Route("POST /", servermock.RawStringResponse(responseBody), servermock.CheckHeader().WithContentType("text/xml; charset=utf-8")). Build(t) res, err := client.ChangeResourceRecordSets(t.Context(), "example.com", ChangeResourceRecordSetsRequest{}) require.NoError(t, err) assert.Equal(t, "xxxxx", res.ChangeInfo.ID) assert.Equal(t, "INSYNC", res.ChangeInfo.Status) assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt) } func TestClient_ChangeResourceRecordSets_errors(t *testing.T) { testCases := []struct { desc string responseBody string statusCode int expected string }{ { desc: "API error", responseBody: ` Sender AuthFailed The request signature we calculated does not match the signature you provided. `, statusCode: http.StatusUnauthorized, expected: "Sender(AuthFailed): The request signature we calculated does not match the signature you provided.", }, { desc: "response body error", responseBody: "foo", statusCode: http.StatusOK, expected: "unable to unmarshal response: [status code: 200] body: foo error: EOF", }, { desc: "error message error", responseBody: "foo", statusCode: http.StatusInternalServerError, expected: "unexpected status code: [status code: 500] body: foo", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.RawStringResponse(test.responseBody). WithStatusCode(test.statusCode), servermock.CheckHeader(). WithContentType("text/xml; charset=utf-8")). Build(t) res, err := client.ChangeResourceRecordSets(t.Context(), "example.com", ChangeResourceRecordSetsRequest{}) assert.Nil(t, res) assert.EqualError(t, err, test.expected) }) } } func TestClient_GetChange(t *testing.T) { responseBody := ` xxxxx INSYNC 2015-08-05T00:00:00.000Z ` client := mockBuilder(). Route("GET /", servermock.RawStringResponse(responseBody)). Build(t) res, err := client.GetChange(t.Context(), "12345") require.NoError(t, err) assert.Equal(t, "xxxxx", res.ChangeInfo.ID) assert.Equal(t, "INSYNC", res.ChangeInfo.Status) assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt) } func TestClient_GetChange_errors(t *testing.T) { testCases := []struct { desc string responseBody string statusCode int expected string }{ { desc: "API error", responseBody: ` Sender AuthFailed The request signature we calculated does not match the signature you provided. `, statusCode: http.StatusUnauthorized, expected: "Sender(AuthFailed): The request signature we calculated does not match the signature you provided.", }, { desc: "response body error", responseBody: "foo", statusCode: http.StatusOK, expected: "unable to unmarshal response: [status code: 200] body: foo error: EOF", }, { desc: "error message error", responseBody: "foo", statusCode: http.StatusInternalServerError, expected: "unexpected status code: [status code: 500] body: foo", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.RawStringResponse(test.responseBody).WithStatusCode(test.statusCode)). Build(t) res, err := client.GetChange(t.Context(), "12345") assert.Nil(t, res) assert.EqualError(t, err, test.expected) }) } } ================================================ FILE: providers/dns/nifcloud/internal/types.go ================================================ package internal import "fmt" // ChangeResourceRecordSetsRequest is a complex type that contains change information for the resource record set. type ChangeResourceRecordSetsRequest struct { XMLNs string `xml:"xmlns,attr"` ChangeBatch ChangeBatch `xml:"ChangeBatch"` } // ChangeResourceRecordSetsResponse is a complex type containing the response for the request. type ChangeResourceRecordSetsResponse struct { ChangeInfo ChangeInfo `xml:"ChangeInfo"` } // GetChangeResponse is a complex type that contains the ChangeInfo element. type GetChangeResponse struct { ChangeInfo ChangeInfo `xml:"ChangeInfo"` } type Error struct { Type string `xml:"Type"` Message string `xml:"Message"` Code string `xml:"Code"` } func (e Error) Error() string { return fmt.Sprintf("%s(%s): %s", e.Type, e.Code, e.Message) } // ErrorResponse is the information for any errors. type ErrorResponse struct { Error Error `xml:"Error"` RequestID string `xml:"RequestId"` } // ChangeBatch is the information for a change request. type ChangeBatch struct { Changes Changes `xml:"Changes"` Comment string `xml:"Comment"` } // Changes is array of Change. type Changes struct { Change []Change `xml:"Change"` } // Change is the information for each resource record set that you want to change. type Change struct { Action string `xml:"Action"` ResourceRecordSet ResourceRecordSet `xml:"ResourceRecordSet"` } // ResourceRecordSet is the information about the resource record set to create or delete. type ResourceRecordSet struct { Name string `xml:"Name"` Type string `xml:"Type"` TTL int `xml:"TTL"` ResourceRecords ResourceRecords `xml:"ResourceRecords"` } // ResourceRecords is array of ResourceRecord. type ResourceRecords struct { ResourceRecord []ResourceRecord `xml:"ResourceRecord"` } // ResourceRecord is the information specific to the resource record. type ResourceRecord struct { Value string `xml:"Value"` } // ChangeInfo is A complex type that describes change information about changes made to your hosted zone. type ChangeInfo struct { ID string `xml:"Id"` Status string `xml:"Status"` SubmittedAt string `xml:"SubmittedAt"` } ================================================ FILE: providers/dns/nifcloud/nifcloud.go ================================================ // Package nifcloud implements a DNS provider for solving the DNS-01 challenge using NIFCLOUD DNS. package nifcloud import ( "context" "errors" "fmt" "net/http" "net/url" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/nifcloud/internal" ) // Environment variables names. const ( envNamespace = "NIFCLOUD_" EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY" EnvDNSEndpoint = envNamespace + "DNS_ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string AccessKey string SecretKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 the NIFCLOUD DNS service. // Credentials must be passed in the environment variables: // NIFCLOUD_ACCESS_KEY_ID and NIFCLOUD_SECRET_ACCESS_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey) if err != nil { return nil, fmt.Errorf("nifcloud: %w", err) } config := NewDefaultConfig() config.BaseURL = env.GetOrFile(EnvDNSEndpoint) config.AccessKey = values[EnvAccessKeyID] config.SecretKey = values[EnvSecretAccessKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for NIFCLOUD. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("nifcloud: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.AccessKey, config.SecretKey) if err != nil { return nil, fmt.Errorf("nifcloud: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) if config.BaseURL != "" { baseURL, err := url.Parse(config.BaseURL) if err != nil { return nil, fmt.Errorf("nifcloud: %w", err) } client.BaseURL = baseURL } return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) err := d.changeRecord(ctx, "CREATE", info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("nifcloud: %w", err) } return err } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) err := d.changeRecord(ctx, "DELETE", info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("nifcloud: %w", err) } return err } // 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 } func (d *DNSProvider) changeRecord(ctx context.Context, action, fqdn, value string, ttl int) error { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return fmt.Errorf("could not find zone: %w", err) } name := dns01.UnFqdn(fqdn) if authZone == fqdn { name = "@" } reqParams := internal.ChangeResourceRecordSetsRequest{ XMLNs: internal.XMLNs, ChangeBatch: internal.ChangeBatch{ Comment: "Managed by Lego", Changes: internal.Changes{ Change: []internal.Change{ { Action: action, ResourceRecordSet: internal.ResourceRecordSet{ Name: name, Type: "TXT", TTL: ttl, ResourceRecords: internal.ResourceRecords{ ResourceRecord: []internal.ResourceRecord{ { Value: value, }, }, }, }, }, }, }, }, } resp, err := d.client.ChangeResourceRecordSets(ctx, dns01.UnFqdn(authZone), reqParams) if err != nil { return fmt.Errorf("failed to change record set: %w", err) } statusID := resp.ChangeInfo.ID return wait.Retry(ctx, func() error { resp, err := d.client.GetChange(ctx, statusID) if err != nil { return fmt.Errorf("get change: %w", err) } if resp.ChangeInfo.Status != "INSYNC" { return fmt.Errorf("change status: %s", resp.ChangeInfo.Status) } return nil }, backoff.WithBackOff(backoff.NewConstantBackOff(4*time.Second)), backoff.WithMaxElapsedTime(120*time.Second), ) } ================================================ FILE: providers/dns/nifcloud/nifcloud.toml ================================================ Name = "NIFCloud" Description = '''''' URL = "https://www.nifcloud.com/" Code = "nifcloud" Since = "v1.1.0" Example = ''' NIFCLOUD_ACCESS_KEY_ID=xxxx \ NIFCLOUD_SECRET_ACCESS_KEY=yyyy \ lego --dns nifcloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NIFCLOUD_ACCESS_KEY_ID = "Access key" NIFCLOUD_SECRET_ACCESS_KEY = "Secret access key" [Configuration.Additional] NIFCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NIFCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" NIFCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" NIFCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://mbaas.nifcloud.com/doc/current/rest/common/format.html" ================================================ FILE: providers/dns/nifcloud/nifcloud_test.go ================================================ package nifcloud import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAccessKeyID, EnvSecretAccessKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessKeyID: "123", EnvSecretAccessKey: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAccessKeyID: "", EnvSecretAccessKey: "", }, expected: "nifcloud: some credentials information are missing: NIFCLOUD_ACCESS_KEY_ID,NIFCLOUD_SECRET_ACCESS_KEY", }, { desc: "missing access key", envVars: map[string]string{ EnvAccessKeyID: "", EnvSecretAccessKey: "456", }, expected: "nifcloud: some credentials information are missing: NIFCLOUD_ACCESS_KEY_ID", }, { desc: "missing secret key", envVars: map[string]string{ EnvAccessKeyID: "123", EnvSecretAccessKey: "", }, expected: "nifcloud: some credentials information are missing: NIFCLOUD_SECRET_ACCESS_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accessKey string secretKey string expected string }{ { desc: "success", accessKey: "123", secretKey: "456", }, { desc: "missing credentials", expected: "nifcloud: credentials missing", }, { desc: "missing api key", secretKey: "456", expected: "nifcloud: credentials missing", }, { desc: "missing secret key", accessKey: "123", expected: "nifcloud: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessKey = test.accessKey config.SecretKey = test.secretKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/njalla/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const apiEndpoint = "https://njal.la/api/1/" const authorizationHeader = "Authorization" // Client is a Njalla API client. type Client struct { token string apiEndpoint string HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(token string) *Client { return &Client{ token: token, apiEndpoint: apiEndpoint, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // AddRecord adds a record. func (c *Client) AddRecord(ctx context.Context, record Record) (*Record, error) { data := APIRequest{ Method: "add-record", Params: record, } req, err := newJSONRequest(ctx, http.MethodPost, c.apiEndpoint, data) if err != nil { return nil, err } var result APIResponse[*Record] err = c.do(req, &result) if err != nil { return nil, err } return result.Result, nil } // RemoveRecord removes a record. func (c *Client) RemoveRecord(ctx context.Context, id, domain string) error { data := APIRequest{ Method: "remove-record", Params: Record{ ID: id, Domain: domain, }, } req, err := newJSONRequest(ctx, http.MethodPost, c.apiEndpoint, data) if err != nil { return err } err = c.do(req, &APIResponse[json.RawMessage]{}) if err != nil { return err } return nil } // ListRecords list the records for one domain. func (c *Client) ListRecords(ctx context.Context, domain string) ([]Record, error) { data := APIRequest{ Method: "list-records", Params: Record{ Domain: domain, }, } req, err := newJSONRequest(ctx, http.MethodPost, c.apiEndpoint, data) if err != nil { return nil, err } var result APIResponse[Records] err = c.do(req, &result) if err != nil { return nil, err } return result.Result.Records, nil } func (c *Client) do(req *http.Request, result Response) error { req.Header.Set(authorizationHeader, "Njalla "+c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return result.GetError() } func newJSONRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint, buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/njalla/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.apiEndpoint = server.URL client.HTTPClient = server.Client() return client, nil } func TestClient_AddRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Njalla secret"), ). Route("POST /", servermock.ResponseFromFixture("add_record.json"), servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")). Build(t) record := Record{ Content: "foobar", Domain: "test", Name: "example.com", TTL: 300, Type: "TXT", } result, err := client.AddRecord(t.Context(), record) require.NoError(t, err) expected := &Record{ ID: "123", Content: "foobar", Domain: "test", Name: "example.com", TTL: 300, Type: "TXT", } assert.Equal(t, expected, result) } func TestClient_AddRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Njalla invalid"), ). Route("POST /", servermock.ResponseFromFixture("auth_error.json")). Build(t) client.token = "invalid" record := Record{ Content: "test", Domain: "test01", Name: "example.com", TTL: 300, Type: "TXT", } result, err := client.AddRecord(t.Context(), record) require.EqualError(t, err, "code: 403, message: Invalid token.") assert.Nil(t, result) } func TestClient_ListRecords(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Njalla secret"), ). Route("POST /", servermock.ResponseFromFixture("list_records.json"), servermock.CheckRequestJSONBodyFromFixture("list_records-request.json")). Build(t) records, err := client.ListRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ { ID: "1", Domain: "example.com", Content: "test", Name: "test01", TTL: 300, Type: "TXT", }, { ID: "2", Domain: "example.com", Content: "txtTxt", Name: "test02", TTL: 120, Type: "TXT", }, } assert.Equal(t, expected, records) } func TestClient_ListRecords_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Njalla invalid"), ). Route("POST /", servermock.ResponseFromFixture("auth_error.json")). Build(t) client.token = "invalid" records, err := client.ListRecords(t.Context(), "example.com") require.EqualError(t, err, "code: 403, message: Invalid token.") assert.Empty(t, records) } func TestClient_RemoveRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Njalla secret"), ). Route("POST /", servermock.RawStringResponse(`{"jsonrpc":"2.0"}`), servermock.CheckRequestJSONBodyFromFixture("remove_record-request.json")). Build(t) err := client.RemoveRecord(t.Context(), "123", "example.com") require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Njalla secret"), ). Route("POST /", servermock.ResponseFromFixture("remove_record_error_missing_domain.json")). Build(t) err := client.RemoveRecord(t.Context(), "123", "example.com") require.EqualError(t, err, "code: 400, message: missing domain") } ================================================ FILE: providers/dns/njalla/internal/fixtures/add_record-request.json ================================================ { "method": "add-record", "params": { "content": "foobar", "domain": "test", "name": "example.com", "ttl": 300, "type": "TXT" } } ================================================ FILE: providers/dns/njalla/internal/fixtures/add_record.json ================================================ { "id": "897", "jsonrpc": "2.0", "result": { "id": "123", "content": "foobar", "domain": "test", "name": "example.com", "ttl": 300, "type": "TXT" } } ================================================ FILE: providers/dns/njalla/internal/fixtures/auth_error.json ================================================ { "jsonrpc": "2.0", "Error": { "code": 403, "message": "Invalid token." } } ================================================ FILE: providers/dns/njalla/internal/fixtures/list_records-request.json ================================================ { "method": "list-records", "params": { "domain": "example.com" } } ================================================ FILE: providers/dns/njalla/internal/fixtures/list_records.json ================================================ { "id": "897", "jsonrpc": "2.0", "result": { "records": [ { "id": "1", "content": "test", "domain": "example.com", "name": "test01", "ttl": 300, "type": "TXT" }, { "id": "2", "content": "txtTxt", "domain": "example.com", "name": "test02", "ttl": 120, "type": "TXT" } ] } } ================================================ FILE: providers/dns/njalla/internal/fixtures/remove_record-request.json ================================================ { "method": "remove-record", "params": { "id": "123", "domain": "example.com" } } ================================================ FILE: providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json ================================================ { "jsonrpc": "2.0", "Error": { "code": 400, "message": "missing domain" } } ================================================ FILE: providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json ================================================ { "jsonrpc": "2.0", "Error": { "code": 400, "message": "missing ID" } } ================================================ FILE: providers/dns/njalla/internal/types.go ================================================ package internal import ( "fmt" ) // APIRequest represents an API request body. type APIRequest struct { Method string `json:"method"` Params any `json:"params"` } type Response interface { GetError() error } // APIResponse represents an API response body. type APIResponse[T any] struct { ID string `json:"id"` RPC string `json:"jsonrpc"` Error *APIError `json:"error,omitempty"` Result T `json:"result,omitempty"` } func (a APIResponse[T]) GetError() error { if a.Error == (*APIError)(nil) { return nil } return a.Error } // APIError is an API error. type APIError struct { Code int Message string } func (a APIError) Error() string { return fmt.Sprintf("code: %d, message: %s", a.Code, a.Message) } // Record is a DNS record. type Record struct { ID string `json:"id,omitempty"` Content string `json:"content,omitempty"` Domain string `json:"domain,omitempty"` Name string `json:"name,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` } // Records is a list of DNS records. type Records struct { Records []Record `json:"records,omitempty"` } ================================================ FILE: providers/dns/njalla/njalla.go ================================================ // Package njalla implements a DNS provider for solving the DNS-01 challenge using Njalla. package njalla import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/njalla/internal" "github.com/miekg/dns" ) // Environment variables names. const ( envNamespace = "NJALLA_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Njalla. // Credentials must be passed in the environment variable: NJALLA_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("njalla: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Njalla. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("njalla: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("njalla: missing credentials") } client := internal.NewClient(config.Token) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) rootDomain, subDomain, err := splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("njalla: %w", err) } record := internal.Record{ Name: subDomain, // TODO need to be tested Domain: dns01.UnFqdn(rootDomain), // TODO need to be tested Content: info.Value, TTL: d.config.TTL, Type: "TXT", } resp, err := d.client.AddRecord(context.Background(), record) if err != nil { return fmt.Errorf("njalla: failed to add record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = resp.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) rootDomain, _, err := splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("njalla: %w", err) } // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("njalla: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.RemoveRecord(context.Background(), recordID, dns01.UnFqdn(rootDomain)) if err != nil { return fmt.Errorf("njalla: failed to delete TXT records: fqdn=%s, recordID=%s: %w", info.EffectiveFQDN, recordID, err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func splitDomain(full string) (string, string, error) { split := dns.Split(full) if len(split) < 2 { return "", "", fmt.Errorf("unsupported domain: %s", full) } if len(split) == 2 { return full, "", nil } domain := full[split[len(split)-2]:] subDomain := full[:split[len(split)-2]-1] return domain, subDomain, nil } ================================================ FILE: providers/dns/njalla/njalla.toml ================================================ Name = "Njalla" Description = '''''' URL = "https://njal.la" Code = "njalla" Since = "v4.3.0" Example = ''' NJALLA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns njalla -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NJALLA_TOKEN = "API token" [Configuration.Additional] NJALLA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NJALLA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" NJALLA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" NJALLA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://njal.la/api/" ================================================ FILE: providers/dns/njalla/njalla_test.go ================================================ package njalla import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvToken: "", }, expected: "njalla: some credentials information are missing: NJALLA_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "123", }, { desc: "missing credentials", expected: "njalla: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/nodion/nodion.go ================================================ // Package nodion implements a DNS provider for solving the DNS-01 challenge using Nodion DNS. package nodion import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/nodion" ) // Environment variables names. const ( envNamespace = "NODION_" EnvAPIToken = envNamespace + "API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *nodion.Client zoneIDs map[string]string zoneIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Nodion. // Credentials must be passed in the environment variable: NODION_API_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("nodion: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Nodion. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("nodion: the configuration of the DNS provider is nil") } if config.APIToken == "" { return nil, errors.New("nodion: incomplete credentials, missing API token") } client, err := nodion.NewClient(config.APIToken) if err != nil { return nil, err } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, zoneIDs: map[string]string{}, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("nodion: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("nodion: %w", err) } ctx := context.Background() zones, err := d.client.GetZones(ctx, &nodion.ZonesFilter{Name: dns01.UnFqdn(authZone)}) if err != nil { return fmt.Errorf("nodion: %w", err) } if len(zones) == 0 { return fmt.Errorf("nodion: zone not found: %s", authZone) } if len(zones) > 1 { return fmt.Errorf("nodion: too many possible zones for the domain %s: %v", authZone, zones) } zoneID := zones[0].ID record := nodion.Record{ RecordType: nodion.TypeTXT, Name: subDomain, Content: info.Value, TTL: d.config.TTL, } _, err = d.client.CreateRecord(ctx, zoneID, record) if err != nil { return fmt.Errorf("nodion: failed to create TXT records [domain: %s, sub domain: %s]: %w", dns01.UnFqdn(authZone), subDomain, err) } d.zoneIDsMu.Lock() d.zoneIDs[token] = zoneID d.zoneIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("nodion: could not find zone for domain %q: %w", domain, err) } d.zoneIDsMu.Lock() zoneID, ok := d.zoneIDs[token] d.zoneIDsMu.Unlock() if !ok { return fmt.Errorf("nodion: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("nodion: %w", err) } ctx := context.Background() filter := &nodion.RecordsFilter{ Name: subDomain, RecordType: nodion.TypeTXT, Content: info.Value, } records, err := d.client.GetRecords(ctx, zoneID, filter) if err != nil { return fmt.Errorf("nodion: %w", err) } if len(records) == 0 { return fmt.Errorf("nodion: record not found: %s", authZone) } if len(records) > 1 { return fmt.Errorf("nodion: too many possible records for the domain %s: %v", info.EffectiveFQDN, records) } _, err = d.client.DeleteRecord(ctx, zoneID, records[0].ID) if err != nil { return fmt.Errorf("regru: failed to remove TXT records [domain: %s]: %w", dns01.UnFqdn(authZone), err) } d.zoneIDsMu.Lock() delete(d.zoneIDs, token) d.zoneIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/nodion/nodion.toml ================================================ Name = "Nodion" Description = '''''' URL = "https://www.nodion.com" Code = "nodion" Since = "v4.11.0" Example = ''' NODION_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns nodion -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NODION_API_TOKEN = "The API token" [Configuration.Additional] NODION_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NODION_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" NODION_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" NODION_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.nodion.com/en/docs/dns/api/" ================================================ FILE: providers/dns/nodion/nodion_test.go ================================================ package nodion import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "nodion: some credentials information are missing: NODION_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "123", }, { desc: "missing credentials", expected: "nodion: incomplete credentials, missing API token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/ns1/ns1.go ================================================ // Package ns1 implements a DNS provider for solving the DNS-01 challenge using NS1 DNS. package ns1 import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "gopkg.in/ns1/ns1-go.v2/rest" "gopkg.in/ns1/ns1-go.v2/rest/model/dns" ) // Environment variables names. const ( envNamespace = "NS1_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *rest.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for NS1. // Credentials must be passed in the environment variables: NS1_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("ns1: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for NS1. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ns1: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("ns1: credentials missing") } if config.HTTPClient == nil { // Because the rest.NewClient uses the http.DefaultClient. config.HTTPClient = &http.Client{Timeout: 10 * time.Second} } client := rest.NewClient(clientdebug.Wrap(config.HTTPClient), rest.SetAPIKey(config.APIKey)) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ns1: %w", err) } record, _, err := d.client.Records.Get(zone.Zone, dns01.UnFqdn(info.EffectiveFQDN), "TXT") // Create a new record if errors.Is(err, rest.ErrRecordMissing) || record == nil { log.Infof("Create a new record for [zone: %s, fqdn: %s, domain: %s]", zone.Zone, info.EffectiveFQDN, domain) // Work through a bug in the NS1 API library that causes 400 Input validation failed (Value None for field '.filters' is not of type ...) // So the `tags` and `blockedTags` parameters should be initialized to empty. record = dns.NewRecord(zone.Zone, dns01.UnFqdn(info.EffectiveFQDN), "TXT", make(map[string]string), make([]string, 0)) record.TTL = d.config.TTL record.Answers = []*dns.Answer{{Rdata: []string{info.Value}}} _, err = d.client.Records.Create(record) if err != nil { return fmt.Errorf("ns1: failed to create record [zone: %q, fqdn: %q]: %w", zone.Zone, info.EffectiveFQDN, err) } return nil } if err != nil { return fmt.Errorf("ns1: failed to get the existing record: %w", err) } // Update the existing records record.Answers = append(record.Answers, &dns.Answer{Rdata: []string{info.Value}}) log.Infof("Update an existing record for [zone: %s, fqdn: %s, domain: %s]", zone.Zone, info.EffectiveFQDN, domain) _, err = d.client.Records.Update(record) if err != nil { return fmt.Errorf("ns1: failed to update record [zone: %q, fqdn: %q]: %w", zone.Zone, info.EffectiveFQDN, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getHostedZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ns1: %w", err) } name := dns01.UnFqdn(info.EffectiveFQDN) _, err = d.client.Records.Delete(zone.Zone, name, "TXT") if err != nil { return fmt.Errorf("ns1: failed to delete record [zone: %q, domain: %q]: %w", zone.Zone, name, err) } return 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 } func (d *DNSProvider) getHostedZone(fqdn string) (*dns.Zone, error) { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) } authZone = dns01.UnFqdn(authZone) zone, _, err := d.client.Zones.Get(authZone, false) if err != nil { return nil, fmt.Errorf("failed to get zone [authZone: %q, fqdn: %q]: %w", authZone, fqdn, err) } return zone, nil } ================================================ FILE: providers/dns/ns1/ns1.toml ================================================ Name = "NS1" Description = '''''' URL = "https://ns1.com" Code = "ns1" Since = "v0.4.0" Example = ''' NS1_API_KEY=xxxx \ lego --dns ns1 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] NS1_API_KEY = "API key" [Configuration.Additional] NS1_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" NS1_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" NS1_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" NS1_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://ns1.com/api" GoClient = "https://github.com/ns1/ns1-go" ================================================ FILE: providers/dns/ns1/ns1_test.go ================================================ package ns1 import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "ns1: some credentials information are missing: NS1_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "123", }, { desc: "missing credentials", expected: "ns1: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/octenium/fixtures/add_dns_record.json ================================================ { "api-status": "success", "api-response": { "record": { "type": "TXT", "name": "_acme-challenge.example.com.", "ttl": 120, "value": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", "raw": { "txtdata": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI" } } } } ================================================ FILE: providers/dns/octenium/fixtures/delete_dns_record.json ================================================ { "api-status": "success", "api-response": { "deleted": { "count": 1, "lines": [ 123 ] } } } ================================================ FILE: providers/dns/octenium/fixtures/list_dns_records.json ================================================ { "api-status": "success", "api-response": { "records": [ { "line": 31, "type": "TXT", "name": "_dmarc.example.com.", "ttl": 300, "value": "xxx", "raw": { "txtdata": "xxx" } }, { "line": 123, "type": "TXT", "name": "_acme-challenge.example.com.", "ttl": 300, "value": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", "raw": { "txtdata": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI" } } ] } } ================================================ FILE: providers/dns/octenium/fixtures/list_domains.json ================================================ { "api-status": "success", "api-response": { "domains": { "2976": { "domain-name": "example.com", "registration-date": "21\/08\/2025", "expiration-date": "-", "status": "active" } } } } ================================================ FILE: providers/dns/octenium/internal/client.go ================================================ package internal import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://api.panel.octenium.com/" const statusSuccess = "success" // Client the Octenium API client. type Client struct { apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey string) (*Client, error) { if apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // ListDomains retrieves a list of domains. // https://octenium.com/api#tag/Domains/operation/listdomains func (c *Client) ListDomains(ctx context.Context, domain string) (map[string]Domain, error) { endpoint := c.BaseURL.JoinPath("domains") data := endpoint.Query() data.Set("domain-name", domain) endpoint.RawQuery = data.Encode() req, err := newRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := &DomainsResponse{} err = c.do(req, result) if err != nil { return nil, err } return result.Domains, nil } // ListDNSRecords retrieves a list of DNS records. // https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-list func (c *Client) ListDNSRecords(ctx context.Context, orderID, recordType string) ([]Record, error) { endpoint := c.BaseURL.JoinPath("domains", "dns-records", "list") data := make(url.Values) data.Set("order-id", orderID) data.Set("types[]", recordType) req, err := newRequest(ctx, http.MethodPost, endpoint, data) if err != nil { return nil, err } result := &ListRecordsResponse{} err = c.do(req, result) if err != nil { return nil, err } return result.Records, nil } // AddDNSRecord adds a DNS record. // https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-add func (c *Client) AddDNSRecord(ctx context.Context, orderID string, record Record) (*Record, error) { endpoint := c.BaseURL.JoinPath("domains", "dns-records", "add") data, err := querystring.Values(record) if err != nil { return nil, err } data.Set("order-id", orderID) req, err := newRequest(ctx, http.MethodPost, endpoint, data) if err != nil { return nil, err } result := &AddRecordResponse{} err = c.do(req, result) if err != nil { return nil, err } return result.Record, nil } // DeleteDNSRecord deletes a DNS record. // https://octenium.com/api#tag/Domains-DNS/operation/domains-dns-records-delete func (c *Client) DeleteDNSRecord(ctx context.Context, orderID string, recordID int) (*DeletedRecordInfo, error) { endpoint := c.BaseURL.JoinPath("domains", "dns-records", "delete") data := make(url.Values) data.Set("order-id", orderID) data.Set("line", strconv.Itoa(recordID)) req, err := newRequest(ctx, http.MethodPost, endpoint, data) if err != nil { return nil, err } result := &DeleteRecordResponse{} err = c.do(req, result) if err != nil { return nil, err } return result.Deleted, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set("X-Api-Key", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } var response APIResponse err = json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if response.Status != statusSuccess { return fmt.Errorf("unexpected status: %s: %s", response.Status, response.Error) } err = json.Unmarshal(response.Response, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, response.Response, err) } return nil } func newRequest(ctx context.Context, method string, endpoint *url.URL, payload url.Values) (*http.Request, error) { var body io.Reader = http.NoBody if method == http.MethodPost && payload != nil { body = strings.NewReader(payload.Encode()) } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if method == http.MethodPost && payload != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } return req, nil } ================================================ FILE: providers/dns/octenium/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithAccept("application/json"). With("X-Api-Key", "secret"), ) } func TestClient_ListDomains(t *testing.T) { client := mockBuilder(). Route("GET /domains", servermock.ResponseFromFixture("list_domains.json"), servermock.CheckQueryParameter().Strict(). With("domain-name", "example.com")). Build(t) domains, err := client.ListDomains(t.Context(), "example.com") require.NoError(t, err) expected := map[string]Domain{ "2976": {DomainName: "example.com", RegistrationDate: "12/09/2021", ExpirationDate: "12/09/2024", Status: "active"}, "2977": {DomainName: "example.org", RegistrationDate: "01/10/2021", ExpirationDate: "01/10/2024", Status: "active"}, "2978": {DomainName: "example.net", RegistrationDate: "21/08/2025", ExpirationDate: "-", Status: "active"}, } assert.Equal(t, expected, domains) } func TestClient_ListDomains_error(t *testing.T) { client := mockBuilder(). Route("GET /domains", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.ListDomains(t.Context(), "example.com") require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") } func TestClient_ListDomains_api_error(t *testing.T) { client := mockBuilder(). Route("GET /domains", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.ListDomains(t.Context(), "example.com") require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)") } func TestClient_ListDNSRecords(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/list", servermock.ResponseFromFixture("list_dns_records.json"), servermock.CheckHeader(). WithContentType("application/x-www-form-urlencoded"), servermock.CheckForm().Strict(). With("order-id", "abc"). With("types[]", "TXT")). Build(t) records, err := client.ListDNSRecords(t.Context(), "abc", "TXT") require.NoError(t, err) expected := []Record{ {ID: 15, Type: "A", Name: "example.com.", TTL: 14400, Value: "203.0.113.10"}, {ID: 22, Type: "MX", Name: "example.com.", TTL: 14400, Value: "10 mail.example.com."}, {ID: 31, Type: "TXT", Name: "_dmarc.example.com.", TTL: 300, Value: "v=DMARC1; p=none; rua=mailto:dmarc@example.com"}, } assert.Equal(t, expected, records) } func TestClient_ListDNSRecords_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/list", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.ListDNSRecords(t.Context(), "abc", "TXT") require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") } func TestClient_ListDNSRecords_api_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/list", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.ListDNSRecords(t.Context(), "abc", "TXT") require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)") } func TestClient_AddDNSRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/add", servermock.ResponseFromFixture("add_dns_record.json"), servermock.CheckHeader(). WithContentType("application/x-www-form-urlencoded"), servermock.CheckForm().Strict(). With("order-id", "abc"). With("name", "example.com."). With("ttl", "120"). With("type", "TXT"). With("value", "txtTXTtxt")). Build(t) record := Record{ Type: "TXT", Name: "example.com.", TTL: 120, Value: "txtTXTtxt", } result, err := client.AddDNSRecord(t.Context(), "abc", record) require.NoError(t, err) expected := &Record{ Type: "A", Name: "example.com.", TTL: 14400, Value: "203.0.113.10", } assert.Equal(t, expected, result) } func TestClient_AddDNSRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/add", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) record := Record{ Type: "TXT", Name: "example.com.", TTL: 120, Value: "txtTXTtxt", } _, err := client.AddDNSRecord(t.Context(), "abc", record) require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") } func TestClient_AddDNSRecord_api_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/add", servermock.ResponseFromFixture("error.json")). Build(t) record := Record{ Type: "TXT", Name: "example.com.", TTL: 120, Value: "txtTXTtxt", } _, err := client.AddDNSRecord(t.Context(), "abc", record) require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)") } func TestClient_DeleteDNSRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/delete", servermock.ResponseFromFixture("delete_dns_record.json"), servermock.CheckHeader(). WithContentType("application/x-www-form-urlencoded"), servermock.CheckForm().Strict(). With("order-id", "abc"). With("line", "123")). Build(t) result, err := client.DeleteDNSRecord(t.Context(), "abc", 123) require.NoError(t, err) expected := &DeletedRecordInfo{ Count: 1, Lines: []int{15}, } assert.Equal(t, expected, result) } func TestClient_DeleteDNSRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/delete", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.DeleteDNSRecord(t.Context(), "abc", 123) require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") } func TestClient_DeleteDNSRecord_api_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/dns-records/delete", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.DeleteDNSRecord(t.Context(), "abc", 123) require.EqualError(t, err, "unexpected status: error: missing required fields (type, name, ttl)") } ================================================ FILE: providers/dns/octenium/internal/fixtures/add_dns_record.json ================================================ { "api-status": "success", "api-response": { "record": { "type": "A", "name": "example.com.", "ttl": 14400, "value": "203.0.113.10", "raw": { "address": "203.0.113.10" } } } } ================================================ FILE: providers/dns/octenium/internal/fixtures/delete_dns_record.json ================================================ { "api-status": "success", "api-response": { "deleted": { "count": 1, "lines": [ 15 ] } } } ================================================ FILE: providers/dns/octenium/internal/fixtures/error.json ================================================ { "api-status": "error", "api-response": [], "api-error": "missing required fields (type, name, ttl)" } ================================================ FILE: providers/dns/octenium/internal/fixtures/list_dns_records.json ================================================ { "api-status": "success", "api-response": { "records": [ { "line": 15, "type": "A", "name": "example.com.", "ttl": 14400, "value": "203.0.113.10", "raw": { "address": "203.0.113.10" } }, { "line": 22, "type": "MX", "name": "example.com.", "ttl": 14400, "value": "10 mail.example.com.", "raw": { "preference": 10, "exchange": "mail.example.com." } }, { "line": 31, "type": "TXT", "name": "_dmarc.example.com.", "ttl": 300, "value": "v=DMARC1; p=none; rua=mailto:dmarc@example.com", "raw": { "txtdata": "v=DMARC1; p=none; rua=mailto:dmarc@example.com" } } ] } } ================================================ FILE: providers/dns/octenium/internal/fixtures/list_domains.json ================================================ { "api-status": "success", "api-response": { "domains": { "2976": { "domain-name": "example.com", "registration-date": "12/09/2021", "expiration-date": "12/09/2024", "status": "active" }, "2977": { "domain-name": "example.org", "registration-date": "01/10/2021", "expiration-date": "01/10/2024", "status": "active" }, "2978": { "domain-name": "example.net", "registration-date": "21\/08\/2025", "expiration-date": "-", "status": "active" } } } } ================================================ FILE: providers/dns/octenium/internal/types.go ================================================ package internal import "encoding/json" type APIResponse struct { Status string `json:"api-status,omitempty"` Response json.RawMessage `json:"api-response,omitempty"` Error string `json:"api-error,omitempty"` } type Domain struct { DomainName string `json:"domain-name,omitempty"` RegistrationDate string `json:"registration-date,omitempty"` ExpirationDate string `json:"expiration-date,omitempty"` Status string `json:"status,omitempty"` } type Record struct { ID int `json:"line,omitempty" url:"-"` Type string `json:"type,omitempty" url:"type,omitempty"` Name string `json:"name,omitempty" url:"name,omitempty"` TTL int `json:"ttl,omitempty" url:"ttl,omitempty"` Value string `json:"value,omitempty" url:"value,omitempty"` } type DomainsResponse struct { Domains map[string]Domain `json:"domains,omitempty"` } type AddRecordResponse struct { Record *Record `json:"record,omitempty"` } type ListRecordsResponse struct { Records []Record `json:"records,omitempty"` } type DeleteRecordResponse struct { Deleted *DeletedRecordInfo `json:"deleted,omitempty"` } type DeletedRecordInfo struct { Count int `json:"count,omitempty"` Lines []int `json:"lines,omitempty"` } ================================================ FILE: providers/dns/octenium/octenium.go ================================================ // Package octenium implements a DNS provider for solving the DNS-01 challenge using Octenium. package octenium import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/octenium/internal" "github.com/hashicorp/go-retryablehttp" ) // Environment variables names. const ( envNamespace = "OCTENIUM_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client domainIDs map[string]string domainIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Octenium. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("octenium: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Octenium. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("octenium: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIKey) if err != nil { return nil, fmt.Errorf("octenium: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } retryClient := retryablehttp.NewClient() retryClient.RetryMax = 5 retryClient.HTTPClient = client.HTTPClient retryClient.Logger = log.Logger client.HTTPClient = clientdebug.Wrap(retryClient.StandardClient()) return &DNSProvider{ config: config, client: client, domainIDs: make(map[string]string), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("octenium: could not find zone for domain '%s': %w", domain, err) } domainID, err := d.getDomainID(ctx, authZone) if err != nil { return fmt.Errorf("octenium: get domain ID: %w", err) } d.domainIDsMu.Lock() d.domainIDs[token] = domainID d.domainIDsMu.Unlock() record := internal.Record{ Type: "TXT", Name: info.EffectiveFQDN, TTL: d.config.TTL, Value: info.Value, } _, err = d.client.AddDNSRecord(ctx, domainID, record) if err != nil { return fmt.Errorf("octenium: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) d.domainIDsMu.Lock() domainID, ok := d.domainIDs[token] d.domainIDsMu.Unlock() if !ok { return fmt.Errorf("octenium: unknown domain ID for '%s'", info.EffectiveFQDN) } records, err := d.client.ListDNSRecords(ctx, domainID, "TXT") if err != nil { return fmt.Errorf("octenium: list records: %w", err) } for _, record := range records { if record.Type != "TXT" || record.Name != info.EffectiveFQDN || record.Value != info.Value { continue } _, err = d.client.DeleteDNSRecord(ctx, domainID, record.ID) if err != nil { return fmt.Errorf("octenium: delete record: %w", err) } break } d.domainIDsMu.Lock() delete(d.domainIDs, token) d.domainIDsMu.Unlock() return 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 } func (d *DNSProvider) getDomainID(ctx context.Context, authZone string) (string, error) { domains, err := d.client.ListDomains(ctx, dns01.UnFqdn(authZone)) if err != nil { return "", fmt.Errorf("list domains: %w", err) } if len(domains) == 0 { return "", errors.New("domain not found") } if len(domains) > 1 { return "", errors.New("multiple domains found") } for id := range domains { return id, nil } return "", errors.New("domain ID not found") } ================================================ FILE: providers/dns/octenium/octenium.toml ================================================ Name = "Octenium" Description = '''''' URL = "https://octenium.com/" Code = "octenium" Since = "v4.27.0" Example = ''' OCTENIUM_API_KEY="xxx" \ lego --dns octenium -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] OCTENIUM_API_KEY = "API key" [Configuration.Additional] OCTENIUM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" OCTENIUM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" OCTENIUM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" OCTENIUM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://octenium.com/api#tag/Domains-DNS" ================================================ FILE: providers/dns/octenium/octenium_test.go ================================================ package octenium import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "secret", }, }, { desc: "missing API key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "octenium: some credentials information are missing: OCTENIUM_API_KEY", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "octenium: some credentials information are missing: OCTENIUM_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "secret", }, { desc: "missing API key", expected: "octenium: credentials missing", }, { desc: "missing credentials", expected: "octenium: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithAccept("application/json"). With("X-Api-Key", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /domains", servermock.ResponseFromFixture("list_domains.json"), servermock.CheckQueryParameter().Strict(). With("domain-name", "example.com")). Route("POST /domains/dns-records/add", servermock.ResponseFromFixture("add_dns_record.json"), servermock.CheckHeader(). WithContentType("application/x-www-form-urlencoded"), servermock.CheckForm().Strict(). With("order-id", "2976"). With("name", "_acme-challenge.example.com."). With("ttl", "120"). With("type", "TXT"). With("value", "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI")). Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("POST /domains/dns-records/list", servermock.ResponseFromFixture("list_dns_records.json"), servermock.CheckHeader(). WithContentType("application/x-www-form-urlencoded"), servermock.CheckForm().Strict(). With("order-id", "2976"). With("types[]", "TXT")). Route("POST /domains/dns-records/delete", servermock.ResponseFromFixture("delete_dns_record.json"), servermock.CheckHeader(). WithContentType("application/x-www-form-urlencoded"), servermock.CheckForm().Strict(). With("order-id", "2976"). With("line", "123")). Build(t) provider.domainIDs["token"] = "2976" err := provider.CleanUp("example.com", "token", "foobar") require.NoError(t, err) } ================================================ FILE: providers/dns/oraclecloud/configurationprovider.go ================================================ package oraclecloud import ( "crypto/rsa" "encoding/base64" "errors" "fmt" "os" "slices" "strings" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/nrdcg/oci-go-sdk/common/v1065" ) type environmentConfigurationProvider struct { values map[string]string } func newEnvironmentConfigurationProvider() (*environmentConfigurationProvider, error) { values, err := env.GetWithFallback( []string{EnvRegion, altEnvTFVarRegion}, []string{EnvUserOCID, altEnvTFVarUserOCID}, []string{EnvTenancyOCID, altEnvTFVarTenancyOCID}, []string{EnvPubKeyFingerprint, altEnvFingerprint, altEnvTFVarFingerprint}, ) if err != nil { return nil, err } return &environmentConfigurationProvider{ values: values, }, nil } func (p *environmentConfigurationProvider) PrivateRSAKey() (*rsa.PrivateKey, error) { privateKey, err := getPrivateKey() if err != nil { return nil, err } return common.PrivateKeyFromBytesWithPassword(privateKey, []byte(p.privateKeyPassword())) } func (p *environmentConfigurationProvider) KeyID() (string, error) { tenancy, err := p.TenancyOCID() if err != nil { return "", err } user, err := p.UserOCID() if err != nil { return "", err } fingerprint, err := p.KeyFingerprint() if err != nil { return "", err } return fmt.Sprintf("%s/%s/%s", tenancy, user, fingerprint), nil } func (p *environmentConfigurationProvider) TenancyOCID() (string, error) { return p.values[EnvTenancyOCID], nil } func (p *environmentConfigurationProvider) UserOCID() (string, error) { return p.values[EnvUserOCID], nil } func (p *environmentConfigurationProvider) KeyFingerprint() (string, error) { return p.values[EnvPubKeyFingerprint], nil } func (p *environmentConfigurationProvider) Region() (string, error) { return p.values[EnvRegion], nil } func (p *environmentConfigurationProvider) AuthType() (common.AuthConfig, error) { // Inspired by https://github.com/oracle/oci-go-sdk/blob/e7635c292e60d0a9dcdd3a1e7de180d7c99b1eee/common/configuration.go#L231-L234 return common.AuthConfig{AuthType: common.UnknownAuthenticationType}, errors.New("unsupported, keep the interface") } func (p *environmentConfigurationProvider) privateKeyPassword() string { return env.GetOneWithFallback(EnvPrivKeyPass, "", env.ParseString, altEnvPrivateKeyPassword, altEnvTFVarPrivateKeyPassword) } func getPrivateKey() ([]byte, error) { base64EnvKeys := []string{envPrivKey, altEnvPrivateKey} envVarValue := getEnvWithStrictFallback(base64EnvKeys...) if envVarValue != "" { bytes, err := base64.StdEncoding.DecodeString(envVarValue) if err != nil { return nil, fmt.Errorf("failed to read base64 value %s (defined by env vars %s): %w", envVarValue, strings.Join(base64EnvKeys, " or "), err) } return bytes, nil } fileEnvKeys := []string{EnvPrivKeyFile, altEnvPrivateKeyPath, altEnvTFVarPrivateKeyPath} fileVarValue := getEnvFileWithStrictFallback(fileEnvKeys...) if len(fileVarValue) == 0 { return nil, fmt.Errorf("no value provided for: %s", strings.Join(slices.Concat(base64EnvKeys, fileEnvKeys), " or "), ) } return fileVarValue, nil } func getEnvWithStrictFallback(keys ...string) string { for _, key := range keys { envVarValue := os.Getenv(key) if envVarValue != "" { return envVarValue } } return "" } func getEnvFileWithStrictFallback(keys ...string) []byte { for _, key := range keys { fileVarValue := os.Getenv(key) if fileVarValue == "" { continue } fileContents, err := os.ReadFile(fileVarValue) if err != nil { log.Printf("Failed to read the file %s (defined by env var %s): %s", fileVarValue, key, err) return nil } return fileContents } return nil } ================================================ FILE: providers/dns/oraclecloud/fixtures/cert.pem ================================================ -----BEGIN CERTIFICATE----- MIIDHzCCAgegAwIBAgIQKIExaCLIXtXecrT1dWGLszANBgkqhkiG9w0BAQsFADAS MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEAwM4wEPHOGAu8tZNNWx3cH6AMuqKwAmB2RwbA3OK034MzhydOjnDm igw93eUc4nd3dnICyNpb2rbP9FgGlAuMlJ8raHQkG4DSXF1Bf14neOhLpfBItaX9 +EB3oO0NupKZhaHrsTKzLGD7bauAPX6PDXuAPp3u5mgGGuZjpLZoKqg3//WImb/2 xEMVsmvPKTb5FxS/tAMtywjGSUtCTCrudUEh4Gnj6IboVdwYmt539ETDK/Rerxf3 /GsmEbuOkDUdBixQwLo0U+UAoMOw4zoyQDrrtyUmvffDxI50RAdZDFyFtqZ0ZQa8 lQqrMdQdf+x1Wb7BKozSktAw4igRP/mknQIDAQABo28wbTAOBgNVHQ8BAf8EBAMC AqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E FgQUcetTliVbYxxutNS8JRkotRY4DRkwFgYDVR0RBA8wDYILZXhhbXBsZS5vcmcw DQYJKoZIhvcNAQELBQADggEBAEJP74/XB+12aGQ+EMERIX2Pn6YaaBLt6rTLqV7A zFxI9YGIc4xlGa0qkpDhpz6RSypTQG6HN5aZ5b8dz3foMleUVP2cXd8zduc8GQCb p4/8PpEhSl6dQb5+mg/qyHGUAaDl40VAbTLXHtn98dhacaJc+TKuXVJAgYRU3Sm3 wFJxULZSnx+aGdE9s2brOGhvz1fVWnhvWzDvJSM+8xDURz8UiEnimTpV6m3CKItz 2GatNjM8ADKC7MHQI4I5v4fEwronN/g3NfPfFSmnOKk+lPSAW42WEvhFol+2VvdX 3p5X2QracSLCIj/DUBebZP9110C8Lj/YfFtOjFokqtQ9Fh4= -----END CERTIFICATE----- ================================================ FILE: providers/dns/oraclecloud/fixtures/key.pem ================================================ -----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAzjAQ8c4YC7y1 k01bHdwfoAy6orACYHZHBsDc4rTfgzOHJ06OcOaKDD3d5Rzid3d2cgLI2lvats/0 WAaUC4yUnytodCQbgNJcXUF/Xid46Eul8Ei1pf34QHeg7Q26kpmFoeuxMrMsYPtt q4A9fo8Ne4A+ne7maAYa5mOktmgqqDf/9YiZv/bEQxWya88pNvkXFL+0Ay3LCMZJ S0JMKu51QSHgaePohuhV3Bia3nf0RMMr9F6vF/f8ayYRu46QNR0GLFDAujRT5QCg w7DjOjJAOuu3JSa998PEjnREB1kMXIW2pnRlBryVCqsx1B1/7HVZvsEqjNKS0DDi KBE/+aSdAgMBAAECggEAWl2pWJ/ErS9/HIl0NbMKk0YEAUuz/AEzHnoTVdPp22KW eY+aOZe/7c7sBj7WqWw98SVhmbsCV0HcuNSzDJtXIedyRGw+6icYMVNCGgzKqlgR 8K3snjq1DLBGgYXpq9r/Got4ON6e7LttzIqXufrB2JtcUbzbFmGGDwCRjkcyDl9l M8ufwD/Xgcd2L8jainU43d2pVxvxUIpRlRdoupCCSlkRYPsXiWlqav7YO4F/Txos z3gJyzkXzc3WwfNZdQtEMYwBwozO+Dp2p4TUBr0Ta3MbfrKfDoTs4XT/Ce9IwJJS /h6E9cxZD8t5oMT50quFjwhHBKodMiUqIlh2YQEAbwKBgQDIULzo/tgDgTwveyEn L9n8yVbEh/SfrE9QtXcjkDB5+tYmIsIaz16NRWlAqnJVGZvcanrCq7ZTxgUcs/hW Ag+sfWkeg7lmfeJAkiZ6kmi1h2qJjXMOBri+Cm6MTOsE6qdIc3eT4PnYkNpV7o6S 70hWNncVadXLV4Thm9BLAbMbQwKBgQD2ZwKe/2zRQcbuBe1loF0HWIsJPxcKQ3LH hVf7f0YLQlIuzOhK8TQXgM0G4hxLlk1XeLjgf3z4Ju7hfh2JQLor1QYPRGUj66SX KTE5eDwE0yEX1c9m5PW6M+f8vkOU4LQ/OtPw5OrKyYxpLf9dp42nmDYY/8IvUk96 iKZNY1sSnwKBgQC27tS2SxVmjf0yt1WdfdurOQueSzKhJzD/2djFh4Zdvy8WgKOW 7E3C4eKvBXmIMezeq/cUFNBbTPmaLtjZYuSBd74p+c20xb17jnzJby9kqBgpKh4q bwUDuG8gfZYbVVgTmC9ZwxkoJ5Dc7RETKqZ65R53VcHDA1f82Nitxw2UFQKBgBDl c2qPvViEGC4OPf8wBfERA0e5Cc1sXpyL6kKWsajn/Va0OmGZNKc/788/Bg2w2tDa uGK8m0cw9ESGL2RQCfQjgWzelcjmybyL2JJGSmdSSvylbrlxjeAc2xWbvmqhFfsX /5yPNgJ926ECxHYZnT8W0u7X6urvy/9tC2pXG9GlAoGBAKOAfij4fMbHY+Z1m825 VhY110FDnePYFJWmExP8GAVqOzhCs0mzyCnYh6nvS/OY8moH2LOuwPUlDfF3IzyT hTUuXnykWT3w40eYQXXIaXEGhue+guL8ch16vEEJy5ltwEdIPNMTErbqAAk2W6Ps NB46HzETzEIWnzoamX6iQVWj -----END PRIVATE KEY----- ================================================ FILE: providers/dns/oraclecloud/oraclecloud.go ================================================ // Package oraclecloud implements a DNS provider for solving the DNS-01 challenge using Oracle Cloud DNS. package oraclecloud import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/oci-go-sdk/common/v1065" "github.com/nrdcg/oci-go-sdk/common/v1065/auth" "github.com/nrdcg/oci-go-sdk/dns/v1065" ) // Environment variables names. const ( envNamespace = "OCI_" EnvAuthType = envNamespace + "AUTH_TYPE" EnvCompartmentOCID = envNamespace + "COMPARTMENT_OCID" EnvRegion = envNamespace + "REGION" envPrivKey = envNamespace + "PRIVKEY" EnvPrivKeyFile = envPrivKey + "_FILE" EnvPrivKeyPass = envPrivKey + "_PASS" EnvTenancyOCID = envNamespace + "TENANCY_OCID" EnvUserOCID = envNamespace + "USER_OCID" EnvPubKeyFingerprint = envNamespace + "PUBKEY_FINGERPRINT" altEnvPrivateKey = envNamespace + "PRIVATE_KEY" // alias on OCI_PRIVKEY altEnvPrivateKeyPath = altEnvPrivateKey + "_PATH" // alias on OCI_PRIVKEY_FILE altEnvPrivateKeyPassword = altEnvPrivateKey + "_PASSWORD" // alias on OCI_PRIVKEY_PASS altEnvFingerprint = envNamespace + "FINGERPRINT" // alias on OCI_PUBKEY_FINGERPRINT EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // https://github.com/oracle/oci-go-sdk/blob/7f425f74c74fd0c6a5acb74466c85eb5346e0092/common/client.go#L350 // https://github.com/oracle/oci-go-sdk/blob/7f425f74c74fd0c6a5acb74466c85eb5346e0092/common/configuration.go#L174-L175 const ( altEnvTFVarNamespace = "TF_VAR_" altEnvTFVarRegion = altEnvTFVarNamespace + "region" // alias on OCI_REGION altEnvTFVarFingerprint = altEnvTFVarNamespace + "fingerprint" // alias on OCI_PUBKEY_FINGERPRINT altEnvTFVarUserOCID = altEnvTFVarNamespace + "user_ocid" // alias on OCI_USER_OCID altEnvTFVarTenancyOCID = altEnvTFVarNamespace + "tenancy_ocid" // alias on OCI_TENANCY_OCID altEnvTFVarPrivateKeyPath = altEnvTFVarNamespace + "private_key_path" // alias on OCI_PRIVKEY_FILE altEnvTFVarPrivateKeyPassword = altEnvTFVarNamespace + "private_key_password" // alias on OCI_PRIVKEY_PASS ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { CompartmentID string OCIConfigProvider common.ConfigurationProvider PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *dns.DnsClient config *Config } // NewDNSProvider returns a DNSProvider instance configured for OracleCloud. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() switch env.GetOrFile(EnvAuthType) { case string(common.InstancePrincipal): values, err := env.Get(EnvCompartmentOCID) if err != nil { return nil, fmt.Errorf("oraclecloud: %w", err) } config.CompartmentID = values[EnvCompartmentOCID] region := env.GetOneWithFallback(EnvRegion, "", env.ParseString, altEnvTFVarRegion) configurationProvider, err := auth.InstancePrincipalConfigurationProviderForRegion(common.Region(region)) if err != nil { return nil, fmt.Errorf("oraclecloud: %w", err) } config.OCIConfigProvider = configurationProvider default: values, err := env.Get(EnvCompartmentOCID) if err != nil { return nil, fmt.Errorf("oraclecloud: %w", err) } config.CompartmentID = values[EnvCompartmentOCID] ecp, err := newEnvironmentConfigurationProvider() if err != nil { return nil, fmt.Errorf("oraclecloud: %w", err) } config.OCIConfigProvider = ecp } return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for OracleCloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("oraclecloud: the configuration of the DNS provider is nil") } if config.CompartmentID == "" { return nil, errors.New("oraclecloud: CompartmentID is missing") } if config.OCIConfigProvider == nil { return nil, errors.New("oraclecloud: OCIConfigProvider is missing") } client, err := dns.NewDnsClientWithConfigurationProvider(config.OCIConfigProvider) if err != nil { return nil, fmt.Errorf("oraclecloud: %w", err) } if config.HTTPClient != nil { client.HTTPClient = clientdebug.Wrap(config.HTTPClient) } return &DNSProvider{client: &client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zoneNameOrID, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("oraclecloud: could not find zone for domain %q: %w", domain, err) } // generate request to dns.PatchDomainRecordsRequest recordOperation := dns.RecordOperation{ Domain: common.String(dns01.UnFqdn(info.EffectiveFQDN)), Rdata: common.String(info.Value), Rtype: common.String("TXT"), Ttl: common.Int(d.config.TTL), IsProtected: common.Bool(false), } request := dns.PatchDomainRecordsRequest{ CompartmentId: common.String(d.config.CompartmentID), ZoneNameOrId: common.String(zoneNameOrID), Domain: common.String(dns01.UnFqdn(info.EffectiveFQDN)), PatchDomainRecordsDetails: dns.PatchDomainRecordsDetails{ Items: []dns.RecordOperation{recordOperation}, }, } _, err = d.client.PatchDomainRecords(context.Background(), request) if err != nil { return fmt.Errorf("oraclecloud: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zoneNameOrID, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("oraclecloud: could not find zone for domain %q: %w", domain, err) } // search to TXT record's hash to delete getRequest := dns.GetDomainRecordsRequest{ ZoneNameOrId: common.String(zoneNameOrID), Domain: common.String(dns01.UnFqdn(info.EffectiveFQDN)), CompartmentId: common.String(d.config.CompartmentID), Rtype: common.String("TXT"), } ctx := context.Background() domainRecords, err := d.client.GetDomainRecords(ctx, getRequest) if err != nil { return fmt.Errorf("oraclecloud: %w", err) } if *domainRecords.OpcTotalItems == 0 { return errors.New("oraclecloud: no record to clean up") } var deleteHash *string for _, record := range domainRecords.Items { if record.Rdata != nil && *record.Rdata == `"`+info.Value+`"` { deleteHash = record.RecordHash break } } if deleteHash == nil { return errors.New("oraclecloud: no record to clean up") } recordOperation := dns.RecordOperation{ RecordHash: deleteHash, Operation: dns.RecordOperationOperationRemove, } patchRequest := dns.PatchDomainRecordsRequest{ ZoneNameOrId: common.String(zoneNameOrID), Domain: common.String(dns01.UnFqdn(info.EffectiveFQDN)), PatchDomainRecordsDetails: dns.PatchDomainRecordsDetails{ Items: []dns.RecordOperation{recordOperation}, }, CompartmentId: common.String(d.config.CompartmentID), } _, err = d.client.PatchDomainRecords(ctx, patchRequest) if err != nil { return fmt.Errorf("oraclecloud: %w", err) } return 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 } ================================================ FILE: providers/dns/oraclecloud/oraclecloud.toml ================================================ Name = "Oracle Cloud" Description = '''''' URL = "https://cloud.oracle.com/home" Code = "oraclecloud" Since = "v2.3.0" Example = ''' # Using API Key authentication: OCI_PRIVATE_KEY_PATH="~/.oci/oci_api_key.pem" \ OCI_PRIVATE_KEY_PASSWORD="secret" \ OCI_TENANCY_OCID="ocid1.tenancy.oc1..secret" \ OCI_USER_OCID="ocid1.user.oc1..secret" \ OCI_FINGERPRINT="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" \ OCI_REGION="us-phoenix-1" \ OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \ lego --dns oraclecloud -d '*.example.com' -d example.com run # Using Instance Principal authentication (when running on OCI compute instances): # https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm OCI_AUTH_TYPE="instance_principal" \ OCI_COMPARTMENT_OCID="ocid1.tenancy.oc1..secret" \ lego --dns oraclecloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] OCI_COMPARTMENT_OCID = "Compartment OCID" OCI_REGION = "Region (it can be empty if `OCI_AUTH_TYPE=instance_principal`)." OCI_PRIVATE_KEY_PATH = "Private key file (ignored if `OCI_AUTH_TYPE=instance_principal`)" OCI_PRIVATE_KEY_PASSWORD = "Private key password (ignored if `OCI_AUTH_TYPE=instance_principal`)" OCI_TENANCY_OCID = "Tenancy OCID (ignored if `OCI_AUTH_TYPE=instance_principal`)" OCI_USER_OCID = "User OCID (ignored if `OCI_AUTH_TYPE=instance_principal`)" OCI_FINGERPRINT = "Public key fingerprint (ignored if `OCI_AUTH_TYPE=instance_principal`)" [Configuration.Additional] OCI_AUTH_TYPE = "Authorization type. Possible values: 'instance_principal', '' (Default: '')" TF_VAR_region = "Alias on `OCI_REGION`" TF_VAR_fingerprint = "Alias on `OCI_FINGERPRINT`" TF_VAR_user_ocid = "Alias on `OCI_USER_OCID`" TF_VAR_tenancy_ocid = "Alias on `OCI_TENANCY_OCID`" TF_VAR_private_key_path = "Alias on `OCI_PRIVATE_KEY_PATH`" OCI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" OCI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" OCI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" OCI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 60)" [Links] API = "https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm" GoClient = "https://github.com/oracle/oci-go-sdk" ================================================ FILE: providers/dns/oraclecloud/oraclecloud_test.go ================================================ package oraclecloud import ( "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/pem" "maps" "net/http/httptest" "os" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/nrdcg/oci-go-sdk/common/v1065" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" // Used by Instance Principal authentication. const ( envMetadataBaseURL = "OCI_METADATA_BASE_URL" envSDKAuthClientRegionURL = "OCI_SDK_AUTH_CLIENT_REGION_URL" ) var envTest = tester.NewEnvTest( envPrivKey, EnvAuthType, envMetadataBaseURL, envSDKAuthClientRegionURL, EnvPrivKeyFile, EnvPrivKeyPass, EnvTenancyOCID, EnvUserOCID, EnvPubKeyFingerprint, EnvRegion, EnvCompartmentOCID). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ envPrivKey: mustGeneratePrivateKey("secret1"), EnvPrivKeyPass: "secret1", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "123", }, }, { desc: "success file", envVars: map[string]string{ EnvPrivKeyFile: mustGeneratePrivateKeyFile(t, "secret1"), EnvPrivKeyPass: "secret1", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "123", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "oraclecloud: some credentials information are missing: OCI_COMPARTMENT_OCID", }, { desc: "missing CompartmentID", envVars: map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), EnvPrivKeyPass: "secret", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "", }, expected: "oraclecloud: some credentials information are missing: OCI_COMPARTMENT_OCID", }, { desc: "missing OCI_PRIVKEY", envVars: map[string]string{ envPrivKey: "", EnvPrivKeyPass: "secret", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "123", }, expected: "oraclecloud: can not create client, bad configuration: no value provided for: OCI_PRIVKEY or OCI_PRIVATE_KEY or OCI_PRIVKEY_FILE or OCI_PRIVATE_KEY_PATH or TF_VAR_private_key_path", }, { desc: "missing OCI_PRIVKEY_PASS", envVars: map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), EnvPrivKeyPass: "", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "123", }, expected: "oraclecloud: can not create client, bad configuration: ", }, { desc: "missing OCI_TENANCY_OCID", envVars: map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), EnvPrivKeyPass: "secret", EnvTenancyOCID: "", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "123", }, expected: "oraclecloud: some credentials information are missing: OCI_TENANCY_OCID", }, { desc: "missing OCI_USER_OCID", envVars: map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), EnvPrivKeyPass: "secret", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "123", }, expected: "oraclecloud: some credentials information are missing: OCI_USER_OCID", }, { desc: "missing OCI_PUBKEY_FINGERPRINT", envVars: map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), EnvPrivKeyPass: "secret", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "", EnvRegion: "us-phoenix-1", EnvCompartmentOCID: "123", }, expected: "oraclecloud: some credentials information are missing: OCI_PUBKEY_FINGERPRINT", }, { desc: "missing OCI_REGION", envVars: map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), EnvPrivKeyPass: "secret", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "", EnvCompartmentOCID: "123", }, expected: "oraclecloud: some credentials information are missing: OCI_REGION", }, { desc: "missing OCI_REGION", envVars: map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), EnvPrivKeyPass: "secret", EnvTenancyOCID: "ocid1.tenancy.oc1..secret", EnvUserOCID: "ocid1.user.oc1..secret", EnvPubKeyFingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", EnvRegion: "", EnvCompartmentOCID: "123", }, expected: "oraclecloud: some credentials information are missing: OCI_REGION", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer func() { privKeyFile := os.Getenv(EnvPrivKeyFile) if privKeyFile != "" { _ = os.Remove(privKeyFile) } envTest.RestoreEnv() }() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.Error(t, err) require.Contains(t, err.Error(), test.expected) } }) } } func TestNewDNSProvider_instance_principal(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthType: "instance_principal", EnvCompartmentOCID: "123", }, }, { desc: "missing CompartmentID", envVars: map[string]string{ EnvAuthType: "instance_principal", }, expected: "oraclecloud: some credentials information are missing: OCI_COMPARTMENT_OCID", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer func() { envTest.RestoreEnv() }() envTest.ClearEnv() serverURL := servermock.NewBuilder( func(server *httptest.Server) (string, error) { return server.URL, nil }). Route("GET /instance/region", servermock.RawStringResponse("oc1")). // To generate fake certificates: // go run `go env GOROOT`/src/crypto/tls/generate_cert.go --host example.org --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h Route("GET /identity/cert.pem", servermock.ResponseFromFixture("cert.pem")). Route("GET /identity/key.pem", servermock.ResponseFromFixture("key.pem")). Route("GET /identity/intermediate.pem", servermock.ResponseFromFixture("cert.pem")). // https://github.com/oracle/oci-go-sdk/blob/413a2f277f95c5eb76e26a0e0833c396a518bf50/common/auth/jwt_test.go#L12 Route("POST /v1/x509", servermock.RawStringResponse(`{"token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFzdyIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJvcGMub3JhY2xlLmNvbSIsImV4cCI6MTUxMTgzODc5MywiaWF0IjoxNTExODE3MTkzLCJpc3MiOiJhdXRoU2VydmljZS5vcmFjbGUuY29tIiwib3BjLWNlcnR0eXBlIjoiaW5zdGFuY2UiLCJvcGMtY29tcGFydG1lbnQiOiJvY2lkMS5jb21wYXJ0bWVudC5vYzEuLmJsdWhibHVoYmx1aCIsIm9wYy1pbnN0YW5jZSI6Im9jaWQxLmluc3RhbmNlLm9jMS5waHguYmx1aGJsdWhibHVoIiwib3BjLXRlbmFudCI6Im9jaWR2MTp0ZW5hbmN5Om9jMTpwaHg6MTIzNDU2Nzg5MDpibHVoYmx1aGJsdWgiLCJwdHlwZSI6Imluc3RhbmNlIiwic3ViIjoib2NpZDEuaW5zdGFuY2Uub2MxLnBoeC5ibHVoYmx1aGJsdWgiLCJ0ZW5hbnQiOiJvY2lkdjE6dGVuYW5jeTpvYzE6cGh4OjEyMzQ1Njc4OTA6Ymx1aGJsdWhibHVoIiwidHR5cGUiOiJ4NTA5In0.zen7q2yJSpMjzH4ym_H7VEwZA0-vTT4Wcild-HRfLxX6A1ej4tlpACa7A24j5JoZYI4mHooZVJ8e7ZezFenK0zZx5j8RbIjsqJKwroYXExOiBXLCUwMWOLXIndEsUzzGLqnPfKHXd80vrhMLmtkVTCJqBMzvPUSYkH_ciWgmjP9m0YETdQ9ifghkADhZGt9IlnOswg0s3Bx9ASwxFZEtom0BmU9GwEuITTTZfKvndk785BlNeZMOjhovaD97-LYpv5B_PiWEz8zialK5zxjijLCw06zyA8CQRQqmVCagNUPilfz_BcPyImzvFDuzQcPyDkTcsB7weX35tafHmA_Ul"}`)). Build(t) envVars := map[string]string{ envMetadataBaseURL: serverURL, envSDKAuthClientRegionURL: serverURL, } maps.Copy(envVars, test.envVars) envTest.Apply(envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.Error(t, err) require.Contains(t, err.Error(), test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { envTest.ClearEnv() defer envTest.RestoreEnv() testCases := []struct { desc string compartmentID string configurationProvider common.ConfigurationProvider expected string }{ { desc: "configuration provider error", configurationProvider: mockConfigurationProvider("wrong-secret"), compartmentID: "123", expected: "oraclecloud: can not create client, bad configuration: x509: decryption password incorrect", }, { desc: "OCIConfigProvider is missing", compartmentID: "123", expected: "oraclecloud: OCIConfigProvider is missing", }, { desc: "missing CompartmentID", configurationProvider: mockConfigurationProvider("secret"), expected: "oraclecloud: CompartmentID is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.CompartmentID = test.compartmentID config.OCIConfigProvider = test.configurationProvider p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockConfigurationProvider(keyPassphrase string) *environmentConfigurationProvider { envTest.Apply(map[string]string{ envPrivKey: mustGeneratePrivateKey("secret"), }) return &environmentConfigurationProvider{ values: map[string]string{ EnvCompartmentOCID: "test", EnvPrivKeyPass: keyPassphrase, EnvTenancyOCID: "test", EnvUserOCID: "test", EnvPubKeyFingerprint: "test", EnvRegion: "test", }, } } func mustGeneratePrivateKey(pwd string) string { block, err := generatePrivateKey(pwd) if err != nil { panic(err) } return base64.StdEncoding.EncodeToString(pem.EncodeToMemory(block)) } func mustGeneratePrivateKeyFile(t *testing.T, pwd string) string { t.Helper() block, err := generatePrivateKey(pwd) require.NoError(t, err) file, err := os.CreateTemp(t.TempDir(), "lego_oci_*.pem") require.NoError(t, err) defer func() { _ = file.Close() }() err = pem.Encode(file, block) require.NoError(t, err) return file.Name() } func generatePrivateKey(pwd string) (*pem.Block, error) { key, err := rsa.GenerateKey(rand.Reader, 1024) if err != nil { return nil, err } block := &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), } if pwd != "" { block, err = x509.EncryptPEMBlock(rand.Reader, block.Type, block.Bytes, []byte(pwd), x509.PEMCipherAES256) if err != nil { return nil, err } } return block, nil } ================================================ FILE: providers/dns/otc/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) type Client struct { username string password string domainName string projectName string IdentityEndpoint string token string muToken sync.Mutex baseURL *url.URL muBaseURL sync.Mutex HTTPClient *http.Client } func NewClient(username, password, domainName, projectName string) *Client { return &Client{ username: username, password: password, domainName: domainName, projectName: projectName, IdentityEndpoint: DefaultIdentityEndpoint, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) GetZoneID(ctx context.Context, zone string, privateZone bool) (string, error) { zonesResp, err := c.getZones(ctx, zone, privateZone) if err != nil { return "", err } if len(zonesResp.Zones) < 1 { return "", fmt.Errorf("zone %s not found", zone) } for _, z := range zonesResp.Zones { if z.Name == zone { return z.ID, nil } } return "", fmt.Errorf("zone %s not found", zone) } // https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/public_zone_management/querying_public_zones.html func (c *Client) getZones(ctx context.Context, zone string, privateZone bool) (*ZonesResponse, error) { c.muBaseURL.Lock() endpoint := c.baseURL.JoinPath("zones") c.muBaseURL.Unlock() query := endpoint.Query() query.Set("name", zone) if privateZone { query.Set("type", "private") } endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var zones ZonesResponse err = c.do(req, &zones) if err != nil { return nil, err } return &zones, nil } func (c *Client) GetRecordSetID(ctx context.Context, zoneID, fqdn string) (string, error) { recordSetsRes, err := c.getRecordSet(ctx, zoneID, fqdn) if err != nil { return "", err } if len(recordSetsRes.RecordSets) < 1 { return "", errors.New("record not found") } if len(recordSetsRes.RecordSets) > 1 { return "", errors.New("to many records found") } if recordSetsRes.RecordSets[0].ID == "" { return "", errors.New("id not found") } return recordSetsRes.RecordSets[0].ID, nil } // https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/record_set_management/querying_all_record_sets.html func (c *Client) getRecordSet(ctx context.Context, zoneID, fqdn string) (*RecordSetsResponse, error) { c.muBaseURL.Lock() endpoint := c.baseURL.JoinPath("zones", zoneID, "recordsets") c.muBaseURL.Unlock() query := endpoint.Query() query.Set("type", "TXT") query.Set("name", fqdn) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var recordSetsRes RecordSetsResponse err = c.do(req, &recordSetsRes) if err != nil { return nil, err } return &recordSetsRes, nil } // CreateRecordSet creates a record. // https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/record_set_management/creating_a_record_set.html func (c *Client) CreateRecordSet(ctx context.Context, zoneID string, record RecordSets) error { c.muBaseURL.Lock() endpoint := c.baseURL.JoinPath("zones", zoneID, "recordsets") c.muBaseURL.Unlock() req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } // DeleteRecordSet delete a record set. // https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/record_set_management/deleting_a_record_set.html func (c *Client) DeleteRecordSet(ctx context.Context, zoneID, recordID string) error { c.muBaseURL.Lock() endpoint := c.baseURL.JoinPath("zones", zoneID, "recordsets", recordID) c.muBaseURL.Unlock() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { c.muToken.Lock() if c.token != "" { req.Header.Set("X-Auth-Token", c.token) } c.muToken.Unlock() resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusBadRequest { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("%s", endpoint), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/otc/internal/client_test.go ================================================ package internal import ( "context" "net/http/httptest" "net/url" "strconv" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret", "example.com", "test") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestClient_GetZoneID(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("zones_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com.")). Build(t) zoneID, err := client.GetZoneID(context.Background(), "example.com.", false) require.NoError(t, err) assert.Equal(t, "123123", zoneID) } func TestClient_GetZoneID_private(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("zones_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com."). With("type", "private")). Build(t) zoneID, err := client.GetZoneID(context.Background(), "example.com.", true) require.NoError(t, err) assert.Equal(t, "123123", zoneID) } func TestClient_GetZoneID_error(t *testing.T) { client := mockBuilder(). Route("GET /zones", servermock.ResponseFromFixture("zones_GET_empty.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com.")). Build(t) _, err := client.GetZoneID(context.Background(), "example.com.", false) require.EqualError(t, err, "zone example.com. not found") } func TestClient_GetRecordSetID(t *testing.T) { client := mockBuilder(). Route("GET /zones/123123/recordsets", servermock.ResponseFromFixture("zones-recordsets_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com."). With("type", "TXT"), ). Build(t) recordSetID, err := client.GetRecordSetID(context.Background(), "123123", "example.com.") require.NoError(t, err) assert.Equal(t, "321321", recordSetID) } func TestClient_GetRecordSetID_error(t *testing.T) { client := mockBuilder(). Route("GET /zones/123123/recordsets", servermock.ResponseFromFixture("zones-recordsets_GET_empty.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com."). With("type", "TXT"), ). Build(t) _, err := client.GetRecordSetID(context.Background(), "123123", "example.com.") require.EqualError(t, err, "record not found") } func TestClient_CreateRecordSet(t *testing.T) { client := mockBuilder(). Route("POST /zones/123123/recordsets", servermock.ResponseFromFixture("zones-recordsets_POST.json"), servermock.CheckRequestJSONBodyFromFixture("zones-recordsets_POST-request.json")). Build(t) rs := RecordSets{ Name: "_acme-challenge.example.com.", Description: "Added TXT record for ACME dns-01 challenge using lego client", Type: "TXT", TTL: 300, Records: []string{strconv.Quote("ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")}, } err := client.CreateRecordSet(context.Background(), "123123", rs) require.NoError(t, err) } func TestClient_DeleteRecordSet(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/123123/recordsets/321321", servermock.ResponseFromFixture("zones-recordsets_DELETE.json")). Build(t) err := client.DeleteRecordSet(context.Background(), "123123", "321321") require.NoError(t, err) } ================================================ FILE: providers/dns/otc/internal/fixtures/zones-recordsets_DELETE.json ================================================ { "id": "2c9eb155587228570158722b6ac30007", "name": "www.example.com.", "description": "This is an example record set.", "type": "A", "ttl": 300, "status": "PENDING_DELETE", "links": { "self": "https://Endpoint/v2/zones/2c9eb155587194ec01587224c9f90149/recordsets/2c9eb155587228570158722b6ac30007" }, "zone_id": "2c9eb155587194ec01587224c9f90149", "zone_name": "example.com.", "create_at": "2016-11-17T12:03:17.827", "update_at": "2016-11-17T12:56:03.827", "default": false, "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c" } ================================================ FILE: providers/dns/otc/internal/fixtures/zones-recordsets_GET.json ================================================ { "links": { "self": "https://Endpoint/v2/recordsets", "next": "https://Endpoint/v2/recordsets?id=&limit=11&marker=2c9eb155587194ec01587224c9f9014a" }, "recordsets": [ { "id": "321321", "name": "_acme-challenge.example.com", "type": "TXT", "ttl": 300, "records": [ "ns1.hotrot.de. xx.example.com. (1 7200 900 1209600 300)" ], "status": "ACTIVE", "links": { "self": "https://Endpoint/v2/zones/2c9eb155587194ec01587224c9f90149/recordsets/2c9eb155587194ec01587224c9f9014a" }, "zone_id": "2c9eb155587194ec01587224c9f90149", "zone_name": "example.com.", "create_at": "2016-11-17T11:56:03.439", "update_at": "2016-11-17T11:56:03.827", "default": true, "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c" } ], "metadata": { "total_count": 1 } } ================================================ FILE: providers/dns/otc/internal/fixtures/zones-recordsets_GET_empty.json ================================================ { "recordsets": [] } ================================================ FILE: providers/dns/otc/internal/fixtures/zones-recordsets_POST-request.json ================================================ { "name": "_acme-challenge.example.com.", "description": "Added TXT record for ACME dns-01 challenge using lego client", "type": "TXT", "ttl": 300, "records": [ "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"" ] } ================================================ FILE: providers/dns/otc/internal/fixtures/zones-recordsets_POST.json ================================================ { "id": "2c9eb155587228570158722b6ac30007", "name": "www.example.com.", "description": "This is an example record set.", "type": "A", "ttl": 300, "records": [ "192.168.10.1", "192.168.10.2" ], "status": "PENDING_CREATE", "links": { "self": "https://Endpoint/v2/zones/2c9eb155587194ec01587224c9f90149/recordsets/2c9eb155587228570158722b6ac30007" }, "zone_id": "2c9eb155587194ec01587224c9f90149", "zone_name": "example.com.", "create_at": "2016-11-17T12:03:17.827", "update_at": null, "default": false, "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c" } ================================================ FILE: providers/dns/otc/internal/fixtures/zones_GET.json ================================================ { "links": { "self": "https://Endpoint/v2/zones?type=public&limit=11", "next": "https://Endpoint/v2/zones?type=public&limit=11&marker=2c9eb155587194ec01587224c9f90149" }, "zones": [ { "id": "123123", "name": "example.com.", "description": "This is an example zone.", "email": "xx@example.com", "ttl": 300, "serial": 0, "masters": [], "status": "ACTIVE", "links": { "self": "https://Endpoint/v2/zones/2c9eb155587194ec01587224c9f90149" }, "pool_id": "00000000570e54ee01570e9939b20019", "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c", "zone_type": "public", "created_at": "2016-11-17T11:56:03.439", "updated_at": "2016-11-17T11:56:05.528", "record_num": 2 }, { "id": "2c9eb155587228570158722996c50001", "name": "example.org.", "description": "This is an example zone.", "email": "xx@example.org", "ttl": 300, "serial": 0, "masters": [], "status": "PENDING_CREATE", "links": { "self": "https://Endpoint/v2/zones/2c9eb155587228570158722996c50001" }, "pool_id": "00000000570e54ee01570e9939b20019", "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c", "zone_type": "public", "created_at": "2016-11-17T12:01:17.996", "updated_at": "2016-11-17T12:01:18.528", "record_num": 2 } ], "metadata": { "total_count": 2 } } ================================================ FILE: providers/dns/otc/internal/fixtures/zones_GET_empty.json ================================================ { "zones": [] } ================================================ FILE: providers/dns/otc/internal/identity.go ================================================ package internal import ( "context" "encoding/json" "errors" "io" "net/http" "net/url" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultIdentityEndpoint the default API identity endpoint. const DefaultIdentityEndpoint = "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens" // Login Starts a new OTC API Session. Authenticates using userName, password // and receives a token to be used in for subsequent requests. func (c *Client) Login(ctx context.Context) error { payload := LoginRequest{ Auth: Auth{ Identity: Identity{ Methods: []string{"password"}, Password: Password{ User: User{ Name: c.username, Password: c.password, Domain: Domain{ Name: c.domainName, }, }, }, }, Scope: Scope{ Project: Project{ Name: c.projectName, }, }, }, } tokenResp, token, err := c.obtainUserToken(ctx, payload) if err != nil { return err } c.muToken.Lock() defer c.muToken.Unlock() c.token = token if c.token == "" { return errors.New("unable to get auth token") } baseURL, err := getBaseURL(tokenResp) if err != nil { return err } c.muBaseURL.Lock() c.baseURL = baseURL c.muBaseURL.Unlock() return nil } // https://docs.otc.t-systems.com/identity-access-management/api-ref/apis/token_management/obtaining_a_user_token.html func (c *Client) obtainUserToken(ctx context.Context, payload LoginRequest) (*TokenResponse, string, error) { req, err := newJSONRequest(ctx, http.MethodPost, c.IdentityEndpoint, payload) if err != nil { return nil, "", err } client := &http.Client{Timeout: c.HTTPClient.Timeout} resp, err := client.Do(req) if err != nil { return nil, "", err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return nil, "", errutils.NewUnexpectedResponseStatusCodeError(req, resp) } token := resp.Header.Get("X-Subject-Token") if token == "" { return nil, "", errors.New("unable to get auth token") } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, "", errutils.NewReadResponseError(req, resp.StatusCode, err) } var newToken TokenResponse err = json.Unmarshal(raw, &newToken) if err != nil { return nil, "", errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &newToken, token, nil } func getBaseURL(tokenResp *TokenResponse) (*url.URL, error) { var endpoints []Endpoint for _, v := range tokenResp.Token.Catalog { if v.Type == "dns" { endpoints = append(endpoints, v.Endpoints...) } } if len(endpoints) == 0 { return nil, errors.New("unable to get dns endpoint") } baseURL, err := url.JoinPath(endpoints[0].URL, "v2") if err != nil { return nil, err } return url.Parse(baseURL) } ================================================ FILE: providers/dns/otc/internal/identity_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClient_Login(t *testing.T) { var serverURL *url.URL client := servermock.NewBuilder( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret", "example.com", "test") client.HTTPClient = server.Client() client.IdentityEndpoint = server.URL + "/v3/auth/token" serverURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ). Route("POST /v3/auth/token", IdentityHandlerMock()). Build(t) err := client.Login(t.Context()) require.NoError(t, err) assert.Equal(t, serverURL.JoinPath("v2").String(), client.baseURL.String()) assert.Equal(t, fakeOTCToken, client.token) } ================================================ FILE: providers/dns/otc/internal/mock.go ================================================ package internal import ( "fmt" "net/http" ) const fakeOTCToken = "62244bc21da68d03ebac94e6636ff01f" func IdentityHandlerMock() http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { w.Header().Set("X-Subject-Token", fakeOTCToken) _, _ = fmt.Fprintf(w, `{ "token": { "catalog": [ { "type": "dns", "id": "56cd81db1f8445d98652479afe07c5ba", "name": "", "endpoints": [ { "url": "http://%s", "region": "eu-de", "region_id": "eu-de", "interface": "public", "id": "0047a06690484d86afe04877074efddf" } ] } ] }}`, req.Context().Value(http.LocalAddrContextKey)) } } ================================================ FILE: providers/dns/otc/internal/types.go ================================================ package internal // LoginRequest type LoginRequest struct { Auth Auth `json:"auth"` } type Auth struct { Identity Identity `json:"identity"` Scope Scope `json:"scope"` } type Identity struct { Methods []string `json:"methods"` Password Password `json:"password"` } type Password struct { User User `json:"user"` } type User struct { Name string `json:"name"` Password string `json:"password"` Domain Domain `json:"domain"` } type Scope struct { Project Project `json:"project"` } type Project struct { Name string `json:"name"` } // TokenResponse type TokenResponse struct { Token Token `json:"token"` } type Token struct { User UserR `json:"user"` Domain Domain `json:"domain"` Catalog []Catalog `json:"catalog,omitempty"` Methods []string `json:"methods,omitempty"` Roles []Role `json:"roles,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` IssuedAt string `json:"issued_at,omitempty"` } type Catalog struct { ID string `json:"id,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Endpoints []Endpoint `json:"endpoints,omitempty"` } type UserR struct { ID string `json:"id,omitempty"` Domain Domain `json:"domain"` Name string `json:"name,omitempty"` PasswordExpiresAt string `json:"password_expires_at,omitempty"` } type Endpoint struct { ID string `json:"id,omitempty"` URL string `json:"url,omitempty"` Region string `json:"region,omitempty"` RegionID string `json:"region_id,omitempty"` Interface string `json:"interface,omitempty"` } type Role struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` } // RecordSetsResponse type RecordSetsResponse struct { Links Links `json:"links"` RecordSets []RecordSets `json:"recordsets"` Metadata Metadata `json:"metadata"` } type RecordSets struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Type string `json:"type,omitempty"` TTL int `json:"ttl,omitempty"` Records []string `json:"records,omitempty"` Status string `json:"status,omitempty"` Links *Links `json:"links,omitempty"` ZoneID string `json:"zone_id,omitempty"` ZoneName string `json:"zone_name,omitempty"` CreateAt string `json:"create_at,omitempty"` UpdateAt string `json:"update_at,omitempty"` Default bool `json:"default,omitempty"` ProjectID string `json:"project_id,omitempty"` } // ZonesResponse type ZonesResponse struct { Links Links `json:"links"` Zones []Zone `json:"zones"` Metadata Metadata `json:"metadata"` } type Zone struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Email string `json:"email,omitempty"` TTL int `json:"ttl,omitempty"` Serial int `json:"serial,omitempty"` Status string `json:"status,omitempty"` Links *Links `json:"links,omitempty"` PoolID string `json:"pool_id,omitempty"` ProjectID string `json:"project_id,omitempty"` ZoneType string `json:"zone_type,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` RecordNum int `json:"record_num,omitempty"` } // Response type Links struct { Self string `json:"self,omitempty"` Next string `json:"next,omitempty"` } type Metadata struct { TotalCount int `json:"total_count,omitempty"` } // Shared type Domain struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` } ================================================ FILE: providers/dns/otc/otc.go ================================================ // Package otc implements a DNS provider for solving the DNS-01 challenge using Open Telekom Cloud Managed DNS. package otc import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/otc/internal" ) // Environment variables names. const ( envNamespace = "OTC_" EnvDomainName = envNamespace + "DOMAIN_NAME" EnvUserName = envNamespace + "USER_NAME" EnvPassword = envNamespace + "PASSWORD" EnvProjectName = envNamespace + "PROJECT_NAME" EnvIdentityEndpoint = envNamespace + "IDENTITY_ENDPOINT" EnvPrivateZone = envNamespace + "PRIVATE_ZONE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) const defaultIdentityEndpoint = "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens" // minTTL 300 is otc minimum value for TTL. const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { DomainName string ProjectName string UserName string Password string IdentityEndpoint string PrivateZone bool PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { tr := &http.Transport{} defaultTransport, ok := http.DefaultTransport.(*http.Transport) if ok { tr = defaultTransport.Clone() } // Workaround for keep alive bug in otc api tr.DisableKeepAlives = true return &Config{ PrivateZone: env.GetOrDefaultBool(EnvPrivateZone, false), IdentityEndpoint: env.GetOrDefaultString(EnvIdentityEndpoint, defaultIdentityEndpoint), TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), Transport: tr, }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for OTC DNS. // Credentials must be passed in the environment variables: OTC_USER_NAME, // OTC_DOMAIN_NAME, OTC_PASSWORD OTC_PROJECT_NAME and OTC_IDENTITY_ENDPOINT. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvDomainName, EnvUserName, EnvPassword, EnvProjectName) if err != nil { return nil, fmt.Errorf("otc: %w", err) } config := NewDefaultConfig() config.DomainName = values[EnvDomainName] config.UserName = values[EnvUserName] config.Password = values[EnvPassword] config.ProjectName = values[EnvProjectName] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for OTC DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("otc: the configuration of the DNS provider is nil") } if config.DomainName == "" || config.UserName == "" || config.Password == "" || config.ProjectName == "" { return nil, errors.New("otc: credentials missing") } if config.TTL < minTTL { return nil, fmt.Errorf("otc: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.UserName, config.Password, config.DomainName, config.ProjectName) if config.IdentityEndpoint != "" { client.IdentityEndpoint = config.IdentityEndpoint } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("otc: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() err = d.client.Login(ctx) if err != nil { return fmt.Errorf("otc: %w", err) } zoneID, err := d.client.GetZoneID(ctx, authZone, d.config.PrivateZone) if err != nil { return fmt.Errorf("otc: unable to get zone: %w", err) } record := internal.RecordSets{ Name: info.EffectiveFQDN, Description: "Added TXT record for ACME dns-01 challenge using lego client", Type: "TXT", TTL: d.config.TTL, Records: []string{fmt.Sprintf("%q", info.Value)}, } err = d.client.CreateRecordSet(ctx, zoneID, record) if err != nil { return fmt.Errorf("otc: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("otc: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() err = d.client.Login(ctx) if err != nil { return fmt.Errorf("otc: %w", err) } zoneID, err := d.client.GetZoneID(ctx, authZone, d.config.PrivateZone) if err != nil { return fmt.Errorf("otc: %w", err) } recordID, err := d.client.GetRecordSetID(ctx, zoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("otc: unable to get record %s for zone %s: %w", info.EffectiveFQDN, domain, err) } err = d.client.DeleteRecordSet(ctx, zoneID, recordID) if err != nil { return fmt.Errorf("otc: %w", err) } return 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/otc/otc.toml ================================================ Name = "Open Telekom Cloud" Description = '''''' URL = "https://cloud.telekom.de/en" Code = "otc" Since = "v0.4.1" Example = ''' OTC_DOMAIN_NAME=domain_name \ OTC_USER_NAME=user_name \ OTC_PASSWORD=password \ OTC_PROJECT_NAME=project_name \ lego --dns otc -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] OTC_USER_NAME = "User name" OTC_PASSWORD = "Password" OTC_PROJECT_NAME = "Project name" OTC_DOMAIN_NAME = "Domain name" [Configuration.Additional] OTC_IDENTITY_ENDPOINT = "Identity endpoint URL (default: https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens)" OTC_PRIVATE_ZONE = "Set to true to use private zones only (default: use public zones only)" OTC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" OTC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" OTC_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" OTC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" OTC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://docs.otc.t-systems.com/domain-name-service/api-ref/index.html" ================================================ FILE: providers/dns/otc/otc_test.go ================================================ package otc import ( "fmt" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/otc/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvDomainName, EnvUserName, EnvPassword, EnvPrivateZone, EnvProjectName, EnvIdentityEndpoint). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvDomainName: "example.com", EnvUserName: "user", EnvPassword: "secret", EnvProjectName: "test", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvDomainName: "", EnvUserName: "", EnvPassword: "", EnvProjectName: "", }, expected: "otc: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME", }, { desc: "missing domain name", envVars: map[string]string{ EnvDomainName: "", EnvUserName: "user", EnvPassword: "secret", EnvProjectName: "test", }, expected: "otc: some credentials information are missing: OTC_DOMAIN_NAME", }, { desc: "missing username", envVars: map[string]string{ EnvDomainName: "example.com", EnvUserName: "", EnvPassword: "secret", EnvProjectName: "test", }, expected: "otc: some credentials information are missing: OTC_USER_NAME", }, { desc: "missing password", envVars: map[string]string{ EnvDomainName: "example.com", EnvUserName: "user", EnvPassword: "", EnvProjectName: "test", }, expected: "otc: some credentials information are missing: OTC_PASSWORD", }, { desc: "missing project name", envVars: map[string]string{ EnvDomainName: "example.com", EnvUserName: "user", EnvPassword: "secret", EnvProjectName: "", }, expected: "otc: some credentials information are missing: OTC_PROJECT_NAME", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string domainName string projectName string username string password string expected string }{ { desc: "success", domainName: "example.com", projectName: "test", username: "user", password: "secret", }, { desc: "missing credentials", expected: "otc: credentials missing", }, { desc: "missing domain name", domainName: "", projectName: "test", username: "user", password: "secret", expected: "otc: credentials missing", }, { desc: "missing project name", domainName: "example.com", projectName: "", username: "user", password: "secret", expected: "otc: credentials missing", }, { desc: "missing username", domainName: "example.com", projectName: "test", username: "", password: "secret", expected: "otc: credentials missing", }, { desc: "missing password ", domainName: "example.com", projectName: "test", username: "user", password: "", expected: "otc: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.DomainName = test.domainName config.ProjectName = test.projectName config.UserName = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(false). Route("GET /v2/zones", servermock.ResponseFromInternal("zones_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com.")). Route("POST /v2/zones/123123/recordsets", servermock.Noop(), servermock.CheckRequestJSONBodyFromInternal("zones-recordsets_POST-request.json")). Build(t) err := provider.Present("example.com", "", "123d==") require.NoError(t, err) } func TestDNSProvider_Present_private(t *testing.T) { provider := mockBuilder(true). Route("GET /v2/zones", servermock.ResponseFromInternal("zones_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com."). With("type", "private")). Route("POST /v2/zones/123123/recordsets", servermock.Noop(), servermock.CheckRequestJSONBodyFromInternal("zones-recordsets_POST-request.json")). Build(t) err := provider.Present("example.com", "", "123d==") require.NoError(t, err) } func TestDNSProvider_Present_emptyZone(t *testing.T) { provider := mockBuilder(false). Route("GET /v2/zones", servermock.ResponseFromInternal("zones_GET_empty.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com.")). Build(t) err := provider.Present("example.com", "", "123d==") require.EqualError(t, err, "otc: unable to get zone: zone example.com. not found") } func TestDNSProvider_Cleanup(t *testing.T) { provider := mockBuilder(false). Route("GET /v2/zones", servermock.ResponseFromInternal("zones_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com.")). Route("GET /v2/zones/123123/recordsets", servermock.ResponseFromInternal("zones-recordsets_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge.example.com."). With("type", "TXT")). Route("DELETE /v2/zones/123123/recordsets/321321", servermock.ResponseFromInternal("zones-recordsets_DELETE.json")). Build(t) err := provider.CleanUp("example.com", "", "123d==") require.NoError(t, err) } func TestDNSProvider_Cleanup_private(t *testing.T) { provider := mockBuilder(true). Route("GET /v2/zones", servermock.ResponseFromInternal("zones_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com."). With("type", "private")). Route("GET /v2/zones/123123/recordsets", servermock.ResponseFromInternal("zones-recordsets_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge.example.com."). With("type", "TXT")). Route("DELETE /v2/zones/123123/recordsets/321321", servermock.ResponseFromInternal("zones-recordsets_DELETE.json")). Build(t) err := provider.CleanUp("example.com", "", "123d==") require.NoError(t, err) } func TestDNSProvider_Cleanup_emptyRecordset(t *testing.T) { provider := mockBuilder(false). Route("GET /v2/zones", servermock.ResponseFromInternal("zones_GET.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com.")). Route("GET /v2/zones/123123/recordsets", servermock.ResponseFromInternal("zones-recordsets_GET_empty.json"), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge.example.com."). With("type", "TXT")). Build(t) err := provider.CleanUp("example.com", "", "123d==") require.EqualError(t, err, "otc: unable to get record _acme-challenge.example.com. for zone example.com: record not found") } func mockBuilder(private bool) *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.UserName = "user" config.Password = "secret" config.DomainName = "example.com" config.ProjectName = "test" config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", server.URL) config.PrivateZone = private return NewDNSProviderConfig(config) }, servermock.CheckHeader().WithJSONHeaders(), ). Route("POST /v3/auth/token", internal.IdentityHandlerMock()) } ================================================ FILE: providers/dns/ovh/ovh.go ================================================ // Package ovh implements a DNS provider for solving the DNS-01 challenge using OVH DNS. package ovh import ( "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/ovh/go-ovh/ovh" ) // OVH API reference: https://eu.api.ovh.com/ // Create a Token: https://eu.api.ovh.com/createToken/ // Create a OAuth2 client: https://eu.api.ovh.com/console/?section=%2Fme&branch=v1#post-/me/api/oauth2/client // Environment variables names. const ( envNamespace = "OVH_" EnvEndpoint = envNamespace + "ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Authenticate using application key. const ( EnvApplicationKey = envNamespace + "APPLICATION_KEY" EnvApplicationSecret = envNamespace + "APPLICATION_SECRET" EnvConsumerKey = envNamespace + "CONSUMER_KEY" ) // Authenticate using OAuth2 client. const ( EnvClientID = envNamespace + "CLIENT_ID" EnvClientSecret = envNamespace + "CLIENT_SECRET" ) // EnvAccessToken Authenticate using Access Token client. const EnvAccessToken = envNamespace + "ACCESS_TOKEN" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Record a DNS record. type Record struct { ID int64 `json:"id,omitempty"` FieldType string `json:"fieldType,omitempty"` SubDomain string `json:"subDomain,omitempty"` Target string `json:"target,omitempty"` TTL int `json:"ttl,omitempty"` Zone string `json:"zone,omitempty"` } // OAuth2Config the OAuth2 specific configuration. type OAuth2Config struct { ClientID string ClientSecret string } // Config is used to configure the creation of the DNSProvider. type Config struct { APIEndpoint string ApplicationKey string ApplicationSecret string ConsumerKey string OAuth2Config *OAuth2Config AccessToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, ovh.DefaultTimeout), }, } } func (c *Config) hasAppKeyAuth() bool { return c.ApplicationKey != "" || c.ApplicationSecret != "" || c.ConsumerKey != "" } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *ovh.Client recordIDs map[string]int64 recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for OVH // Credentials must be passed in the environment variables: // OVH_ENDPOINT (must be either "ovh-eu" or "ovh-ca"), OVH_APPLICATION_KEY, OVH_APPLICATION_SECRET, OVH_CONSUMER_KEY. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() // https://github.com/ovh/go-ovh/blob/6817886d12a8c5650794b28da635af9fcdfd1162/ovh/configuration.go#L105 config.APIEndpoint = env.GetOrDefaultString(EnvEndpoint, "ovh-eu") config.ApplicationKey = env.GetOrFile(EnvApplicationKey) config.ApplicationSecret = env.GetOrFile(EnvApplicationSecret) config.ConsumerKey = env.GetOrFile(EnvConsumerKey) config.AccessToken = env.GetOrFile(EnvAccessToken) clientID := env.GetOrFile(EnvClientID) clientSecret := env.GetOrFile(EnvClientSecret) if clientID != "" || clientSecret != "" { config.OAuth2Config = &OAuth2Config{ ClientID: clientID, ClientSecret: clientSecret, } } return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for OVH. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ovh: the configuration of the DNS provider is nil") } if config.OAuth2Config != nil && config.hasAppKeyAuth() && config.AccessToken != "" { return nil, errors.New("ovh: can't use multiple authentication systems (ApplicationKey, OAuth2, Access Token)") } if config.OAuth2Config != nil && config.AccessToken != "" { return nil, errors.New("ovh: can't use multiple authentication systems (OAuth2, Access Token)") } if config.OAuth2Config != nil && config.hasAppKeyAuth() { return nil, errors.New("ovh: can't use multiple authentication systems (ApplicationKey, OAuth2)") } if config.hasAppKeyAuth() && config.AccessToken != "" { return nil, errors.New("ovh: can't use multiple authentication systems (ApplicationKey, Access Token)") } client, err := newClient(config) if err != nil { return nil, fmt.Errorf("ovh: %w", err) } return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int64), }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ovh: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("ovh: %w", err) } reqURL := fmt.Sprintf("/domain/zone/%s/record", authZone) reqData := Record{FieldType: "TXT", SubDomain: subDomain, Target: info.Value, TTL: d.config.TTL} // Create TXT record var respData Record err = d.client.Post(reqURL, reqData, &respData) if err != nil { return fmt.Errorf("ovh: error when call api to add record (%s): %w", reqURL, err) } // Apply the change reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone) err = d.client.Post(reqURL, nil, nil) if err != nil { return fmt.Errorf("ovh: error when call api to refresh zone (%s): %w", reqURL, err) } d.recordIDsMu.Lock() d.recordIDs[token] = respData.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // get the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("ovh: unknown record ID for '%s'", info.EffectiveFQDN) } authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ovh: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) reqURL := fmt.Sprintf("/domain/zone/%s/record/%d", authZone, recordID) err = d.client.Delete(reqURL, nil) if err != nil { return fmt.Errorf("ovh: error when call OVH api to delete challenge record (%s): %w", reqURL, err) } // Apply the change reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone) err = d.client.Post(reqURL, nil, nil) if err != nil { return fmt.Errorf("ovh: error when call api to refresh zone (%s): %w", reqURL, err) } // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } func newClient(config *Config) (*ovh.Client, error) { var ( client *ovh.Client err error ) switch { case config.hasAppKeyAuth(): client, err = ovh.NewClient(config.APIEndpoint, config.ApplicationKey, config.ApplicationSecret, config.ConsumerKey) case config.OAuth2Config != nil: client, err = ovh.NewOAuth2Client(config.APIEndpoint, config.OAuth2Config.ClientID, config.OAuth2Config.ClientSecret) case config.AccessToken != "": client, err = ovh.NewAccessTokenClient(config.APIEndpoint, config.AccessToken) default: client, err = ovh.NewDefaultClient() } if err != nil { return nil, fmt.Errorf("new client: %w", err) } client.UserAgent = useragent.Get() if config.HTTPClient != nil { client.Client = config.HTTPClient } client.Client = clientdebug.Wrap(client.Client) return client, nil } ================================================ FILE: providers/dns/ovh/ovh.toml ================================================ Name = "OVH" Description = '''''' URL = "https://www.ovh.com/" Code = "ovh" Since = "v0.4.0" Example = ''' # Application Key authentication: OVH_APPLICATION_KEY=1234567898765432 \ OVH_APPLICATION_SECRET=b9841238feb177a84330febba8a832089 \ OVH_CONSUMER_KEY=256vfsd347245sdfg \ OVH_ENDPOINT=ovh-eu \ lego --dns ovh -d '*.example.com' -d example.com run # Or Access Token: OVH_ACCESS_TOKEN=xxx \ OVH_ENDPOINT=ovh-eu \ lego --dns ovh -d '*.example.com' -d example.com run # Or OAuth2: OVH_CLIENT_ID=yyy \ OVH_CLIENT_SECRET=xxx \ OVH_ENDPOINT=ovh-eu \ lego --dns ovh -d '*.example.com' -d example.com run ''' Additional = ''' ## Application Key and Secret Application key and secret can be created by following the [OVH guide](https://docs.ovh.com/gb/en/customer/first-steps-with-ovh-api/). When requesting the consumer key, the following configuration can be used to define access rights: ```json { "accessRules": [ { "method": "POST", "path": "/domain/zone/*" }, { "method": "DELETE", "path": "/domain/zone/*" } ] } ``` ## OAuth2 Client Credentials Another method for authentication is by using OAuth2 client credentials. An IAM policy and service account can be created by following the [OVH guide](https://help.ovhcloud.com/csm/en-manage-service-account?id=kb_article_view&sysparm_article=KB0059343). Following IAM policies need to be authorized for the affected domain: * dnsZone:apiovh:record/create * dnsZone:apiovh:record/delete * dnsZone:apiovh:refresh ## Important Note Both authentication methods cannot be used at the same time. ''' [Configuration] [Configuration.Credentials] OVH_ENDPOINT = "Endpoint URL (ovh-eu or ovh-ca)" OVH_APPLICATION_KEY = "Application key (Application Key authentication)" OVH_APPLICATION_SECRET = "Application secret (Application Key authentication)" OVH_CONSUMER_KEY = "Consumer key (Application Key authentication)" OVH_CLIENT_ID = "Client ID (OAuth2)" OVH_CLIENT_SECRET = "Client secret (OAuth2)" OVH_ACCESS_TOKEN = "Access token" [Configuration.Additional] OVH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" OVH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" OVH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" OVH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 180)" [Links] API = "https://eu.api.ovh.com/" GoClient = "https://github.com/ovh/go-ovh" ================================================ FILE: providers/dns/ovh/ovh_test.go ================================================ package ovh import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvEndpoint, EnvApplicationKey, EnvApplicationSecret, EnvConsumerKey, EnvClientID, EnvClientSecret, EnvAccessToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "application key: success", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "B", EnvApplicationSecret: "C", EnvConsumerKey: "D", }, }, { desc: "application key: missing invalid endpoint", envVars: map[string]string{ EnvEndpoint: "foobar", EnvApplicationKey: "B", EnvApplicationSecret: "C", EnvConsumerKey: "D", }, expected: "ovh: new client: unknown endpoint 'foobar', consider checking 'Endpoints' list or using an URL", }, { desc: "application key: missing application key", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "", EnvApplicationSecret: "C", EnvConsumerKey: "D", }, expected: "ovh: new client: invalid authentication config, both application_key and application_secret must be given", }, { desc: "application key: missing application secret", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "B", EnvApplicationSecret: "", EnvConsumerKey: "D", }, expected: "ovh: new client: invalid authentication config, both application_key and application_secret must be given", }, { desc: "oauth2: success", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvClientID: "E", EnvClientSecret: "F", }, }, { desc: "access token: success", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvAccessToken: "G", }, }, { desc: "oauth2: missing client secret", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvClientID: "E", EnvClientSecret: "", }, expected: "ovh: new client: invalid oauth2 config, both client_id and client_secret must be given", }, { desc: "oauth2: missing client ID", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvClientID: "", EnvClientSecret: "F", }, expected: "ovh: new client: invalid oauth2 config, both client_id and client_secret must be given", }, { desc: "missing credentials", envVars: map[string]string{ EnvEndpoint: "", EnvApplicationKey: "", EnvApplicationSecret: "", EnvConsumerKey: "", EnvClientID: "", EnvClientSecret: "", EnvAccessToken: "", }, expected: "ovh: new client: missing authentication information, you need to provide one of the following: application_key/application_secret, client_id/client_secret, or access_token", }, { desc: "mixed auth (all)", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "B", EnvApplicationSecret: "C", EnvConsumerKey: "D", EnvClientID: "E", EnvClientSecret: "F", EnvAccessToken: "G", }, expected: "ovh: can't use multiple authentication systems (ApplicationKey, OAuth2, Access Token)", }, { desc: "mixed auth (ApplicationKey, OAuth2)", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "B", EnvApplicationSecret: "C", EnvConsumerKey: "D", EnvClientID: "E", EnvClientSecret: "F", }, expected: "ovh: can't use multiple authentication systems (ApplicationKey, OAuth2)", }, { desc: "mixed auth (ApplicationKey, Access Token)", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvApplicationKey: "B", EnvApplicationSecret: "C", EnvConsumerKey: "D", EnvAccessToken: "G", }, expected: "ovh: can't use multiple authentication systems (ApplicationKey, Access Token)", }, { desc: "mixed auth (OAuth2, Access Token)", envVars: map[string]string{ EnvEndpoint: "ovh-eu", EnvClientID: "E", EnvClientSecret: "F", EnvAccessToken: "G", }, expected: "ovh: can't use multiple authentication systems (OAuth2, Access Token)", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiEndpoint string applicationKey string applicationSecret string consumerKey string clientID string clientSecret string accessToken string expected string }{ { desc: "application key: success", apiEndpoint: "ovh-eu", applicationKey: "B", applicationSecret: "C", consumerKey: "D", }, { desc: "application key: default api endpoint", apiEndpoint: "", applicationKey: "B", applicationSecret: "C", consumerKey: "D", }, { desc: "application key: invalid api endpoint", apiEndpoint: "foobar", applicationKey: "B", applicationSecret: "C", consumerKey: "D", expected: "ovh: new client: unknown endpoint 'foobar', consider checking 'Endpoints' list or using an URL", }, { desc: "application key: missing application key", apiEndpoint: "ovh-eu", applicationKey: "", applicationSecret: "C", consumerKey: "D", expected: "ovh: new client: invalid authentication config, both application_key and application_secret must be given", }, { desc: "application key: missing application secret", apiEndpoint: "ovh-eu", applicationKey: "B", applicationSecret: "", consumerKey: "D", expected: "ovh: new client: invalid authentication config, both application_key and application_secret must be given", }, { desc: "oauth2: success", apiEndpoint: "ovh-eu", clientID: "B", clientSecret: "C", }, { desc: "oauth2: default api endpoint", apiEndpoint: "", clientID: "B", clientSecret: "C", }, { desc: "oauth2: invalid api endpoint", apiEndpoint: "foobar", clientID: "B", clientSecret: "C", expected: "ovh: new client: unknown endpoint 'foobar', consider checking 'Endpoints' list or using an URL", }, { desc: "oauth2: missing client id", apiEndpoint: "ovh-eu", clientID: "", clientSecret: "C", expected: "ovh: new client: invalid oauth2 config, both client_id and client_secret must be given", }, { desc: "oauth2: missing client secret", apiEndpoint: "ovh-eu", clientID: "B", clientSecret: "", expected: "ovh: new client: invalid oauth2 config, both client_id and client_secret must be given", }, { desc: "access token: success", apiEndpoint: "ovh-eu", accessToken: "G", }, { desc: "missing credentials", expected: "ovh: new client: missing authentication information, you need to provide one of the following: application_key/application_secret, client_id/client_secret, or access_token", }, { desc: "mixed auth (all)", apiEndpoint: "ovh-eu", applicationKey: "B", applicationSecret: "C", consumerKey: "D", clientID: "B", clientSecret: "C", accessToken: "G", expected: "ovh: can't use multiple authentication systems (ApplicationKey, OAuth2, Access Token)", }, { desc: "mixed auth (ApplicationKey, OAuth2)", apiEndpoint: "ovh-eu", applicationKey: "B", applicationSecret: "C", consumerKey: "D", clientID: "B", clientSecret: "C", expected: "ovh: can't use multiple authentication systems (ApplicationKey, OAuth2)", }, { desc: "mixed auth (ApplicationKey, Access Token)", apiEndpoint: "ovh-eu", applicationKey: "B", applicationSecret: "C", consumerKey: "D", accessToken: "G", expected: "ovh: can't use multiple authentication systems (ApplicationKey, Access Token)", }, { desc: "mixed auth (OAuth2, Access Token)", apiEndpoint: "ovh-eu", clientID: "B", clientSecret: "C", accessToken: "G", expected: "ovh: can't use multiple authentication systems (OAuth2, Access Token)", }, } // The OVH client use the same env vars than lego, so it requires to clean them. defer envTest.RestoreEnv() envTest.ClearEnv() for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIEndpoint = test.apiEndpoint config.ApplicationKey = test.applicationKey config.ApplicationSecret = test.applicationSecret config.ConsumerKey = test.consumerKey config.AccessToken = test.accessToken if test.clientID != "" || test.clientSecret != "" { config.OAuth2Config = &OAuth2Config{ ClientID: test.clientID, ClientSecret: test.clientSecret, } } p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/pdns/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "path" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/miekg/dns" ) // APIKeyHeader API key header. const APIKeyHeader = "X-Api-Key" // Client the PowerDNS API client. type Client struct { serverName string apiKey string apiVersion int Host *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(host *url.URL, serverName string, apiVersion int, apiKey string) *Client { return &Client{ serverName: serverName, apiKey: apiKey, apiVersion: apiVersion, Host: host, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) APIVersion() int { return c.apiVersion } func (c *Client) SetAPIVersion(ctx context.Context) error { var err error c.apiVersion, err = c.getAPIVersion(ctx) return err } func (c *Client) getAPIVersion(ctx context.Context) (int, error) { endpoint := c.joinPath("/", "api") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return 0, err } result, err := c.do(req) if err != nil { return 0, err } var versions []apiVersion err = json.Unmarshal(result, &versions) if err != nil { return 0, err } latestVersion := 0 for _, v := range versions { if v.Version > latestVersion { latestVersion = v.Version } } return latestVersion, err } func (c *Client) GetHostedZone(ctx context.Context, authZone string) (*HostedZone, error) { endpoint := c.joinPath("/", "servers", c.serverName, "zones", dns.Fqdn(authZone)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result, err := c.do(req) if err != nil { return nil, err } var zone HostedZone err = json.Unmarshal(result, &zone) if err != nil { return nil, err } // convert pre-v1 API result if len(zone.Records) > 0 { zone.RRSets = []RRSet{} for _, record := range zone.Records { set := RRSet{ Name: record.Name, Type: record.Type, Records: []Record{record}, } zone.RRSets = append(zone.RRSets, set) } } return &zone, nil } func (c *Client) UpdateRecords(ctx context.Context, zone *HostedZone, sets RRSets) error { endpoint := c.joinPath("/", "servers", c.serverName, "zones", zone.ID) req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, sets) if err != nil { return err } _, err = c.do(req) if err != nil { return err } return nil } func (c *Client) Notify(ctx context.Context, zone *HostedZone) error { if c.apiVersion < 1 || zone.Kind != "Master" && zone.Kind != "Slave" { return nil } endpoint := c.joinPath("/", "servers", c.serverName, "zones", zone.ID, "notify") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, nil) if err != nil { return err } _, err = c.do(req) if err != nil { return err } return nil } func (c *Client) joinPath(elem ...string) *url.URL { p := path.Join(elem...) if p != "/api" && c.apiVersion > 0 && !strings.HasPrefix(p, "/api/v") { p = path.Join("/api", "v"+strconv.Itoa(c.apiVersion), p) } return c.Host.JoinPath(p) } func (c *Client) do(req *http.Request) (json.RawMessage, error) { req.Header.Set(APIKeyHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusUnprocessableEntity && (resp.StatusCode < 200 || resp.StatusCode >= 300) { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } var msg json.RawMessage err = json.NewDecoder(resp.Body).Decode(&msg) if err != nil { if errors.Is(err, io.EOF) { // empty body return nil, nil } // other error return nil, err } // check for PowerDNS error message if len(msg) > 0 && msg[0] == '{' { var errInfo apiError err = json.Unmarshal(msg, &errInfo) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, msg, err) } if errInfo.ShortMsg != "" { return nil, fmt.Errorf("error talking to PDNS API: %w", errInfo) } } return msg, nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(endpoint.String(), "/"), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") // PowerDNS doesn't follow HTTP convention about the "Content-Type" header. if method != http.MethodGet && method != http.MethodDelete { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/pdns/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { serverURL, _ := url.Parse(server.URL) client := NewClient(serverURL, "server", 0, "secret") client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders().With(APIKeyHeader, "secret")) } func TestClient_joinPath(t *testing.T) { testCases := []struct { desc string apiVersion int baseURL string uri string expected string }{ { desc: "host with path", apiVersion: 1, baseURL: "https://example.com/test", uri: "/foo", expected: "https://example.com/test/api/v1/foo", }, { desc: "host with path + trailing slash", apiVersion: 1, baseURL: "https://example.com/test/", uri: "/foo", expected: "https://example.com/test/api/v1/foo", }, { desc: "no URI", apiVersion: 1, baseURL: "https://example.com/test", uri: "", expected: "https://example.com/test/api/v1", }, { desc: "host without path", apiVersion: 1, baseURL: "https://example.com", uri: "/foo", expected: "https://example.com/api/v1/foo", }, { desc: "api", apiVersion: 1, baseURL: "https://example.com", uri: "/api", expected: "https://example.com/api", }, { desc: "API version 0, host with path", apiVersion: 0, baseURL: "https://example.com/test", uri: "/foo", expected: "https://example.com/test/foo", }, { desc: "API version 0, host with path + trailing slash", apiVersion: 0, baseURL: "https://example.com/test/", uri: "/foo", expected: "https://example.com/test/foo", }, { desc: "API version 0, no URI", apiVersion: 0, baseURL: "https://example.com/test", uri: "", expected: "https://example.com/test", }, { desc: "API version 0, host without path", apiVersion: 0, baseURL: "https://example.com", uri: "/foo", expected: "https://example.com/foo", }, { desc: "API version 0, api", apiVersion: 0, baseURL: "https://example.com", uri: "/api", expected: "https://example.com/api", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() host, err := url.Parse(test.baseURL) require.NoError(t, err) client := NewClient(host, "test", test.apiVersion, "secret") endpoint := client.joinPath(test.uri) assert.Equal(t, test.expected, endpoint.String()) }) } } func TestClient_GetHostedZone(t *testing.T) { client := mockBuilder(). Route("GET /api/v1/servers/server/zones/example.org.", servermock.ResponseFromFixture("zone.json")). Build(t) client.apiVersion = 1 zone, err := client.GetHostedZone(t.Context(), "example.org.") require.NoError(t, err) expected := &HostedZone{ ID: "example.org.", Name: "example.org.", URL: "api/v1/servers/localhost/zones/example.org.", Kind: "Master", RRSets: []RRSet{ { Name: "example.org.", Type: "NS", Records: []Record{{Content: "ns2.example.org."}, {Content: "ns1.example.org."}}, TTL: 86400, }, { Name: "example.org.", Type: "SOA", Records: []Record{{Content: "ns1.example.org. hostmaster.example.org. 2015120401 10800 15 604800 10800"}}, TTL: 86400, }, { Name: "ns1.example.org.", Type: "A", Records: []Record{{Content: "192.168.0.1"}}, TTL: 86400, }, { Name: "www.example.org.", Type: "A", Records: []Record{{Content: "192.168.0.2"}}, TTL: 86400, }, }, } assert.Equal(t, expected, zone) } func TestClient_GetHostedZone_error(t *testing.T) { client := mockBuilder(). Route("GET /api/v1/servers/server/zones/example.org.", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnprocessableEntity)). Build(t) client.apiVersion = 1 _, err := client.GetHostedZone(t.Context(), "example.org.") require.ErrorAs(t, err, &apiError{}) } func TestClient_GetHostedZone_v0(t *testing.T) { client := mockBuilder(). Route("GET /servers/server/zones/example.org.", servermock.ResponseFromFixture("zone.json")). Build(t) client.apiVersion = 0 zone, err := client.GetHostedZone(t.Context(), "example.org.") require.NoError(t, err) expected := &HostedZone{ ID: "example.org.", Name: "example.org.", URL: "api/v1/servers/localhost/zones/example.org.", Kind: "Master", RRSets: []RRSet{ { Name: "example.org.", Type: "NS", Records: []Record{{Content: "ns2.example.org."}, {Content: "ns1.example.org."}}, TTL: 86400, }, { Name: "example.org.", Type: "SOA", Records: []Record{{Content: "ns1.example.org. hostmaster.example.org. 2015120401 10800 15 604800 10800"}}, TTL: 86400, }, { Name: "ns1.example.org.", Type: "A", Records: []Record{{Content: "192.168.0.1"}}, TTL: 86400, }, { Name: "www.example.org.", Type: "A", Records: []Record{{Content: "192.168.0.2"}}, TTL: 86400, }, }, } assert.Equal(t, expected, zone) } func TestClient_UpdateRecords(t *testing.T) { client := mockBuilder(). Route("PATCH /api/v1/servers/localhost/zones/example.org.", servermock.ResponseFromFixture("zone.json"), servermock.CheckRequestJSONBodyFromFixture("zone-request.json")). Build(t) client.apiVersion = 1 client.serverName = "localhost" zone := &HostedZone{ ID: "example.org.", Name: "example.org.", URL: "api/v1/servers/localhost/zones/example.org.", Kind: "Master", } rrSets := RRSets{ RRSets: []RRSet{{ Name: "example.org.", Type: "NS", ChangeType: "REPLACE", Records: []Record{{ Content: "192.0.2.5", Name: "ns1.example.org.", TTL: 86400, Type: "A", }}, }}, } err := client.UpdateRecords(t.Context(), zone, rrSets) require.NoError(t, err) } func TestClient_UpdateRecords_NonRootApi(t *testing.T) { client := mockBuilder(). Route("PATCH /some/path/api/v1/servers/localhost/zones/example.org.", servermock.ResponseFromFixture("zone.json"), servermock.CheckRequestJSONBodyFromFixture("zone-request.json")). Build(t) client.Host = client.Host.JoinPath("some", "path") client.apiVersion = 1 client.serverName = "localhost" zone := &HostedZone{ ID: "example.org.", Name: "example.org.", URL: "some/path/api/v1/servers/server/zones/example.org.", Kind: "Master", } rrSets := RRSets{ RRSets: []RRSet{{ Name: "example.org.", Type: "NS", ChangeType: "REPLACE", Records: []Record{{ Content: "192.0.2.5", Name: "ns1.example.org.", TTL: 86400, Type: "A", }}, }}, } err := client.UpdateRecords(t.Context(), zone, rrSets) require.NoError(t, err) } func TestClient_UpdateRecords_v0(t *testing.T) { client := mockBuilder(). Route("PATCH /servers/localhost/zones/example.org.", servermock.ResponseFromFixture("zone.json"), servermock.CheckRequestJSONBodyFromFixture("zone-request.json")). Build(t) client.apiVersion = 0 client.serverName = "localhost" zone := &HostedZone{ ID: "example.org.", Name: "example.org.", URL: "servers/localhost/zones/example.org.", Kind: "Master", } rrSets := RRSets{ RRSets: []RRSet{{ Name: "example.org.", Type: "NS", ChangeType: "REPLACE", Records: []Record{{ Content: "192.0.2.5", Name: "ns1.example.org.", TTL: 86400, Type: "A", }}, }}, } err := client.UpdateRecords(t.Context(), zone, rrSets) require.NoError(t, err) } func TestClient_Notify(t *testing.T) { client := mockBuilder(). Route("PUT /api/v1/servers/localhost/zones/example.org./notify", nil). Build(t) client.apiVersion = 1 client.serverName = "localhost" zone := &HostedZone{ ID: "example.org.", Name: "example.org.", URL: "api/v1/servers/localhost/zones/example.org.", Kind: "Master", } err := client.Notify(t.Context(), zone) require.NoError(t, err) } func TestClient_Notify_NonRootApi(t *testing.T) { client := mockBuilder(). Route("PUT /some/path/api/v1/servers/localhost/zones/example.org./notify", nil). Build(t) client.Host = client.Host.JoinPath("some", "path") client.apiVersion = 1 client.serverName = "localhost" zone := &HostedZone{ ID: "example.org.", Name: "example.org.", URL: "/some/path/api/v1/servers/server/zones/example.org.", Kind: "Master", } err := client.Notify(t.Context(), zone) require.NoError(t, err) } func TestClient_Notify_v0(t *testing.T) { client := mockBuilder(). Route("PUT /some/path/api/v1/servers/localhost/zones/example.org./notify", nil). Build(t) client.apiVersion = 0 zone := &HostedZone{ ID: "example.org.", Name: "example.org.", URL: "servers/localhost/zones/example.org.", Kind: "Master", } err := client.Notify(t.Context(), zone) require.NoError(t, err) } func TestClient_getAPIVersion(t *testing.T) { client := mockBuilder(). Route("GET /api", servermock.ResponseFromFixture("versions.json")). Build(t) version, err := client.getAPIVersion(t.Context()) require.NoError(t, err) assert.Equal(t, 4, version) } ================================================ FILE: providers/dns/pdns/internal/fixtures/error.json ================================================ { "error": "A human readable error message" } ================================================ FILE: providers/dns/pdns/internal/fixtures/versions.json ================================================ [ { "url": "/fooa", "version": 0 }, { "url": "/foob", "version": 4 }, { "url": "/fooc", "version": 2 }, { "url": "/food", "version": 1 } ] ================================================ FILE: providers/dns/pdns/internal/fixtures/zone-request.json ================================================ { "rrsets": [ { "name": "example.org.", "type": "NS", "kind": "", "changetype": "REPLACE", "records": [ { "content": "192.0.2.5", "disabled": false, "name": "ns1.example.org.", "type": "A", "ttl": 86400 } ] } ] } ================================================ FILE: providers/dns/pdns/internal/fixtures/zone.json ================================================ { "id": "example.org.", "url": "api/v1/servers/localhost/zones/example.org.", "name": "example.org.", "kind": "Master", "dnssec": false, "account": "", "masters": [], "serial": 2015120401, "notified_serial": 0, "last_check": 0, "soa_edit_api": "", "soa_edit": "", "rrsets": [ { "comments": [], "name": "example.org.", "records": [ { "content": "ns2.example.org.", "disabled": false }, { "content": "ns1.example.org.", "disabled": false } ], "ttl": 86400, "type": "NS" }, { "comments": [], "name": "example.org.", "type": "SOA", "ttl": 86400, "records": [ { "disabled": false, "content": "ns1.example.org. hostmaster.example.org. 2015120401 10800 15 604800 10800" } ] }, { "comments": [], "name": "ns1.example.org.", "type": "A", "ttl": 86400, "records": [ { "content": "192.168.0.1", "disabled": false } ] }, { "comments": [], "name": "www.example.org.", "type": "A", "ttl": 86400, "records": [ { "disabled": false, "content": "192.168.0.2" } ] } ] } ================================================ FILE: providers/dns/pdns/internal/types.go ================================================ package internal type Record struct { Content string `json:"content"` Disabled bool `json:"disabled"` // pre-v1 API Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl,omitempty"` } type HostedZone struct { ID string `json:"id"` Name string `json:"name"` URL string `json:"url"` Kind string `json:"kind"` RRSets []RRSet `json:"rrsets"` // pre-v1 API Records []Record `json:"records"` } type RRSet struct { Name string `json:"name"` Type string `json:"type"` Kind string `json:"kind"` ChangeType string `json:"changetype"` Records []Record `json:"records,omitempty"` TTL int `json:"ttl,omitempty"` } type RRSets struct { RRSets []RRSet `json:"rrsets"` } type apiError struct { ShortMsg string `json:"error"` } func (a apiError) Error() string { return a.ShortMsg } type apiVersion struct { URL string `json:"url"` Version int `json:"version"` } ================================================ FILE: providers/dns/pdns/pdns.go ================================================ // Package pdns implements a DNS provider for solving the DNS-01 challenge using PowerDNS nameserver. package pdns import ( "context" "errors" "fmt" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/pdns/internal" ) // Environment variables names. const ( envNamespace = "PDNS_" EnvAPIKey = envNamespace + "API_KEY" EnvAPIURL = envNamespace + "API_URL" EnvTTL = envNamespace + "TTL" EnvAPIVersion = envNamespace + "API_VERSION" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvServerName = envNamespace + "SERVER_NAME" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string Host *url.URL ServerName string APIVersion int PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ ServerName: env.GetOrDefaultString(EnvServerName, "localhost"), APIVersion: env.GetOrDefaultInt(EnvAPIVersion, 0), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for pdns. // Credentials must be passed in the environment variable: // PDNS_API_URL and PDNS_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvAPIURL) if err != nil { return nil, fmt.Errorf("pdns: %w", err) } hostURL, err := url.Parse(values[EnvAPIURL]) if err != nil { return nil, fmt.Errorf("pdns: %w", err) } config := NewDefaultConfig() config.Host = hostURL config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for pdns. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("pdns: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("pdns: API key missing") } if config.Host == nil || config.Host.Host == "" { return nil, errors.New("pdns: API URL missing") } client := internal.NewClient(config.Host, config.ServerName, config.APIVersion, config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) if config.APIVersion <= 0 { err := client.SetAPIVersion(context.Background()) if err != nil { log.Warnf("pdns: failed to get API version %v", err) } } return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err) } zone, err := d.client.GetHostedZone(ctx, authZone) if err != nil { return fmt.Errorf("pdns: get hosted zone for %s: %w", authZone, err) } name := info.EffectiveFQDN if d.client.APIVersion() == 0 { // pre-v1 API wants non-fqdn name = dns01.UnFqdn(info.EffectiveFQDN) } // Look for existing records. existingRRSet := findTxtRecord(zone, info.EffectiveFQDN) var records []internal.Record if existingRRSet != nil { records = existingRRSet.Records } records = append(records, internal.Record{ Content: strconv.Quote(info.Value), Disabled: false, // pre-v1 API Type: "TXT", Name: name, TTL: d.config.TTL, }) rrSets := internal.RRSets{ RRSets: []internal.RRSet{{ Name: name, ChangeType: "REPLACE", Type: "TXT", Kind: "Master", TTL: d.config.TTL, Records: records, }}, } err = d.client.UpdateRecords(ctx, zone, rrSets) if err != nil { return fmt.Errorf("pdns: update records: %w", err) } err = d.client.Notify(ctx, zone) if err != nil { return fmt.Errorf("pdns: notify: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("pdns: could not find zone for domain %q: %w", domain, err) } zone, err := d.client.GetHostedZone(ctx, authZone) if err != nil { return fmt.Errorf("pdns: get hosted zone for %s: %w", authZone, err) } // Look for existing records. set := findTxtRecord(zone, info.EffectiveFQDN) if set == nil { return fmt.Errorf("pdns: no existing record found for %s", info.EffectiveFQDN) } var records []internal.Record for _, r := range set.Records { if r.Content != strconv.Quote(info.Value) { records = append(records, r) } } rrSet := internal.RRSet{ Name: set.Name, Type: set.Type, } if len(records) > 0 { rrSet.ChangeType = "REPLACE" rrSet.TTL = d.config.TTL rrSet.Records = records } else { rrSet.ChangeType = "DELETE" } err = d.client.UpdateRecords(ctx, zone, internal.RRSets{RRSets: []internal.RRSet{rrSet}}) if err != nil { return fmt.Errorf("pdns: update records: %w", err) } err = d.client.Notify(ctx, zone) if err != nil { return fmt.Errorf("pdns: notify: %w", err) } return nil } func findTxtRecord(zone *internal.HostedZone, fqdn string) *internal.RRSet { for _, set := range zone.RRSets { if set.Type == "TXT" && (set.Name == dns01.UnFqdn(fqdn) || set.Name == fqdn) { return &set } } return nil } ================================================ FILE: providers/dns/pdns/pdns.toml ================================================ Name = "PowerDNS" Description = '''''' URL = "https://www.powerdns.com/" Code = "pdns" Since = "v0.4.0" Example = ''' PDNS_API_URL=http://pdns-server:80/ \ PDNS_API_KEY=xxxx \ lego --dns pdns -d '*.example.com' -d example.com run ''' Additional = ''' ## Information Tested and confirmed to work with PowerDNS authoritative server 3.4.8 and 4.0.1. Refer to [PowerDNS documentation](https://doc.powerdns.com/md/httpapi/README/) instructions on how to enable the built-in API interface. PowerDNS Notes: - PowerDNS API does not currently support SSL, therefore you should take care to ensure that traffic between lego and the PowerDNS API is over a trusted network, VPN etc. - In order to have the SOA serial automatically increment each time the `_acme-challenge` record is added/modified via the API, set `SOA-EDIT-API` to `INCEPTION-INCREMENT` for the zone in the `domainmetadata` table - Some PowerDNS servers doesn't have root API endpoints enabled and API version autodetection will not work. In that case version number can be defined using `PDNS_API_VERSION`. ''' [Configuration] [Configuration.Credentials] PDNS_API_KEY = "API key" PDNS_API_URL = "API URL" [Configuration.Additional] PDNS_SERVER_NAME = "Name of the server in the URL, 'localhost' by default" PDNS_API_VERSION = "Skip API version autodetection and use the provided version number." PDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" PDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" PDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" PDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://doc.powerdns.com/md/httpapi/README/" ================================================ FILE: providers/dns/pdns/pdns_test.go ================================================ package pdns import ( "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIURL, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", EnvAPIURL: "http://example.com", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", EnvAPIURL: "", }, expected: "pdns: some credentials information are missing: PDNS_API_KEY,PDNS_API_URL", }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", EnvAPIURL: "http://example.com", }, expected: "pdns: some credentials information are missing: PDNS_API_KEY", }, { desc: "missing API URL", envVars: map[string]string{ EnvAPIKey: "123", EnvAPIURL: "", }, expected: "pdns: some credentials information are missing: PDNS_API_URL", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string customAPIVersion int host *url.URL expected string }{ { desc: "success", apiKey: "123", host: mustParse("http://example.com"), }, { desc: "success custom API version", apiKey: "123", customAPIVersion: 1, host: mustParse("http://example.com"), }, { desc: "missing credentials", expected: "pdns: API key missing", }, { desc: "missing API key", apiKey: "", host: mustParse("http://example.com"), expected: "pdns: API key missing", }, { desc: "missing host", apiKey: "123", expected: "pdns: API URL missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Host = test.host config.APIVersion = test.customAPIVersion p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresentAndCleanup(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) err = provider.Present(envTest.GetDomain(), "", "123e==") require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123e==") require.NoError(t, err) } func mustParse(rawURL string) *url.URL { u, err := url.Parse(rawURL) if err != nil { panic(err) } return u } ================================================ FILE: providers/dns/plesk/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // Client the Plesk API client. type Client struct { login string password string baseURL *url.URL HTTPClient *http.Client } // NewClient created a new Client. func NewClient(baseURL *url.URL, login, password string) *Client { return &Client{ login: login, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // GetSite gets a site. // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-sites-domains/getting-information-about-sites.66583/ func (c *Client) GetSite(ctx context.Context, domain string) (int, error) { payload := RequestPacketType{Site: &SiteTypeRequest{Get: SiteGetRequest{Filter: &SiteFilterType{ Name: domain, }}}} response, err := c.doRequest(ctx, payload) if err != nil { return 0, err } if response.System != nil { return 0, response.System } if response == nil || response.Site.Get.Result == nil { return 0, errors.New("unexpected empty result") } if response.Site.Get.Result.Status != StatusOK { return 0, response.Site.Get.Result } return response.Site.Get.Result.ID, nil } // AddRecord adds a TXT record. // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-dns/managing-dns-records/adding-dns-record.34798/ func (c *Client) AddRecord(ctx context.Context, siteID int, host, value string) (int, error) { payload := RequestPacketType{DNS: &DNSInputType{AddRec: []AddRecRequest{{ SiteID: siteID, Type: "TXT", Host: host, Value: value, }}}} response, err := c.doRequest(ctx, payload) if err != nil { return 0, err } if response.System != nil { return 0, response.System } if len(response.DNS.AddRec) < 1 { return 0, errors.New("unexpected empty result") } if response.DNS.AddRec[0].Result.Status != StatusOK { return 0, response.DNS.AddRec[0].Result } return response.DNS.AddRec[0].Result.ID, nil } // DeleteRecord Deletes a TXT record. // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-dns/managing-dns-records/deleting-dns-records.34864/ func (c *Client) DeleteRecord(ctx context.Context, recordID int) (int, error) { payload := RequestPacketType{DNS: &DNSInputType{DelRec: []DelRecRequest{{Filter: DNSSelectionFilterType{ ID: recordID, }}}}} response, err := c.doRequest(ctx, payload) if err != nil { return 0, err } if response.System != nil { return 0, response.System } if len(response.DNS.DelRec) < 1 { return 0, errors.New("unexpected empty result") } if response.DNS.DelRec[0].Result.Status != StatusOK { return 0, response.DNS.DelRec[0].Result } return response.DNS.DelRec[0].Result.ID, nil } func (c *Client) doRequest(ctx context.Context, payload RequestPacketType) (*ResponsePacketType, error) { endpoint := c.baseURL.JoinPath("/enterprise/control/agent.php") body := new(bytes.Buffer) err := xml.NewEncoder(body).Encode(payload) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), body) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Content-Type", "text/xml") req.Header.Set("Http_auth_login", c.login) req.Header.Set("Http_auth_passwd", c.password) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var response ResponsePacketType err = xml.Unmarshal(raw, &response) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &response, nil } ================================================ FILE: providers/dns/plesk/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { serverURL, _ := url.Parse(server.URL) client := NewClient(serverURL, "user", "secret") client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithContentType("text/xml"). With("Http_auth_login", "user"). With("Http_auth_passwd", "secret"), ) } func TestClient_GetSite(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("get-site.xml")). Build(t) siteID, err := client.GetSite(t.Context(), "example.com") require.NoError(t, err) assert.Equal(t, 82, siteID) } func TestClient_GetSite_error(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("get-site-error.xml")). Build(t) siteID, err := client.GetSite(t.Context(), "example.com") require.Error(t, err) assert.Equal(t, 0, siteID) } func TestClient_GetSite_system_error(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). Build(t) siteID, err := client.GetSite(t.Context(), "example.com") require.Error(t, err) assert.Equal(t, 0, siteID) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("add-record.xml")). Build(t) recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.NoError(t, err) assert.Equal(t, 4537, recordID) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("add-record-error.xml")). Build(t) recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.ErrorAs(t, err, new(RecResult)) assert.Equal(t, 0, recordID) } func TestClient_AddRecord_system_error(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). Build(t) recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.ErrorAs(t, err, new(*System)) assert.Equal(t, 0, recordID) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("delete-record.xml")). Build(t) recordID, err := client.DeleteRecord(t.Context(), 4537) require.NoError(t, err) assert.Equal(t, 4537, recordID) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("delete-record-error.xml")). Build(t) recordID, err := client.DeleteRecord(t.Context(), 4537) require.ErrorAs(t, err, new(RecResult)) assert.Equal(t, 0, recordID) } func TestClient_DeleteRecord_system_error(t *testing.T) { client := mockBuilder(). Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). Build(t) recordID, err := client.DeleteRecord(t.Context(), 4537) require.ErrorAs(t, err, new(*System)) assert.Equal(t, 0, recordID) } ================================================ FILE: providers/dns/plesk/internal/fixtures/add-record-error.xml ================================================ error 1015 Domain does not exist. ================================================ FILE: providers/dns/plesk/internal/fixtures/add-record.xml ================================================ ok 4537 ================================================ FILE: providers/dns/plesk/internal/fixtures/delete-record-error.xml ================================================ error 1013 Record does not exist 453899 ================================================ FILE: providers/dns/plesk/internal/fixtures/delete-record.xml ================================================ ok 4537 ================================================ FILE: providers/dns/plesk/internal/fixtures/get-site-error.xml ================================================ error 1013 Site does not exist bollox.com ================================================ FILE: providers/dns/plesk/internal/fixtures/get-site.xml ================================================ ok example.com 82 2022-12-31 example.com example.com 0 2717782016 217.28.1.1 vrt_hst e9114a63-e626-4977-ac15-a8e608750a33 e9114a63-e626-4977-ac15-a8e608750a33 82 ================================================ FILE: providers/dns/plesk/internal/fixtures/global-error.xml ================================================ error 1001 You have entered incorrect username or password. ================================================ FILE: providers/dns/plesk/internal/types.go ================================================ package internal import ( "encoding/xml" "fmt" ) // Response status. const ( StatusOK = "ok" StatusError = "error" ) // Request. type RequestPacketType struct { XMLName xml.Name `xml:"packet"` Text string `xml:",chardata"` DNS *DNSInputType `xml:"dns,omitempty"` Site *SiteTypeRequest `xml:"site,omitempty"` } type DNSInputType struct { Text string `xml:",chardata"` AddRec []AddRecRequest `xml:"add_rec,omitempty"` DelRec []DelRecRequest `xml:"del_rec,omitempty"` } type AddRecRequest struct { Text string `xml:",chardata"` SiteID int `xml:"site-id,omitempty"` Type string `xml:"type,omitempty"` Host string `xml:"host,omitempty"` Value string `xml:"value,omitempty"` } type DelRecRequest struct { Text string `xml:",chardata"` Filter DNSSelectionFilterType `xml:"filter"` } type DNSSelectionFilterType struct { Text string `xml:",chardata"` ID int `xml:"id"` } type SiteTypeRequest struct { Text string `xml:",chardata"` Get SiteGetRequest `xml:"get"` } type SiteGetRequest struct { Text string `xml:",chardata"` Filter *SiteFilterType `xml:"filter,omitempty"` Dataset SiteDatasetType `xml:"dataset,omitempty"` } type SiteFilterType struct { Text string `xml:",chardata"` Name string `xml:"name"` } type SiteDatasetType struct { Text string `xml:",chardata"` GenInfo *SiteGenInfoType `xml:"gen_info,omitempty"` } type SiteGenInfoType struct { Text string `xml:",chardata"` CrDate string `xml:"cr_date,omitempty"` Name string `xml:"name,omitempty"` ASCIIName string `xml:"ascii-name,omitempty"` Status string `xml:"status,omitempty"` RealSize string `xml:"real_size,omitempty"` DNSIPAddress string `xml:"dns_ip_address,omitempty"` HType string `xml:"htype,omitempty"` GUID string `xml:"guid,omitempty"` WebspaceGUID string `xml:"webspace-guid,omitempty"` SbSiteUUID string `xml:"sb-site-uuid,omitempty"` WebspaceID string `xml:"webspace-id,omitempty"` Description string `xml:"description,omitempty"` } // Response. type ResponsePacketType struct { XMLName xml.Name `xml:"packet"` Text string `xml:",chardata"` DNS DNSResponseType `xml:"dns,omitempty"` Site SiteResponseType `xml:"site,omitempty"` System *System `xml:"system,omitempty"` } type System struct { Text string `xml:",chardata"` Status string `xml:"status"` ErrCode string `xml:"errcode"` ErrText string `xml:"errtext"` } func (s System) Error() string { return fmt.Sprintf("%s: %s - %s", s.Status, s.ErrCode, s.ErrText) } type DNSResponseType struct { Text string `xml:",chardata"` AddRec []AddRecResponse `xml:"add_rec,omitempty"` DelRec []DelRecResponse `xml:"del_rec,omitempty"` } type AddRecResponse struct { Text string `xml:",chardata"` Result RecResult `xml:"result,omitempty"` } type DelRecResponse struct { Text string `xml:",chardata"` Result RecResult `xml:"result"` } type RecResult struct { Text string `xml:",chardata"` ID int `xml:"id"` Status string `xml:"status"` ErrCode string `xml:"errcode"` ErrText string `xml:"errtext"` } func (r RecResult) Error() string { return fmt.Sprintf("%s: %s - %s", r.Status, r.ErrCode, r.ErrText) } type SiteResponseType struct { Text string `xml:",chardata"` Get SiteGetResponse `xml:"get"` } type SiteGetResponse struct { Text string `xml:",chardata"` Result *SiteResult `xml:"result,omitempty"` } type SiteResult struct { Text string `xml:",chardata"` ID int `xml:"id"` FilterID string `xml:"filter-id"` Status string `xml:"status"` ErrCode string `xml:"errcode"` ErrText string `xml:"errtext"` Data *SiteResultData `xml:"data"` } func (s SiteResult) Error() string { return fmt.Sprintf("%s: %s - %s", s.Status, s.ErrCode, s.ErrText) } type SiteResultData struct { Text string `xml:",chardata"` GenInfo *SiteGenInfoType `xml:"gen_info"` } ================================================ FILE: providers/dns/plesk/plesk.go ================================================ // Package plesk implements a DNS provider for solving the DNS-01 challenge using Plesk DNS. package plesk import ( "context" "errors" "fmt" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/plesk/internal" ) // Environment variables names. const ( envNamespace = "PLESK_" EnvServerBaseURL = envNamespace + "SERVER_BASE_URL" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { baseURL string Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Plesk. // Credentials must be passed in the environment variables: // PLESK_USERNAME and PLESK_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvServerBaseURL, EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("plesk: %w", err) } config := NewDefaultConfig() config.baseURL = values[EnvServerBaseURL] config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Plesk. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("plesk: the configuration of the DNS provider is nil") } if config.baseURL == "" { return nil, errors.New("plesk: missing server base URL") } baseURL, err := url.Parse(config.baseURL) if err != nil { return nil, fmt.Errorf("plesk: failed to parse base URL (%s): %w", config.baseURL, err) } if config.Username == "" || config.Password == "" { return nil, errors.New("plesk: incomplete credentials, missing username and/or password") } client := internal.NewClient(baseURL, config.Username, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: map[string]int{}, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("plesk: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() siteID, err := d.client.GetSite(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("plesk: failed to get site: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("nodion: %w", err) } recordID, err := d.client.AddRecord(ctx, siteID, subDomain, info.Value) if err != nil { return fmt.Errorf("plesk: failed to add record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("plesk: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } _, err := d.client.DeleteRecord(context.Background(), recordID) if err != nil { return fmt.Errorf("plesk: failed to delete record (%d): %w", recordID, err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/plesk/plesk.toml ================================================ Name = "plesk.com" Description = '''''' URL = "https://www.plesk.com/" Code = "plesk" Since = "v4.11.0" Example = ''' PLESK_SERVER_BASE_URL="https://plesk.myserver.com:8443" \ PLESK_USERNAME=xxxxxx \ PLESK_PASSWORD=yyyyyy \ lego --dns plesk -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] PLESK_SERVER_BASE_URL = "Base URL of the server (ex: https://plesk.myserver.com:8443)" PLESK_USERNAME = "API username" PLESK_PASSWORD = "API password" [Configuration.Additional] PLESK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" PLESK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" PLESK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" PLESK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference.28784/" ================================================ FILE: providers/dns/plesk/plesk_test.go ================================================ package plesk import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvServerBaseURL, EnvUsername, EnvPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvServerBaseURL: "https//example.com", EnvUsername: "user", EnvPassword: "secret", }, }, { desc: "missing server base URL", envVars: map[string]string{ EnvServerBaseURL: "", EnvUsername: "user", EnvPassword: "secret", }, expected: "plesk: some credentials information are missing: PLESK_SERVER_BASE_URL", }, { desc: "missing username", envVars: map[string]string{ EnvServerBaseURL: "https//example.com", EnvUsername: "", EnvPassword: "secret", }, expected: "plesk: some credentials information are missing: PLESK_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvServerBaseURL: "https//example.com", EnvUsername: "user", EnvPassword: "", }, expected: "plesk: some credentials information are missing: PLESK_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "plesk: some credentials information are missing: PLESK_SERVER_BASE_URL,PLESK_USERNAME,PLESK_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string baseURL string username string password string expected string }{ { desc: "success", baseURL: "https://example.com", username: "user", password: "secret", }, { desc: "missing base URL", username: "user", password: "secret", expected: "plesk: missing server base URL", }, { desc: "missing username", baseURL: "https://example.com", password: "secret", expected: "plesk: incomplete credentials, missing username and/or password", }, { desc: "missing password", baseURL: "https://example.com", username: "user", expected: "plesk: incomplete credentials, missing username and/or password", }, { desc: "missing credential", baseURL: "https://example.com", expected: "plesk: incomplete credentials, missing username and/or password", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.baseURL = test.baseURL config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/porkbun/porkbun.go ================================================ // Package porkbun implements a DNS provider for solving the DNS-01 challenge using Porkbun. package porkbun import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/porkbun" ) // Environment variables names. const ( envNamespace = "PORKBUN_" EnvSecretAPIKey = envNamespace + "SECRET_API_KEY" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string SecretAPIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), TTL: env.GetOrDefaultInt(EnvTTL, minTTL), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *porkbun.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Porkbun. // Credentials must be passed in the environment variables: // PORKBUN_SECRET_API_KEY, PORKBUN_PAPI_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvSecretAPIKey, EnvAPIKey) if err != nil { return nil, fmt.Errorf("porkbun: %w", err) } config := NewDefaultConfig() config.SecretAPIKey = values[EnvSecretAPIKey] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Porkbun. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("porkbun: the configuration of the DNS provider is nil") } if config.SecretAPIKey == "" || config.APIKey == "" { return nil, errors.New("porkbun: some credentials information are missing") } if config.TTL < minTTL { return nil, fmt.Errorf("porkbun: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := porkbun.New(config.SecretAPIKey, config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), }, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) zoneName, hostName, err := splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("porkbun: %w", err) } record := porkbun.Record{ Name: hostName, Type: "TXT", Content: info.Value, TTL: strconv.Itoa(d.config.TTL), } ctx := context.Background() recordID, err := d.client.CreateRecord(ctx, dns01.UnFqdn(zoneName), record) if err != nil { return fmt.Errorf("porkbun: failed to create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("porkbun: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } zoneName, _, err := splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("porkbun: %w", err) } ctx := context.Background() err = d.client.DeleteRecord(ctx, dns01.UnFqdn(zoneName), recordID) if err != nil { return fmt.Errorf("porkbun: failed to delete record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } // splitDomain splits the hostname from the authoritative zone, and returns both parts. func splitDomain(fqdn string) (string, string, error) { zone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", "", fmt.Errorf("could not find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, zone) if err != nil { return "", "", err } return zone, subDomain, nil } ================================================ FILE: providers/dns/porkbun/porkbun.toml ================================================ Name = "Porkbun" Description = '''''' # This URL is NOT the API URL. URL = "https://porkbun.com/" Code = "porkbun" Since = "v4.4.0" Example = ''' PORKBUN_SECRET_API_KEY=xxxxxx \ PORKBUN_API_KEY=yyyyyy \ lego --dns porkbun -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] PORKBUN_SECRET_API_KEY = "secret API key" PORKBUN_API_KEY = "API key" [Configuration.Additional] PORKBUN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" PORKBUN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" PORKBUN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" PORKBUN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://porkbun.com/api/json/v3/documentation" ================================================ FILE: providers/dns/porkbun/porkbun_test.go ================================================ package porkbun import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvSecretAPIKey, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvSecretAPIKey: "secret", EnvAPIKey: "key", }, }, { desc: "missing secret API key", envVars: map[string]string{ EnvSecretAPIKey: "", EnvAPIKey: "key", }, expected: "porkbun: some credentials information are missing: PORKBUN_SECRET_API_KEY", }, { desc: "missing API key", envVars: map[string]string{ EnvSecretAPIKey: "secret", EnvAPIKey: "", }, expected: "porkbun: some credentials information are missing: PORKBUN_API_KEY", }, { desc: "missing all credentials", envVars: map[string]string{ EnvSecretAPIKey: "", EnvAPIKey: "", }, expected: "porkbun: some credentials information are missing: PORKBUN_SECRET_API_KEY,PORKBUN_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string secretAPIKey string apiKey string expected string }{ { desc: "success", secretAPIKey: "secret", apiKey: "key", }, { desc: "missing secret API key", apiKey: "key", expected: "porkbun: some credentials information are missing", }, { desc: "missing API key", secretAPIKey: "secret", expected: "porkbun: some credentials information are missing", }, { desc: "missing all credentials", expected: "porkbun: some credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.SecretAPIKey = test.secretAPIKey config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/rackspace/fixtures/delete.json ================================================ { "status": "RUNNING", "verb": "DELETE", "jobId": "00000000-0000-0000-0000-0000000000", "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/recordsid=TXT-654321" } ================================================ FILE: providers/dns/rackspace/fixtures/identity.json ================================================ { "access": { "token": { "id": "testToken", "expires": "1970-01-01T00:00:00.000Z", "tenant": { "id": "123456", "name": "123456" }, "RAX-AUTH:authenticatedBy": [ "APIKEY" ] }, "serviceCatalog": [ { "type": "rax:dns", "endpoints": [ { "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456", "tenantId": "123456" } ], "name": "cloudDNS" } ], "user": { "id": "fakeUseID", "name": "testUser" } } } ================================================ FILE: providers/dns/rackspace/fixtures/record.json ================================================ { "request": "{\"records\":[{\"name\":\"_acme-challenge.example.com\",\"type\":\"TXT\",\"data\":\"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM\",\"ttl\":300}]}", "status": "RUNNING", "verb": "POST", "jobId": "00000000-0000-0000-0000-0000000000", "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/records" } ================================================ FILE: providers/dns/rackspace/fixtures/record_details.json ================================================ { "records": [ { "name": "_acme-challenge.example.com", "id": "TXT-654321", "type": "TXT", "data": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", "ttl": 300, "updated": "1970-01-01T00:00:00.000+0000", "created": "1970-01-01T00:00:00.000+0000" } ] } ================================================ FILE: providers/dns/rackspace/fixtures/zone_details.json ================================================ { "domains": [ { "name": "example.com", "id": "112233", "emailAddress": "hostmaster@example.com", "updated": "1970-01-01T00:00:00.000+0000", "created": "1970-01-01T00:00:00.000+0000" } ], "totalEntries": 1 } ================================================ FILE: providers/dns/rackspace/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const AuthToken = "X-Auth-Token" type Client struct { token string baseURL *url.URL HTTPClient *http.Client } func NewClient(endpoint, token string) (*Client, error) { baseURL, err := url.Parse(endpoint) if err != nil { return nil, err } return &Client{ token: token, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, }, nil } // AddRecord Adds one record to a specified domain. // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#add-records func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) error { endpoint := c.baseURL.JoinPath("domains", zoneID, "records") records := Records{Records: []Record{record}} req, err := newJSONRequest(ctx, http.MethodPost, endpoint, records) if err != nil { return err } err = c.do(req, nil) if err != nil { return err } return nil } // DeleteRecord Deletes a record from the domain. // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#delete-records func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.baseURL.JoinPath("domains", zoneID, "records") query := endpoint.Query() query.Set("id", recordID) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } err = c.do(req, nil) if err != nil { return err } return nil } // GetHostedZoneID performs a lookup to get the DNS zone which needs modifying for a given FQDN. func (c *Client) GetHostedZoneID(ctx context.Context, fqdn string) (string, error) { authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone: %w", err) } zoneSearchResponse, err := c.listDomainsByName(ctx, dns01.UnFqdn(authZone)) if err != nil { return "", err } // If nothing was returned, or for whatever reason more than 1 was returned (the search uses exact match, so should not occur) if zoneSearchResponse.TotalEntries != 1 { return "", fmt.Errorf("found %d zones for %s in Rackspace for domain %s", zoneSearchResponse.TotalEntries, authZone, fqdn) } return zoneSearchResponse.HostedZones[0].ID, nil } // listDomainsByName Filters domains by domain name. // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/domains#list-domains-by-name func (c *Client) listDomainsByName(ctx context.Context, domain string) (*ZoneSearchResponse, error) { endpoint := c.baseURL.JoinPath("domains") query := endpoint.Query() query.Set("name", domain) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var zoneSearchResponse ZoneSearchResponse err = c.do(req, &zoneSearchResponse) if err != nil { return nil, err } return &zoneSearchResponse, nil } // FindTxtRecord searches a DNS zone for a TXT record with a specific name. func (c *Client) FindTxtRecord(ctx context.Context, fqdn, zoneID string) (*Record, error) { records, err := c.searchRecords(ctx, zoneID, dns01.UnFqdn(fqdn), "TXT") if err != nil { return nil, err } switch len(records.Records) { case 1: case 0: return nil, fmt.Errorf("no TXT record found for %s", fqdn) default: return nil, fmt.Errorf("more than 1 TXT record found for %s", fqdn) } return &records.Records[0], nil } // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#search-records func (c *Client) searchRecords(ctx context.Context, zoneID, recordName, recordType string) (*Records, error) { endpoint := c.baseURL.JoinPath("domains", zoneID, "records") query := endpoint.Query() query.Set("type", recordType) query.Set("name", recordName) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var records Records err = c.do(req, &records) if err != nil { return nil, err } return &records, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(AuthToken, c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("%s", endpoint), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/rackspace/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With(AuthToken, "secret")) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/1234/records", servermock.ResponseFromFixture("add-records.json"), servermock.CheckRequestJSONBody(`{"records":[{"name":"exmaple.com","type":"TXT","data":"value1","ttl":120,"id":"abc"}]}`)). Build(t) record := Record{ Name: "exmaple.com", Type: "TXT", Data: "value1", TTL: 120, ID: "abc", } err := client.AddRecord(t.Context(), "1234", record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/1234/records", nil). Build(t) err := client.DeleteRecord(t.Context(), "1234", "2725233") require.NoError(t, err) } func TestClient_searchRecords(t *testing.T) { client := mockBuilder(). Route("GET /domains/1234/records", servermock.ResponseFromFixture("search-records.json")). Build(t) records, err := client.searchRecords(t.Context(), "1234", "2725233", "A") require.NoError(t, err) expected := &Records{ TotalEntries: 6, Records: []Record{ {Name: "ftp.example.com", Type: "A", Data: "192.0.2.8", TTL: 5771, ID: "A-6817754"}, {Name: "example.com", Type: "A", Data: "192.0.2.17", TTL: 86400, ID: "A-6822994"}, {Name: "example.com", Type: "NS", Data: "ns.rackspace.com", TTL: 3600, ID: "NS-6251982"}, {Name: "example.com", Type: "NS", Data: "ns2.rackspace.com", TTL: 3600, ID: "NS-6251983"}, {Name: "example.com", Type: "MX", Data: "mail.example.com", TTL: 3600, ID: "MX-3151218"}, {Name: "www.example.com", Type: "CNAME", Data: "example.com", TTL: 5400, ID: "CNAME-9778009"}, }, } assert.Equal(t, expected, records) } func TestClient_listDomainsByName(t *testing.T) { client := mockBuilder(). Route("GET /domains", servermock.ResponseFromFixture("list-domains-by-name.json")). Build(t) domains, err := client.listDomainsByName(t.Context(), "1234") require.NoError(t, err) expected := &ZoneSearchResponse{ TotalEntries: 114, HostedZones: []HostedZone{{ID: "2725257", Name: "sub1.example.com"}}, } assert.Equal(t, expected, domains) } ================================================ FILE: providers/dns/rackspace/internal/fixtures/add-records.json ================================================ { "totalEntries": 6, "records": [ { "name": "ftp.example.com", "id": "A-6817754", "type": "A", "data": "192.0.2.8", "updated": "2011-05-19T13:07:08.000+0000", "ttl": 5771, "created": "2011-05-18T19:53:09.000+0000" }, { "name": "example.com", "id": "A-6822994", "type": "A", "data": "192.0.2.17", "updated": "2011-06-24T01:12:52.000+0000", "ttl": 86400, "created": "2011-06-24T01:12:52.000+0000" }, { "name": "example.com", "id": "NS-6251982", "type": "NS", "data": "ns.rackspace.com", "updated": "2011-06-24T01:12:51.000+0000", "ttl": 3600, "created": "2011-06-24T01:12:51.000+0000" }, { "name": "example.com", "id": "NS-6251983", "type": "NS", "data": "ns2.rackspace.com", "updated": "2011-06-24T01:12:51.000+0000", "ttl": 3600, "created": "2011-06-24T01:12:51.000+0000" }, { "name": "example.com", "priority": 5, "id": "MX-3151218", "type": "MX", "data": "mail.example.com", "updated": "2011-06-24T01:12:53.000+0000", "ttl": 3600, "created": "2011-06-24T01:12:53.000+0000" }, { "name": "www.example.com", "id": "CNAME-9778009", "type": "CNAME", "comment": "This is a comment on the CNAME record", "data": "example.com", "updated": "2011-06-24T01:12:54.000+0000", "ttl": 5400, "created": "2011-06-24T01:12:54.000+0000" } ] } ================================================ FILE: providers/dns/rackspace/internal/fixtures/delete-records_error.json ================================================ { "failedItems" : { "faults" : [ { "message" : "Object not Found.", "code" : 404, "details" : "Domain ID: 2720150; Record ID: 111111111" }, { "message" : "Object not Found.", "code" : 404, "details" : "Domain ID: 2720150; Record ID: 222222222" } ] }, "message" : "One or more items could not be deleted.", "code" : 500, "details" : "See errors list for details." } ================================================ FILE: providers/dns/rackspace/internal/fixtures/list-domains-by-name.json ================================================ { "domains": [ { "name": "sub1.example.com", "id": "2725257", "comment": "1st sample subdomain", "updated": "2011-06-23T03:09:34.000+0000", "emailAddress": "sample@rackspace.com", "created": "2011-06-23T03:09:33.000+0000" } ], "totalEntries": 114 } ================================================ FILE: providers/dns/rackspace/internal/fixtures/search-records.json ================================================ { "totalEntries": 6, "records": [ { "name": "ftp.example.com", "id": "A-6817754", "type": "A", "data": "192.0.2.8", "updated": "2011-05-19T13:07:08.000+0000", "ttl": 5771, "created": "2011-05-18T19:53:09.000+0000" }, { "name": "example.com", "id": "A-6822994", "type": "A", "data": "192.0.2.17", "updated": "2011-06-24T01:12:52.000+0000", "ttl": 86400, "created": "2011-06-24T01:12:52.000+0000" }, { "name": "example.com", "id": "NS-6251982", "type": "NS", "data": "ns.rackspace.com", "updated": "2011-06-24T01:12:51.000+0000", "ttl": 3600, "created": "2011-06-24T01:12:51.000+0000" }, { "name": "example.com", "id": "NS-6251983", "type": "NS", "data": "ns2.rackspace.com", "updated": "2011-06-24T01:12:51.000+0000", "ttl": 3600, "created": "2011-06-24T01:12:51.000+0000" }, { "name": "example.com", "priority": 5, "id": "MX-3151218", "type": "MX", "data": "mail.example.com", "updated": "2011-06-24T01:12:53.000+0000", "ttl": 3600, "created": "2011-06-24T01:12:53.000+0000" }, { "name": "www.example.com", "id": "CNAME-9778009", "type": "CNAME", "comment": "This is a comment on the CNAME record", "data": "example.com", "updated": "2011-06-24T01:12:54.000+0000", "ttl": 5400, "created": "2011-06-24T01:12:54.000+0000" } ] } ================================================ FILE: providers/dns/rackspace/internal/fixtures/tokens.json ================================================ { "access": { "token": { "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "expires": "2014-11-24T22:05:39.115Z", "tenant": { "id": "110011", "name": "110011" }, "RAX-AUTH:authenticatedBy": [ "APIKEY" ] }, "serviceCatalog": [ { "name": "cloudDatabases", "endpoints": [ { "publicURL": "https://syd.databases.api.rackspacecloud.com/v1.0/110011", "region": "SYD", "tenantId": "110011" }, { "publicURL": "https://dfw.databases.api.rackspacecloud.com/v1.0/110011", "region": "DFW", "tenantId": "110011" }, { "publicURL": "https://ord.databases.api.rackspacecloud.com/v1.0/110011", "region": "ORD", "tenantId": "110011" }, { "publicURL": "https://iad.databases.api.rackspacecloud.com/v1.0/110011", "region": "IAD", "tenantId": "110011" }, { "publicURL": "https://hkg.databases.api.rackspacecloud.com/v1.0/110011", "region": "HKG", "tenantId": "110011" } ], "type": "rax:database" }, { "name": "cloudDNS", "endpoints": [ { "publicURL": "https://dns.api.rackspacecloud.com/v1.0/110011", "tenantId": "110011" } ], "type": "rax:dns" }, { "name": "rackCDN", "endpoints": [ { "internalURL": "https://global.cdn.api.rackspacecloud.com/v1.0/110011", "publicURL": "https://global.cdn.api.rackspacecloud.com/v1.0/110011", "tenantId": "110011" } ], "type": "rax:cdn" } ], "user": { "id": "123456", "roles": [ { "description": "A Role that allows a user access to keystone Service methods", "id": "6", "name": "compute:default", "tenantId": "110011" }, { "description": "User Admin Role.", "id": "3", "name": "identity:user-admin" } ], "name": "jsmith", "RAX-AUTH:defaultRegion": "ORD" } } } ================================================ FILE: providers/dns/rackspace/internal/identity.go ================================================ package internal import ( "context" "encoding/json" "io" "net/http" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultIdentityURL represents the Identity API endpoint to call. const DefaultIdentityURL = "https://identity.api.rackspacecloud.com/v2.0/tokens" type Identifier struct { baseURL string httpClient *http.Client } // NewIdentifier creates a new Identifier. func NewIdentifier(httpClient *http.Client, baseURL string) *Identifier { if httpClient == nil { httpClient = &http.Client{Timeout: 5 * time.Second} } if baseURL == "" { baseURL = DefaultIdentityURL } return &Identifier{baseURL: baseURL, httpClient: httpClient} } // Login sends an authentication request. // https://docs.rackspace.com/docs/cloud-dns/v1/getting-started/authenticate func (a *Identifier) Login(ctx context.Context, apiUser, apiKey string) (*Identity, error) { authData := AuthData{ Auth: Auth{ APIKeyCredentials: APIKeyCredentials{ Username: apiUser, APIKey: apiKey, }, }, } req, err := newJSONRequest(ctx, http.MethodPost, a.baseURL, authData) if err != nil { return nil, err } resp, err := a.httpClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var identity Identity err = json.Unmarshal(raw, &identity) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &identity, nil } ================================================ FILE: providers/dns/rackspace/internal/identity_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupIdentifier(server *httptest.Server) (*Identifier, error) { return NewIdentifier(server.Client(), server.URL), nil } func TestIdentifier_Login(t *testing.T) { identifier := servermock.NewBuilder[*Identifier](setupIdentifier, servermock.CheckHeader().WithJSONHeaders()). Route("POST /", servermock.ResponseFromFixture("tokens.json")). Build(t) identity, err := identifier.Login(t.Context(), "user", "secret") require.NoError(t, err) expected := &Identity{ Access: Access{ Token: Token{ ID: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", Expires: "2014-11-24T22:05:39.115Z", Tenant: Tenant{ID: "110011", Name: "110011"}, RAXAUTHAuthenticatedBy: []string{"APIKEY"}, }, ServiceCatalog: []ServiceCatalog{ { Name: "cloudDatabases", Type: "rax:database", Endpoints: []Endpoint{ {PublicURL: "https://syd.databases.api.rackspacecloud.com/v1.0/110011", Region: "SYD", TenantID: "110011", InternalURL: ""}, {PublicURL: "https://dfw.databases.api.rackspacecloud.com/v1.0/110011", Region: "DFW", TenantID: "110011", InternalURL: ""}, {PublicURL: "https://ord.databases.api.rackspacecloud.com/v1.0/110011", Region: "ORD", TenantID: "110011", InternalURL: ""}, {PublicURL: "https://iad.databases.api.rackspacecloud.com/v1.0/110011", Region: "IAD", TenantID: "110011", InternalURL: ""}, {PublicURL: "https://hkg.databases.api.rackspacecloud.com/v1.0/110011", Region: "HKG", TenantID: "110011", InternalURL: ""}, }, }, { Name: "cloudDNS", Type: "rax:dns", Endpoints: []Endpoint{{PublicURL: "https://dns.api.rackspacecloud.com/v1.0/110011", Region: "", TenantID: "110011", InternalURL: ""}}, }, { Name: "rackCDN", Type: "rax:cdn", Endpoints: []Endpoint{{PublicURL: "https://global.cdn.api.rackspacecloud.com/v1.0/110011", Region: "", TenantID: "110011", InternalURL: "https://global.cdn.api.rackspacecloud.com/v1.0/110011"}}, }, }, User: User{ ID: "123456", Roles: []Role{ {Description: "A Role that allows a user access to keystone Service methods", ID: "6", Name: "compute:default", TenantID: "110011"}, {Description: "User Admin Role.", ID: "3", Name: "identity:user-admin", TenantID: ""}, }, Name: "jsmith", RAXAUTHDefaultRegion: "ORD", }, }, } assert.Equal(t, expected, identity) } ================================================ FILE: providers/dns/rackspace/internal/types.go ================================================ package internal // Authentication response. // Identity api structure. type Identity struct { Access Access `json:"access"` } // Access api structure. type Access struct { Token Token `json:"token"` ServiceCatalog []ServiceCatalog `json:"serviceCatalog"` User User `json:"user"` } // Token api structure. type Token struct { ID string `json:"id"` Expires string `json:"expires"` Tenant Tenant `json:"tenant"` RAXAUTHAuthenticatedBy []string `json:"RAX-AUTH:authenticatedBy"` } // ServiceCatalog service catalog. type ServiceCatalog struct { Name string `json:"name"` Type string `json:"type"` Endpoints []Endpoint `json:"endpoints"` } type Tenant struct { ID string `json:"id"` Name string `json:"name"` } // Endpoint api structure. type Endpoint struct { PublicURL string `json:"publicURL"` Region string `json:"region,omitempty"` TenantID string `json:"tenantId"` InternalURL string `json:"internalURL,omitempty"` } type Role struct { Description string `json:"description"` ID string `json:"id"` Name string `json:"name"` TenantID string `json:"tenantId,omitempty"` } type User struct { ID string `json:"id"` Roles []Role `json:"roles"` Name string `json:"name"` RAXAUTHDefaultRegion string `json:"RAX-AUTH:defaultRegion"` } // Authentication request. // AuthData api structure. type AuthData struct { Auth `json:"auth"` } // Auth api structure. type Auth struct { APIKeyCredentials `json:"RAX-KSKEY:apiKeyCredentials"` } // APIKeyCredentials api structure. type APIKeyCredentials struct { Username string `json:"username"` APIKey string `json:"apiKey"` } // API responses. // ZoneSearchResponse represents the response when querying Rackspace DNS zones. type ZoneSearchResponse struct { TotalEntries int `json:"totalEntries"` HostedZones []HostedZone `json:"domains"` } // HostedZone api structure. type HostedZone struct { ID string `json:"id"` Name string `json:"name"` } // Records is the list of records sent/received from the DNS API. type Records struct { TotalEntries int `json:"totalEntries,omitempty"` Records []Record `json:"records,omitempty"` } // Record represents a Rackspace DNS record. type Record struct { Name string `json:"name"` Type string `json:"type"` Data string `json:"data"` TTL int `json:"ttl,omitempty"` ID string `json:"id,omitempty"` } ================================================ FILE: providers/dns/rackspace/rackspace.go ================================================ // Package rackspace implements a DNS provider for solving the DNS-01 challenge using rackspace DNS. package rackspace import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/rackspace/internal" ) // Environment variables names. const ( envNamespace = "RACKSPACE_" EnvUser = envNamespace + "USER" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIUser string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ BaseURL: internal.DefaultIdentityURL, TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client token string cloudDNSEndpoint string } // NewDNSProvider returns a DNSProvider instance configured for Rackspace. // Credentials must be passed in the environment variables: // RACKSPACE_USER and RACKSPACE_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUser, EnvAPIKey) if err != nil { return nil, fmt.Errorf("rackspace: %w", err) } config := NewDefaultConfig() config.APIUser = values[EnvUser] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Rackspace. // It authenticates against the API, also grabbing the DNS Endpoint. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("rackspace: the configuration of the DNS provider is nil") } if config.APIUser == "" || config.APIKey == "" { return nil, errors.New("rackspace: credentials missing") } identifier := internal.NewIdentifier(config.HTTPClient, config.BaseURL) identity, err := identifier.Login(context.Background(), config.APIUser, config.APIKey) if err != nil { return nil, fmt.Errorf("rackspace: %w", err) } // Iterate through the Service Catalog to get the DNS Endpoint var dnsEndpoint string for _, service := range identity.Access.ServiceCatalog { if service.Name == "cloudDNS" { dnsEndpoint = service.Endpoints[0].PublicURL break } } if dnsEndpoint == "" { return nil, errors.New("rackspace: failed to populate DNS endpoint, check Rackspace API for changes") } client, err := internal.NewClient(dnsEndpoint, identity.Access.Token.ID) if err != nil { return nil, fmt.Errorf("rackspace: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, token: identity.Access.Token.ID, cloudDNSEndpoint: dnsEndpoint, }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zoneID, err := d.client.GetHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("rackspace: %w", err) } record := internal.Record{ Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", Data: info.Value, TTL: d.config.TTL, } err = d.client.AddRecord(ctx, zoneID, record) if err != nil { return fmt.Errorf("rackspace: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zoneID, err := d.client.GetHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("rackspace: %w", err) } record, err := d.client.FindTxtRecord(ctx, info.EffectiveFQDN, zoneID) if err != nil { return fmt.Errorf("rackspace: %w", err) } err = d.client.DeleteRecord(ctx, zoneID, record.ID) if err != nil { return fmt.Errorf("rackspace: %w", err) } return 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 } ================================================ FILE: providers/dns/rackspace/rackspace.toml ================================================ Name = "Rackspace" Description = '''''' URL = "https://www.rackspace.com/" Code = "rackspace" Since = "v0.4.0" Example = ''' RACKSPACE_USER=xxxx \ RACKSPACE_API_KEY=yyyy \ lego --dns rackspace -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] RACKSPACE_USER = "API user" RACKSPACE_API_KEY = "API key" [Configuration.Additional] RACKSPACE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 3)" RACKSPACE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" RACKSPACE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" RACKSPACE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developer.rackspace.com/docs/cloud-dns/v1/" ================================================ FILE: providers/dns/rackspace/rackspace_test.go ================================================ package rackspace import ( "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUser, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProviderConfig(t *testing.T) { provider := mockBuilder().Build(t) assert.Equal(t, "testToken", provider.token, "The token should match") } func TestNewDNSProviderConfig_MissingCredErr(t *testing.T) { _, err := NewDNSProviderConfig(NewDefaultConfig()) require.EqualError(t, err, "rackspace: credentials missing") } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /123456/domains", servermock.ResponseFromFixture("zone_details.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com")). Route("POST /123456/domains/112233/records", servermock.ResponseFromFixture("record.json"). WithStatusCode(http.StatusAccepted), servermock.CheckRequestJSONBody(`{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}`)). Build(t) err := provider.Present("example.com", "token", "keyAuth") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /123456/domains", servermock.ResponseFromFixture("zone_details.json"), servermock.CheckQueryParameter().Strict(). With("name", "example.com")). Route("GET /123456/domains/112233/records", servermock.ResponseFromFixture("record_details.json"), servermock.CheckQueryParameter().Strict(). With("type", "TXT"). With("name", "_acme-challenge.example.com")). Route("DELETE /123456/domains/112233/records", servermock.ResponseFromFixture("delete.json"), servermock.CheckQueryParameter().Strict(). With("id", "TXT-654321")). Build(t) err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) } func TestLiveNewDNSProvider_ValidEnv(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) assert.Contains(t, provider.cloudDNSEndpoint, "https://dns.api.rackspacecloud.com/v1.0/", "The endpoint URL should contain the base") } 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(), "", "112233445566==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(15 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "112233445566==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.APIUser = "testUser" config.APIKey = "testKey" config.HTTPClient = server.Client() config.BaseURL = server.URL + "/v2.0/tokens" return NewDNSProviderConfig(config) }, servermock.CheckHeader().WithJSONHeaders(), ). Route("POST /v2.0/tokens", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { apiURL := fmt.Sprintf("http://%s/123456", req.Context().Value(http.LocalAddrContextKey)) resp := strings.Replace(` { "access": { "token": { "id": "testToken", "expires": "1970-01-01T00:00:00.000Z", "tenant": { "id": "123456", "name": "123456" }, "RAX-AUTH:authenticatedBy": [ "APIKEY" ] }, "serviceCatalog": [ { "type": "rax:dns", "endpoints": [ { "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456", "tenantId": "123456" } ], "name": "cloudDNS" } ], "user": { "id": "fakeUseID", "name": "testUser" } } } `, "https://dns.api.rackspacecloud.com/v1.0/123456", apiURL, 1) rw.WriteHeader(http.StatusOK) _, _ = fmt.Fprint(rw, resp) }), servermock.CheckRequestJSONBody(`{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}`)) } ================================================ FILE: providers/dns/rainyun/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://api.v2.rainyun.com/product/" // Client the Rain Yun API client. type Client struct { apiKey string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey string) (*Client, error) { if apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddRecord(ctx context.Context, domainID int, record Record) error { endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error { endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns") values, err := querystring.Values(Record{ID: recordID}) if err != nil { return err } endpoint.RawQuery = values.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) ListRecords(ctx context.Context, domainID int) ([]Record, error) { endpoint := c.baseURL.JoinPath("domain", strconv.Itoa(domainID), "dns") query := endpoint.Query() query.Set("limit", "100") query.Set("page_no", "1") endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var recordData APIResponse[Record] err = c.do(req, &recordData) if err != nil { return nil, err } return recordData.Data.Records, nil } func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { endpoint := c.baseURL.JoinPath("domain") query := endpoint.Query() query.Set("options", `{"columnFilters":{"domains.Domain":""},"sort":[],"page":1,"perPage":100}`) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var domainData APIResponse[Domain] err = c.do(req, &domainData) if err != nil { return nil, err } return domainData.Data.Records, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Add("x-api-key", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/rainyun/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders()) } func TestClient_ListDomains(t *testing.T) { client := mockBuilder(). Route("GET /domain", servermock.ResponseFromFixture("domains.json"), servermock.CheckQueryParameter().Strict(). With("options", `{"columnFilters":{"domains.Domain":""},"sort":[],"page":1,"perPage":100}`)). Build(t) domains, err := client.ListDomains(t.Context()) require.NoError(t, err) expected := []Domain{ {ID: 1, Domain: "example.com"}, {ID: 2, Domain: "example.org"}, } assert.Equal(t, expected, domains) } func TestClient_ListDomains_error(t *testing.T) { client := mockBuilder(). Route("GET /domain", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusForbidden)). Build(t) _, err := client.ListDomains(t.Context()) require.Error(t, err) assert.EqualError(t, err, "30039: 密钥认证错误或已失效") } func TestClient_ListRecords(t *testing.T) { client := mockBuilder(). Route("GET /domain/123/dns", servermock.ResponseFromFixture("records.json"), servermock.CheckQueryParameter().Strict(). With("limit", "100"). With("page_no", "1")). Build(t) records, err := client.ListRecords(t.Context(), 123) require.NoError(t, err) expected := []Record{ { ID: 1, Host: "_acme-challenge.foo.example.com", Line: "DEFAULT", TTL: 120, Type: "TXT", Value: "foo", }, { ID: 2, Host: "_acme-challenge.bar.example.com", Line: "DEFAULT", TTL: 300, Type: "TXT", Value: "bar", }, } assert.Equal(t, expected, records) } func TestClient_ListRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /domain/123/dns", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusForbidden)). Build(t) _, err := client.ListRecords(t.Context(), 123) require.Error(t, err) assert.EqualError(t, err, "30039: 密钥认证错误或已失效") } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /domain/123/dns", nil). Build(t) record := Record{ Host: "_acme-challenge.foo.example.com", Line: "DEFAULT", TTL: 120, Type: "TXT", Value: "foo", } err := client.AddRecord(t.Context(), 123, record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domain/123/dns", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusForbidden)). Build(t) record := Record{ Host: "_acme-challenge.foo.example.com", Line: "DEFAULT", TTL: 120, Type: "TXT", Value: "foo", } err := client.AddRecord(t.Context(), 123, record) require.Error(t, err) assert.EqualError(t, err, "30039: 密钥认证错误或已失效") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /domain/123/dns", nil). Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /domain/123/dns", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusForbidden)). Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.Error(t, err) assert.EqualError(t, err, "30039: 密钥认证错误或已失效") } ================================================ FILE: providers/dns/rainyun/internal/fixtures/domains.json ================================================ { "code": 0, "data": { "TotalRecords": 2, "Records": [ { "id": 1, "domain": "example.com" }, { "id": 2, "domain": "example.org" } ] } } ================================================ FILE: providers/dns/rainyun/internal/fixtures/error.json ================================================ { "code": 30039, "message": "密钥认证错误或已失效" } ================================================ FILE: providers/dns/rainyun/internal/fixtures/records.json ================================================ { "code": 0, "data": { "TotalRecords": 2, "Records": [ { "record_id": 1, "host": "_acme-challenge.foo.example.com", "type": "TXT", "TTL": 120, "value": "foo", "line": "DEFAULT" }, { "record_id": 2, "host": "_acme-challenge.bar.example.com", "type": "TXT", "TTL": 300, "value": "bar", "line": "DEFAULT" } ] } } ================================================ FILE: providers/dns/rainyun/internal/types.go ================================================ package internal import "fmt" type APIError struct { Code int `json:"code"` Message string `json:"message"` } func (a *APIError) Error() string { return fmt.Sprintf("%d: %s", a.Code, a.Message) } type Record struct { ID int `json:"record_id,omitempty" url:"record_id,omitempty"` Host string `json:"host,omitempty" url:"host,omitempty"` Priority int `json:"level,omitempty" url:"level,omitempty"` Line string `json:"line,omitempty" url:"line,omitempty"` TTL int `json:"ttl,omitempty" url:"ttl,omitempty"` Type string `json:"type,omitempty" url:"type,omitempty"` Value string `json:"value,omitempty" url:"value,omitempty"` } type Domain struct { ID int `json:"id,omitempty"` Domain string `json:"domain,omitempty"` } type APIResponse[T any] struct { Code int `json:"code"` Data *Data[T] `json:"data"` } type Data[T any] struct { TotalRecords int `json:"TotalRecords"` Records []T `json:"Records"` } ================================================ FILE: providers/dns/rainyun/rainyun.go ================================================ // Package rainyun implements a DNS provider for solving the DNS-01 challenge using Rain Yun. package rainyun import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/rainyun/internal" ) // Environment variables names. const ( envNamespace = "RAINYUN_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Rain Yun. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("rainyun: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Rain Yun. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("rainyun: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIKey) if err != nil { return nil, fmt.Errorf("rainyun: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("rainyun: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("rainyun: %w", err) } domainID, err := d.findDomainID(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("rainyun: find domain ID: %w", err) } record := internal.Record{ Host: subDomain, Priority: 10, Line: "DEFAULT", TTL: d.config.TTL, Type: "TXT", Value: info.Value, } err = d.client.AddRecord(ctx, domainID, record) if err != nil { return fmt.Errorf("rainyun: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("rainyun: could not find zone for domain %q: %w", domain, err) } domainID, err := d.findDomainID(ctx, dns01.UnFqdn(authZone)) if err != nil { return fmt.Errorf("rainyun: find domain ID: %w", err) } recordID, err := d.findRecordID(ctx, domainID, info) if err != nil { return fmt.Errorf("rainyun: find record ID: %w", err) } err = d.client.DeleteRecord(ctx, domainID, recordID) if err != nil { return fmt.Errorf("rainyun: delete record: %w", err) } return 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 } func (d *DNSProvider) findDomainID(ctx context.Context, domain string) (int, error) { domains, err := d.client.ListDomains(ctx) if err != nil { return 0, err } for _, dom := range domains { if dom.Domain == domain { return dom.ID, nil } } return 0, fmt.Errorf("domain not found: %s", domain) } func (d *DNSProvider) findRecordID(ctx context.Context, domainID int, info dns01.ChallengeInfo) (int, error) { records, err := d.client.ListRecords(ctx, domainID) if err != nil { return 0, fmt.Errorf("list records: %w", err) } zone := dns01.UnFqdn(info.EffectiveFQDN) for _, record := range records { if strings.HasPrefix(zone, record.Host) && record.Value == info.Value { return record.ID, nil } } return 0, fmt.Errorf("record not found: domainID=%d, fqdn=%s", domainID, info.EffectiveFQDN) } ================================================ FILE: providers/dns/rainyun/rainyun.toml ================================================ Name = "Rain Yun/雨云" Description = '''''' URL = "https://www.rainyun.com" Code = "rainyun" Since = "v4.21.0" Example = ''' RAINYUN_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns rainyun -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] RAINYUN_API_KEY = "API key" [Configuration.Additional] RAINYUN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" RAINYUN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" RAINYUN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" RAINYUN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.apifox.cn/apidoc/shared-a4595cc8-44c5-4678-a2a3-eed7738dab03/api-151416609" ================================================ FILE: providers/dns/rainyun/rainyun_test.go ================================================ package rainyun import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "rainyun: some credentials information are missing: RAINYUN_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "secret", }, { desc: "missing credentials", expected: "rainyun: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/rcodezero/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/miekg/dns" ) const defaultBaseURL = "https://my.rcodezero.at/api" const authorizationHeader = "Authorization" // Client for the RcodeZero API. type Client struct { apiToken string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiToken string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiToken: apiToken, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } func (c *Client) UpdateRecords(ctx context.Context, authZone string, sets []UpdateRRSet) (*APIResponse, error) { endpoint := c.baseURL.JoinPath("v1", "acme", "zones", strings.TrimSuffix(dns.Fqdn(authZone), "."), "rrsets") req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, sets) if err != nil { return nil, err } return c.do(req) } func (c *Client) do(req *http.Request) (*APIResponse, error) { req.Header.Set(authorizationHeader, "Bearer "+c.apiToken) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return nil, parseError(req, resp) } result := &APIResponse{} raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return result, nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errAPI := &APIResponse{} err := json.Unmarshal(raw, errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, errAPI) } ================================================ FILE: providers/dns/rcodezero/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil } func TestClient_UpdateRecords_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). Route("PATCH /v1/acme/zones/example.org/rrsets", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnprocessableEntity)). Build(t) rrSet := []UpdateRRSet{{ Name: "acme.example.org.", ChangeType: "add", Type: "TXT", Records: []Record{{Content: `"my-acme-challenge"`}}, }} resp, err := client.UpdateRecords(t.Context(), "example.org", rrSet) require.ErrorAs(t, err, new(*APIResponse)) assert.Nil(t, resp) } func TestClient_UpdateRecords(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). Route("PATCH /v1/acme/zones/example.org/rrsets", servermock.ResponseFromFixture("rrsets-response.json")). Build(t) rrSet := []UpdateRRSet{{ Name: "acme.example.org.", ChangeType: "add", Type: "TXT", Records: []Record{{Content: `"my-acme-challenge"`}}, }} resp, err := client.UpdateRecords(t.Context(), "example.org", rrSet) require.NoError(t, err) expected := &APIResponse{Status: "ok", Message: "RRsets updated"} assert.Equal(t, expected, resp) } ================================================ FILE: providers/dns/rcodezero/internal/fixtures/error.json ================================================ { "status": "failed", "message": "A human readable error message" } ================================================ FILE: providers/dns/rcodezero/internal/fixtures/rrsets-response.json ================================================ { "status": "ok", "message": "RRsets updated" } ================================================ FILE: providers/dns/rcodezero/internal/types.go ================================================ package internal import "fmt" type UpdateRRSet struct { Name string `json:"name"` Type string `json:"type"` ChangeType string `json:"changetype"` Records []Record `json:"records"` TTL int `json:"ttl"` } type Record struct { Content string `json:"content"` Disabled bool `json:"disabled"` } type APIResponse struct { Status string `json:"status"` Message string `json:"message"` } func (a APIResponse) Error() string { return fmt.Sprintf("%s: %s", a.Status, a.Message) } ================================================ FILE: providers/dns/rcodezero/rcodezero.go ================================================ // Package rcodezero implements a DNS provider for solving the DNS-01 challenge using RcodeZero Anycast network. package rcodezero import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/rcodezero/internal" ) // Environment variables names. const ( envNamespace = "RCODEZERO_" EnvAPIToken = envNamespace + "API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 4*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for RcodeZero. // Credentials must be passed in the environment variable: // RCODEZERO_API_URL and RCODEZERO_API_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("rcodezero: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for RcodeZero. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("rcodezero: the configuration of the DNS provider is nil") } if config.APIToken == "" { return nil, errors.New("rcodezero: API token missing") } client := internal.NewClient(config.APIToken) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("rcodezero: could not find zone for domain %q: %w", domain, err) } rrSet := []internal.UpdateRRSet{{ Name: info.EffectiveFQDN, ChangeType: "update", Type: "TXT", TTL: d.config.TTL, Records: []internal.Record{{Content: `"` + info.Value + `"`}}, }} _, err = d.client.UpdateRecords(ctx, authZone, rrSet) if err != nil { return fmt.Errorf("rcodezero: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("rcodezero: could not find zone for domain %q: %w", domain, err) } rrSet := []internal.UpdateRRSet{{ Name: info.EffectiveFQDN, Type: "TXT", ChangeType: "delete", }} _, err = d.client.UpdateRecords(ctx, authZone, rrSet) if err != nil { return fmt.Errorf("rcodezero: %w", err) } return nil } ================================================ FILE: providers/dns/rcodezero/rcodezero.toml ================================================ Name = "RcodeZero" Description = '''''' URL = "https://www.rcodezero.at/" Code = "rcodezero" Since = "v4.13" Example = ''' RCODEZERO_API_TOKEN= \ lego --dns rcodezero -d '*.example.com' -d example.com run ''' Additional = ''' ## Description Generate your API Token via https://my.rcodezero.at with the `ACME` permissions. These are special tokens with limited access for ACME requests only. RcodeZero is an Anycast Network so the distribution of the DNS01-Challenge can take up to 2 minutes. ''' [Configuration] [Configuration.Credentials] RCODEZERO_API_TOKEN = "API token" [Configuration.Additional] RCODEZERO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" RCODEZERO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)" RCODEZERO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" RCODEZERO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] # Note: the API endpoint used inside the client is not documented. API = "https://my.rcodezero.at/openapi" ================================================ FILE: providers/dns/rcodezero/rcodezero_test.go ================================================ package rcodezero import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAPIToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIToken: "", }, expected: "rcodezero: some credentials information are missing: RCODEZERO_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiToken string expected string }{ { desc: "success", apiToken: "123", }, { desc: "missing credentials", expected: "rcodezero: API token missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresentAndCleanup(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) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/regfish/regfish.go ================================================ // Package regfish implements a DNS provider for solving the DNS-01 challenge using Regfish. package regfish import ( "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" regfishapi "github.com/regfish/regfish-dnsapi-go" ) // Environment variables names. const ( envNamespace = "REGFISH_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *regfishapi.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Regfish. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("regfish: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Regfish. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("regfish: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("regfish: credentials missing") } client := regfishapi.NewClient(config.APIKey) if config.HTTPClient != nil { client.Client = config.HTTPClient } else { // Because the regfishapi.NewClient uses an empty http.Client. client.Client = &http.Client{Timeout: 30 * time.Second} } client.Client = clientdebug.Wrap(client.Client) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) record := regfishapi.Record{ Name: info.EffectiveFQDN, Type: "TXT", Data: info.Value, TTL: d.config.TTL, } newRecord, err := d.client.CreateRecord(record) if err != nil { return fmt.Errorf("regfish: create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // get the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("regfish: unknown record ID for '%s'", info.EffectiveFQDN) } err := d.client.DeleteRecord(recordID) if err != nil { return fmt.Errorf("regfish: delete record: %w", err) } // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/regfish/regfish.toml ================================================ Name = "Regfish" Description = '''''' URL = "https://regfish.de/" Code = "regfish" Since = "v4.20.0" Example = ''' REGFISH_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns regfish -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] REGFISH_API_KEY = "API key" [Configuration.Additional] REGFISH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" REGFISH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" REGFISH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" REGFISH_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://regfish.readme.io/" GoClient = "https://github.com/regfish/regfish-dnsapi-go" ================================================ FILE: providers/dns/regfish/regfish_test.go ================================================ package regfish import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "regfish: some credentials information are missing: REGFISH_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "secret", }, { desc: "missing credentials", expected: "regfish: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/regru/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.reg.ru/api/regru2/" // Client the reg.ru client. type Client struct { username string password string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a reg.ru client. func NewClient(username, password string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ username: username, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // RemoveTxtRecord removes a TXT record. // https://www.reg.ru/support/help/api2#zone_remove_record func (c *Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content string) error { request := RemoveRecordRequest{ Domains: []Domain{{DName: domain}}, SubDomain: subDomain, Content: content, RecordType: "TXT", OutputContentType: "plain", } resp, err := c.doRequest(ctx, request, "zone", "remove_record") if err != nil { return err } return resp.HasError() } // AddTXTRecord adds a TXT record. // https://www.reg.ru/support/help/api2#zone_add_txt func (c *Client) AddTXTRecord(ctx context.Context, domain, subDomain, content string) error { request := AddTxtRequest{ Domains: []Domain{{DName: domain}}, SubDomain: subDomain, Text: content, OutputContentType: "plain", } resp, err := c.doRequest(ctx, request, "zone", "add_txt") if err != nil { return err } return resp.HasError() } func (c *Client) doRequest(ctx context.Context, request any, fragments ...string) (*APIResponse, error) { endpoint := c.baseURL.JoinPath(fragments...) inputData, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("failed to create input data: %w", err) } data := url.Values{} data.Set("username", c.username) data.Set("password", c.password) data.Set("input_data", string(inputData)) data.Set("input_format", "json") req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return nil, errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return nil, parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) } var apiResp APIResponse err = json.Unmarshal(raw, &apiResp) if err != nil { return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return &apiResp, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIResponse err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("status code: %d, %w", resp.StatusCode, errAPI) } ================================================ FILE: providers/dns/regru/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded(), ) } func TestRemoveRecord(t *testing.T) { client := mockBuilder(). Route("POST /zone/remove_record", servermock.ResponseFromFixture("remove_record.json"), servermock.CheckForm().Strict(). With("input_data", `{"domains":[{"dname":"test.ru"}],"subdomain":"_acme-challenge","content":"txttxttxt","record_type":"TXT","output_content_type":"plain"}`). With("username", "user"). With("password", "secret"). With("input_format", "json")). Build(t) err := client.RemoveTxtRecord(t.Context(), "test.ru", "_acme-challenge", "txttxttxt") require.NoError(t, err) } func TestRemoveRecord_errors(t *testing.T) { testCases := []struct { desc string domain string response string expected string }{ { desc: "authentication failed", domain: "test.ru", response: "remove_record_error_auth.json", expected: "API error: NO_AUTH: No authorization mechanism selected", }, { desc: "domain error", domain: "", response: "remove_record_error_domain.json", expected: "API error: NO_DOMAIN: domain_name not given or empty", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route("POST /zone/remove_record", servermock.ResponseFromFixture(test.response)). Build(t) err := client.RemoveTxtRecord(t.Context(), test.domain, "_acme-challenge", "txttxttxt") require.EqualError(t, err, test.expected) }) } } func TestAddTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /zone/add_txt", servermock.ResponseFromFixture("add_txt_record.json"), servermock.CheckForm().Strict(). With("input_data", `{"domains":[{"dname":"test.ru"}],"subdomain":"_acme-challenge","text":"txttxttxt","output_content_type":"plain"}`). With("username", "user"). With("password", "secret"). With("input_format", "json")). Build(t) err := client.AddTXTRecord(t.Context(), "test.ru", "_acme-challenge", "txttxttxt") require.NoError(t, err) } func TestAddTXTRecord_errors(t *testing.T) { testCases := []struct { desc string domain string response string expected string }{ { desc: "authentication failed", domain: "test.ru", response: "add_txt_record_error_auth.json", expected: "API error: NO_AUTH: No authorization mechanism selected", }, { desc: "domain error", domain: "", response: "add_txt_record_error_domain.json", expected: "API error: NO_DOMAIN: domain_name not given or empty", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route("POST /zone/add_txt", servermock.ResponseFromFixture(test.response)). Build(t) err := client.AddTXTRecord(t.Context(), test.domain, "_acme-challenge", "txttxttxt") require.EqualError(t, err, test.expected) }) } } ================================================ FILE: providers/dns/regru/internal/fixtures/add_txt_record.json ================================================ { "answer": { "domains": [ { "dname": "test.ru", "result": "success", "service_id": 12345 } ] }, "charset": "utf-8", "messagestore": null, "result": "success" } ================================================ FILE: providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json ================================================ { "charset": "utf-8", "error_code": "NO_AUTH", "error_params": { "command_name": "nop/zone/add_txt" }, "error_text": "No authorization mechanism selected", "messagestore": null, "result": "error" } ================================================ FILE: providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json ================================================ { "answer": { "domains": [ { "error_code": "NO_DOMAIN", "error_text": "domain_name not given or empty", "result": "error" } ] }, "charset": "utf-8", "messagestore": null, "result": "success" } ================================================ FILE: providers/dns/regru/internal/fixtures/remove_record.json ================================================ { "answer": { "domains": [ { "dname": "test.ru", "result": "success", "service_id": 12345 } ] }, "charset": "utf-8", "messagestore": null, "result": "success" } ================================================ FILE: providers/dns/regru/internal/fixtures/remove_record_error_auth.json ================================================ { "charset" : "utf-8", "error_code" : "NO_AUTH", "error_params" : { "command_name" : "nop/zone/remove_record" }, "error_text" : "No authorization mechanism selected", "messagestore" : null, "result" : "error" } ================================================ FILE: providers/dns/regru/internal/fixtures/remove_record_error_domain.json ================================================ { "answer" : { "domains" : [ { "error_code" : "NO_DOMAIN", "error_text" : "domain_name not given or empty", "result" : "error" } ] }, "charset" : "utf-8", "messagestore" : null, "result" : "success" } ================================================ FILE: providers/dns/regru/internal/readme.md ================================================ Test account (with the default endpoint): - user: `test` - password: `test` Noop endpoint: - https://api.reg.ru/api/regru2/nop ================================================ FILE: providers/dns/regru/internal/types.go ================================================ package internal import "fmt" const successResult = "success" // APIResponse is the representation of an API response. type APIResponse struct { Result string `json:"result"` Answer *Answer `json:"answer,omitempty"` ErrorCode string `json:"error_code,omitempty"` ErrorText string `json:"error_text,omitempty"` } func (a APIResponse) Error() string { return fmt.Sprintf("API %s: %s: %s", a.Result, a.ErrorCode, a.ErrorText) } // HasError returns an error is the response contains an error. func (a APIResponse) HasError() error { if a.Result != successResult { return a } if a.Answer != nil { for _, domResp := range a.Answer.Domains { if domResp.Result != successResult { return domResp } } } return nil } // Answer is the representation of an API response answer. type Answer struct { Domains []DomainResponse `json:"domains,omitempty"` } // DomainResponse is the representation of an API response answer domain. type DomainResponse struct { Result string `json:"result"` DName string `json:"dname"` ErrorCode string `json:"error_code,omitempty"` ErrorText string `json:"error_text,omitempty"` } func (d DomainResponse) Error() string { return fmt.Sprintf("API %s: %s: %s", d.Result, d.ErrorCode, d.ErrorText) } // AddTxtRequest is the representation of the payload of a request to add a TXT record. type AddTxtRequest struct { Domains []Domain `json:"domains,omitempty"` SubDomain string `json:"subdomain,omitempty"` Text string `json:"text,omitempty"` OutputContentType string `json:"output_content_type,omitempty"` } // RemoveRecordRequest is the representation of the payload of a request to remove a record. type RemoveRecordRequest struct { Domains []Domain `json:"domains,omitempty"` SubDomain string `json:"subdomain,omitempty"` Content string `json:"content,omitempty"` RecordType string `json:"record_type,omitempty"` OutputContentType string `json:"output_content_type,omitempty"` } // Domain is the representation of a Domain. type Domain struct { DName string `json:"dname"` } ================================================ FILE: providers/dns/regru/regru.go ================================================ // Package regru implements a DNS provider for solving the DNS-01 challenge using reg.ru DNS. package regru import ( "context" "crypto/tls" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/regru/internal" ) // Environment variables names. const ( envNamespace = "REGRU_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTLSCert = envNamespace + "TLS_CERT" EnvTLSKey = envNamespace + "TLS_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string TLSCert string TLSKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for reg.ru. // Credentials must be passed in the environment variables: // REGRU_USERNAME and REGRU_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("regru: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.TLSCert = env.GetOrDefaultString(EnvTLSCert, "") config.TLSKey = env.GetOrDefaultString(EnvTLSKey, "") return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for reg.ru. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("regru: the configuration of the DNS provider is nil") } if config.Username == "" || config.Password == "" { return nil, errors.New("regru: incomplete credentials, missing username and/or password") } client := internal.NewClient(config.Username, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) if config.TLSCert != "" || config.TLSKey != "" { if config.TLSCert == "" { return nil, errors.New("regru: TLS certificate is missing") } if config.TLSKey == "" { return nil, errors.New("regru: TLS key is missing") } tlsCert, err := tls.X509KeyPair([]byte(config.TLSCert), []byte(config.TLSKey)) if err != nil { return nil, fmt.Errorf("regru: %w", err) } client.HTTPClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ Certificates: []tls.Certificate{tlsCert}, }, } } return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("regru: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("regru: %w", err) } err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value) if err != nil { return fmt.Errorf("regru: failed to create TXT records [domain: %s, sub domain: %s]: %w", dns01.UnFqdn(authZone), subDomain, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("regru: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("regru: %w", err) } err = d.client.RemoveTxtRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value) if err != nil { return fmt.Errorf("regru: failed to remove TXT records [domain: %s, sub domain: %s]: %w", dns01.UnFqdn(authZone), subDomain, err) } return nil } ================================================ FILE: providers/dns/regru/regru.toml ================================================ Name = "reg.ru" Description = '''''' URL = "https://www.reg.ru/" Code = "regru" Since = "v3.5.0" Example = ''' REGRU_USERNAME=xxxxxx \ REGRU_PASSWORD=yyyyyy \ lego --dns regru -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] REGRU_USERNAME = "API username" REGRU_PASSWORD = "API password" [Configuration.Additional] REGRU_TLS_CERT = "authentication certificate" REGRU_TLS_KEY = "authentication private key" REGRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" REGRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" REGRU_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" REGRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.reg.ru/support/help/api2" ================================================ FILE: providers/dns/regru/regru_test.go ================================================ package regru import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvUsername: "", EnvPassword: "", }, expected: "regru: some credentials information are missing: REGRU_USERNAME,REGRU_PASSWORD", }, { desc: "missing api key", envVars: map[string]string{ EnvUsername: "", EnvPassword: "api_password", }, expected: "regru: some credentials information are missing: REGRU_USERNAME", }, { desc: "missing secret key", envVars: map[string]string{ EnvUsername: "api_username", EnvPassword: "", }, expected: "regru: some credentials information are missing: REGRU_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "api_username", password: "api_password", }, { desc: "missing credentials", expected: "regru: incomplete credentials, missing username and/or password", }, { desc: "missing username", username: "", password: "api_password", expected: "regru: incomplete credentials, missing username and/or password", }, { desc: "missing password", username: "api_username", password: "", expected: "regru: incomplete credentials, missing username and/or password", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/rfc2136/internal/fixtures/invalid_field.conf ================================================ key "example.com" { algorithm; secret "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="; }; ================================================ FILE: providers/dns/rfc2136/internal/fixtures/invalid_key.conf ================================================ key { algorithm hmac-sha256; secret "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="; }; ================================================ FILE: providers/dns/rfc2136/internal/fixtures/mising_algo.conf ================================================ key "example.com" { secret "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="; }; ================================================ FILE: providers/dns/rfc2136/internal/fixtures/missing_secret.conf ================================================ key "example.com" { algorithm hmac-sha256; }; ================================================ FILE: providers/dns/rfc2136/internal/fixtures/sample.conf ================================================ key "example.com" { algorithm hmac-sha256; secret "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="; }; ================================================ FILE: providers/dns/rfc2136/internal/fixtures/text_after.conf ================================================ key "example.com" { algorithm hmac-sha256; secret "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="; }; key "example.org" { algorithm hmac-sha512; secret "v6CkK3gop6HXj4+dcWiLXLGSYKVY5J1cTMjDsdl/Ah9B8aWfTgjwFBoHHyiHWSyvwWPDuEIRs2Pqm8nedca4+g=="; }; ================================================ FILE: providers/dns/rfc2136/internal/fixtures/text_before.conf ================================================ foo { bar example; }; key "example.com" { algorithm hmac-sha256; secret "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="; }; ================================================ FILE: providers/dns/rfc2136/internal/readme.md ================================================ # TSIG Key File How to generate example: ```console $ docker run --rm -it -v $(pwd):/app -w /app alpine sh /app # apk add bind /app # tsig-keygen example.com > sample1.conf /app # tsig-keygen -a hmac-sha512 example.com > sample2.conf ``` ================================================ FILE: providers/dns/rfc2136/internal/tsigkey.go ================================================ package internal import ( "bufio" "fmt" "os" "strings" ) type Key struct { Name string Algorithm string Secret string } // ReadTSIGFile reads TSIG key file generated with `tsig-keygen`. func ReadTSIGFile(filename string) (*Key, error) { file, err := os.Open(filename) if err != nil { return nil, fmt.Errorf("open file: %w", err) } defer func() { _ = file.Close() }() key := &Key{} var read bool scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), ";")) if line == "" { continue } if read && line == "}" { break } fields := strings.Fields(line) switch { case fields[0] == "key": read = true if len(fields) != 3 { return nil, fmt.Errorf("invalid key line: %s", line) } key.Name = safeUnquote(fields[1]) case !read: continue default: if len(fields) != 2 { continue } v := safeUnquote(fields[1]) switch safeUnquote(fields[0]) { case "algorithm": key.Algorithm = v case "secret": key.Secret = v default: continue } } } return key, nil } func safeUnquote(v string) string { if len(v) < 2 { // empty or single character string return v } if v[0] == '"' && v[len(v)-1] == '"' { // string wrapped in quotes return v[1 : len(v)-1] } return v } ================================================ FILE: providers/dns/rfc2136/internal/tsigkey_test.go ================================================ package internal import ( "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReadTSIGFile(t *testing.T) { testCases := []struct { desc string filename string expected *Key }{ { desc: "basic", filename: "sample.conf", expected: &Key{Name: "example.com", Algorithm: "hmac-sha256", Secret: "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="}, }, { desc: "data before the key", filename: "text_before.conf", expected: &Key{Name: "example.com", Algorithm: "hmac-sha256", Secret: "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="}, }, { desc: "data after the key", filename: "text_after.conf", expected: &Key{Name: "example.com", Algorithm: "hmac-sha256", Secret: "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="}, }, { desc: "ignore missing secret", filename: "missing_secret.conf", expected: &Key{Name: "example.com", Algorithm: "hmac-sha256"}, }, { desc: "ignore missing algorithm", filename: "mising_algo.conf", expected: &Key{Name: "example.com", Secret: "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="}, }, { desc: "ignore invalid field format", filename: "invalid_field.conf", expected: &Key{Name: "example.com", Secret: "TCG5A6/lOHUGbW0e/9RYYbzWDFMlj1pIxCvybLBayBg="}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() key, err := ReadTSIGFile(filepath.Join("fixtures", test.filename)) require.NoError(t, err) assert.Equal(t, test.expected, key) }) } } func TestReadTSIGFile_error(t *testing.T) { if runtime.GOOS != "linux" { // Because error messages are different on Windows. t.Skip("only for UNIX systems") } testCases := []struct { desc string filename string expected string }{ { desc: "missing file", filename: "missing.conf", expected: "open file: open fixtures/missing.conf: no such file or directory", }, { desc: "invalid key format", filename: "invalid_key.conf", expected: "invalid key line: key {", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() _, err := ReadTSIGFile(filepath.Join("fixtures", test.filename)) require.Error(t, err) require.EqualError(t, err, test.expected) }) } } ================================================ FILE: providers/dns/rfc2136/rfc2136.go ================================================ // Package rfc2136 implements a DNS provider for solving the DNS-01 challenge using the rfc2136 dynamic update. package rfc2136 import ( "errors" "fmt" "net" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/rfc2136/internal" "github.com/miekg/dns" ) // Environment variables names. const ( envNamespace = "RFC2136_" EnvTSIGFile = envNamespace + "TSIG_FILE" EnvTSIGKey = envNamespace + "TSIG_KEY" EnvTSIGSecret = envNamespace + "TSIG_SECRET" EnvTSIGAlgorithm = envNamespace + "TSIG_ALGORITHM" EnvNameserver = envNamespace + "NAMESERVER" EnvDNSTimeout = envNamespace + "DNS_TIMEOUT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Nameserver string TSIGFile string TSIGAlgorithm string TSIGKey string TSIGSecret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int SequenceInterval time.Duration DNSTimeout time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TSIGAlgorithm: env.GetOrDefaultString(EnvTSIGAlgorithm, dns.HmacSHA1), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, env.GetOrDefaultSecond("RFC2136_TIMEOUT", dns01.DefaultPropagationTimeout)), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), DNSTimeout: env.GetOrDefaultSecond(EnvDNSTimeout, 10*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config } // NewDNSProvider returns a DNSProvider instance configured for rfc2136 // dynamic update. Configured with environment variables: // RFC2136_NAMESERVER: Network address in the form "host" or "host:port". // RFC2136_TSIG_ALGORITHM: Defaults to hmac-md5.sig-alg.reg.int. (HMAC-MD5). // See https://github.com/miekg/dns/blob/master/tsig.go for supported values. // RFC2136_TSIG_KEY: Name of the secret key as defined in DNS server configuration. // RFC2136_TSIG_SECRET: Secret key payload. // RFC2136_PROPAGATION_TIMEOUT: DNS propagation timeout in time.ParseDuration format. (60s) // To disable TSIG authentication, leave the RFC2136_TSIG* variables unset. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvNameserver) if err != nil { return nil, fmt.Errorf("rfc2136: %w", err) } config := NewDefaultConfig() config.Nameserver = values[EnvNameserver] config.TSIGFile = env.GetOrDefaultString(EnvTSIGFile, "") config.TSIGKey = env.GetOrFile(EnvTSIGKey) config.TSIGSecret = env.GetOrFile(EnvTSIGSecret) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for rfc2136. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("rfc2136: the configuration of the DNS provider is nil") } if config.Nameserver == "" { return nil, errors.New("rfc2136: nameserver missing") } if config.TSIGFile != "" { key, err := internal.ReadTSIGFile(config.TSIGFile) if err != nil { return nil, fmt.Errorf("rfc2136: read TSIG file %s: %w", config.TSIGFile, err) } config.TSIGAlgorithm = key.Algorithm config.TSIGKey = key.Name config.TSIGSecret = key.Secret } // Append the default DNS port if none is specified. if _, _, err := net.SplitHostPort(config.Nameserver); err != nil { if strings.Contains(err.Error(), "missing port") { config.Nameserver = net.JoinHostPort(config.Nameserver, "53") } else { return nil, fmt.Errorf("rfc2136: %w", err) } } if config.TSIGKey == "" || config.TSIGSecret == "" { config.TSIGKey = "" config.TSIGSecret = "" } else { // zonename must be in canonical form (lowercase, fqdn, see RFC 4034 Section 6.2) config.TSIGKey = dns.CanonicalName(config.TSIGKey) } if config.TSIGAlgorithm == "" { config.TSIGAlgorithm = dns.HmacSHA1 } else { // To be compatible with https://github.com/miekg/dns/blob/master/tsig.go config.TSIGAlgorithm = dns.Fqdn(config.TSIGAlgorithm) } switch config.TSIGAlgorithm { case dns.HmacSHA1, dns.HmacSHA224, dns.HmacSHA256, dns.HmacSHA384, dns.HmacSHA512: // valid algorithm default: return nil, fmt.Errorf("rfc2136: unsupported TSIG algorithm: %s", config.TSIGAlgorithm) } return &DNSProvider{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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.changeRecord("INSERT", info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("rfc2136: failed to insert: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.changeRecord("REMOVE", info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("rfc2136: failed to remove: %w", err) } return nil } func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { // Find the zone for the given fqdn zone, err := dns01.FindZoneByFqdnCustom(fqdn, []string{d.config.Nameserver}) if err != nil { return err } // Create RR rrs := []dns.RR{&dns.TXT{ Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)}, Txt: []string{value}, }} // Create dynamic update packet m := new(dns.Msg).SetUpdate(zone) switch action { case "INSERT": // Always remove old challenge left over from who knows what. m.RemoveRRset(rrs) m.Insert(rrs) case "REMOVE": m.Remove(rrs) default: return fmt.Errorf("unexpected action: %s", action) } // Setup client c := &dns.Client{Timeout: d.config.DNSTimeout} // TSIG authentication / msg signing if d.config.TSIGKey != "" && d.config.TSIGSecret != "" { m.SetTsig(d.config.TSIGKey, d.config.TSIGAlgorithm, 300, time.Now().Unix()) // Secret(s) for TSIG map[]. c.TsigSecret = map[string]string{d.config.TSIGKey: d.config.TSIGSecret} } // Send the query reply, _, err := c.Exchange(m, d.config.Nameserver) if err != nil { return fmt.Errorf("DNS update failed: %w", err) } if reply != nil && reply.Rcode != dns.RcodeSuccess { return fmt.Errorf("DNS update failed: server replied: %s", dns.RcodeToString[reply.Rcode]) } return nil } ================================================ FILE: providers/dns/rfc2136/rfc2136.toml ================================================ Name = "RFC2136" Description = '''''' URL = "https://www.rfc-editor.org/rfc/rfc2136.html" Code = "rfc2136" Since = "v0.3.0" Example = ''' RFC2136_NAMESERVER=127.0.0.1 \ RFC2136_TSIG_KEY=example.com \ RFC2136_TSIG_ALGORITHM=hmac-sha256. \ RFC2136_TSIG_SECRET=YWJjZGVmZGdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNDU= \ lego --dns rfc2136 -d '*.example.com' -d example.com run ## --- keyname=example.com; keyfile=example.com.key; tsig-keygen $keyname > $keyfile RFC2136_NAMESERVER=127.0.0.1 \ RFC2136_TSIG_FILE="$keyfile" \ lego --dns rfc2136 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] RFC2136_TSIG_KEY = "Name of the secret key as defined in DNS server configuration. To disable TSIG authentication, leave the `RFC2136_TSIG_KEY` variable unset." RFC2136_TSIG_SECRET = "Secret key payload. To disable TSIG authentication, leave the `RFC2136_TSIG_SECRET` variable unset." RFC2136_TSIG_ALGORITHM = "TSIG algorithm. See [miekg/dns#tsig.go](https://github.com/miekg/dns/blob/master/tsig.go) for supported values. To disable TSIG authentication, leave the `RFC2136_TSIG_KEY` or `RFC2136_TSIG_SECRET` variables unset." RFC2136_NAMESERVER = 'Network address in the form "host" or "host:port"' [Configuration.Additional] RFC2136_TSIG_FILE = "Path to a key file generated by tsig-keygen" RFC2136_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" RFC2136_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" RFC2136_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" RFC2136_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" RFC2136_DNS_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://www.rfc-editor.org/rfc/rfc2136.html" ================================================ FILE: providers/dns/rfc2136/rfc2136_test.go ================================================ package rfc2136 import ( "bytes" "strings" "testing" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/dnsmock" "github.com/miekg/dns" "github.com/stretchr/testify/require" ) const ( fakeDomain = "123456789.www.example.com" fakeKeyAuth = "123d==" fakeValue = "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" fakeFqdn = "_acme-challenge.123456789.www.example.com." fakeZone = "example.com." fakeTTL = 120 fakeTsigKey = "example.com." fakeTsigSecret = "IwBTJx9wrDp4Y1RyC3H0gA==" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvTSIGFile, EnvTSIGKey, EnvTSIGSecret, EnvTSIGAlgorithm, EnvNameserver, EnvDNSTimeout, ).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvNameserver: "example.com", }, }, { desc: "missing nameserver", envVars: map[string]string{ EnvNameserver: "", }, expected: "rfc2136: some credentials information are missing: RFC2136_NAMESERVER", }, { desc: "invalid algorithm", envVars: map[string]string{ EnvNameserver: "example.com", EnvTSIGKey: "", EnvTSIGSecret: "", EnvTSIGAlgorithm: "foo", }, expected: "rfc2136: unsupported TSIG algorithm: foo.", }, { desc: "valid TSIG file", envVars: map[string]string{ EnvNameserver: "example.com", EnvTSIGFile: "./internal/fixtures/sample.conf", }, }, { desc: "invalid TSIG file", envVars: map[string]string{ EnvNameserver: "example.com", EnvTSIGFile: "./internal/fixtures/invalid_key.conf", }, expected: "rfc2136: read TSIG file ./internal/fixtures/invalid_key.conf: invalid key line: key {", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string nameserver string tsigFile string tsigAlgorithm string tsigKey string tsigSecret string }{ { desc: "success", nameserver: "example.com", }, { desc: "missing nameserver", expected: "rfc2136: nameserver missing", }, { desc: "invalid algorithm", nameserver: "example.com", tsigAlgorithm: "foo", expected: "rfc2136: unsupported TSIG algorithm: foo.", }, { desc: "valid TSIG file", nameserver: "example.com", tsigFile: "./internal/fixtures/sample.conf", }, { desc: "invalid TSIG file", nameserver: "example.com", tsigFile: "./internal/fixtures/invalid_key.conf", expected: "rfc2136: read TSIG file ./internal/fixtures/invalid_key.conf: invalid key line: key {", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Nameserver = test.nameserver config.TSIGFile = test.tsigFile config.TSIGAlgorithm = test.tsigAlgorithm config.TSIGKey = test.tsigKey config.TSIGSecret = test.tsigSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present_success(t *testing.T) { dns01.ClearFqdnCache() addr := dnsmock.NewServer(). Query(fakeZone+" SOA", dnsmock.SOA("")). Update(fakeZone+" SOA", dnsmock.Noop). Build(t) config := NewDefaultConfig() config.Nameserver = addr.String() provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(fakeDomain, "", fakeKeyAuth) require.NoError(t, err) } func TestDNSProvider_Present_success_updatePacket(t *testing.T) { dns01.ClearFqdnCache() reqChan := make(chan *dns.Msg, 1) addr := dnsmock.NewServer(). Query("_acme-challenge.123456789.www.example.com. SOA", dnsmock.SOA(fakeZone)). Update(fakeZone+" SOA", func(w dns.ResponseWriter, req *dns.Msg) { dnsmock.Noop(w, req) // Only talk back when it is not the SOA RR. reqChan <- req }). Build(t) config := NewDefaultConfig() config.Nameserver = addr.String() provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(fakeDomain, "", fakeKeyAuth) require.NoError(t, err) select { case <-time.After(time.Second): t.Fatal("timeout waiting for request") case rcvMsg := <-reqChan: txtRR := &dns.TXT{ Hdr: dns.RR_Header{Name: fakeFqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: fakeTTL}, Txt: []string{fakeValue}, } m := new(dns.Msg).SetUpdate(fakeZone) m.RemoveRRset([]dns.RR{txtRR}) m.Insert([]dns.RR{txtRR}) expected, err := m.Pack() require.NoError(t, err, "error packing") rcvMsg.Id = m.Id actual, err := rcvMsg.Pack() require.NoError(t, err, "error packing") if !bytes.Equal(actual, expected) { tmp := new(dns.Msg) require.NoError(t, tmp.Unpack(actual)) t.Errorf("Expected msg:\n%s", m) t.Errorf("Actual msg:\n%s", tmp) } } } func TestDNSProvider_Present_error(t *testing.T) { dns01.ClearFqdnCache() addr := dnsmock.NewServer(). Query(fakeZone+" SOA", dnsmock.Error(dns.RcodeNotZone)). Build(t) config := NewDefaultConfig() config.Nameserver = addr.String() provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(fakeDomain, "", fakeKeyAuth) require.Error(t, err) if !strings.Contains(err.Error(), "NOTZONE") { t.Errorf("Expected Present() to return an error with the 'NOTZONE' rcode string, but it did not: %v", err) } } func TestDNSProvider_Present_tsig_success(t *testing.T) { dns01.ClearFqdnCache() addr := dnsmock.NewServer(). Query(fakeZone+" SOA", dnsmock.SOA("")). Update(fakeZone+" SOA", handleTSIG). Build(t, func(server *dns.Server) error { server.TsigSecret = map[string]string{fakeTsigKey: fakeTsigSecret} return nil }) config := NewDefaultConfig() config.Nameserver = addr.String() config.TSIGKey = fakeTsigKey config.TSIGSecret = fakeTsigSecret provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(fakeDomain, "", fakeKeyAuth) require.NoError(t, err) } func TestDNSProvider_Present_tsig_error(t *testing.T) { dns01.ClearFqdnCache() addr := dnsmock.NewServer(). Query(fakeZone+" SOA", dnsmock.SOA("")). Update(fakeZone+" SOA", handleTSIG). Build(t, func(server *dns.Server) error { server.TsigSecret = map[string]string{"example.org": fakeTsigSecret} return nil }) config := NewDefaultConfig() config.Nameserver = addr.String() config.TSIGKey = fakeTsigKey config.TSIGSecret = fakeTsigSecret provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(fakeDomain, "", fakeKeyAuth) require.Error(t, err) require.EqualError(t, err, "rfc2136: failed to insert: DNS update failed: server replied: NOTZONE") } func handleTSIG(w dns.ResponseWriter, req *dns.Msg) { m := new(dns.Msg) tsig := req.IsTsig() if tsig == nil { _ = w.WriteMsg(m.SetRcode(req, dns.RcodeRefused)) return } err := w.TsigStatus() if err != nil { _ = w.WriteMsg(m.SetRcode(req, dns.RcodeNotZone)) return } // Validated _ = w.WriteMsg(m. SetReply(req). SetTsig(tsig.Hdr.Name, tsig.Algorithm, tsig.Fudge, time.Now().Unix()), ) } ================================================ FILE: providers/dns/rimuhosting/rimuhosting.go ================================================ // Package rimuhosting implements a DNS provider for solving the DNS-01 challenge using RimuHosting DNS. package rimuhosting import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/rimuhosting" ) // Environment variables names. const ( envNamespace = "RIMUHOSTING_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = rimuhosting.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, rimuhosting.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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for RimuHosting. // Credentials must be passed in the environment variables. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("rimuhosting: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for RimuHosting. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("rimuhosting: the configuration of the DNS provider is nil") } provider, err := rimuhosting.NewDNSProviderConfig(config, "") if err != nil { return nil, fmt.Errorf("rimuhosting: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("rimuhosting: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("rimuhosting: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/rimuhosting/rimuhosting.toml ================================================ Name = "RimuHosting" Description = '''''' URL = "https://rimuhosting.com" Code = "rimuhosting" Since = "v0.3.5" Example = ''' RIMUHOSTING_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns rimuhosting -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] RIMUHOSTING_API_KEY = "User API key" [Configuration.Additional] RIMUHOSTING_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" RIMUHOSTING_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" RIMUHOSTING_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" RIMUHOSTING_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://rimuhosting.com/dns/dyndns.jsp" ================================================ FILE: providers/dns/rimuhosting/rimuhosting_test.go ================================================ package rimuhosting import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "rimuhosting: some credentials information are missing: RIMUHOSTING_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string apiKey string secretKey string }{ { desc: "success", apiKey: "api_key", secretKey: "api_secret", }, { desc: "missing api key", apiKey: "", secretKey: "api_secret", expected: "rimuhosting: incomplete credentials, missing API key", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml ================================================ /change/123456 PENDING 2016-02-10T01:36:41.958Z ================================================ FILE: providers/dns/route53/fixtures/getChangeResponse.xml ================================================ 123456 INSYNC 2016-02-10T01:36:41.958Z ================================================ FILE: providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml ================================================ /hostedzone/ABCDEFG example.com. D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A Test comment false 10 true example2.com ZLT12321321124 1 ================================================ FILE: providers/dns/route53/route53.go ================================================ // Package route53 implements a DNS provider for solving the DNS-01 challenge using AWS Route 53 DNS. package route53 import ( "context" "errors" "fmt" "math/rand" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/retry" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/route53" awstypes "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" ) // Environment variables names. const ( envNamespace = "AWS_" EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY" EnvRegion = envNamespace + "REGION" EnvHostedZoneID = envNamespace + "HOSTED_ZONE_ID" EnvMaxRetries = envNamespace + "MAX_RETRIES" EnvAssumeRoleArn = envNamespace + "ASSUME_ROLE_ARN" EnvExternalID = envNamespace + "EXTERNAL_ID" EnvPrivateZone = envNamespace + "PRIVATE_ZONE" EnvWaitForRecordSetsChanged = envNamespace + "WAIT_FOR_RECORD_SETS_CHANGED" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { // Static credential chain. // These are not set via environment for the time being and are only used if they are explicitly provided. AccessKeyID string SecretAccessKey string SessionToken string Region string HostedZoneID string MaxRetries int AssumeRoleArn string ExternalID string PrivateZone bool WaitForRecordSetsChanged bool TTL int PropagationTimeout time.Duration PollingInterval time.Duration Client *route53.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ HostedZoneID: env.GetOrFile(EnvHostedZoneID), MaxRetries: env.GetOrDefaultInt(EnvMaxRetries, 5), AssumeRoleArn: env.GetOrDefaultString(EnvAssumeRoleArn, ""), ExternalID: env.GetOrDefaultString(EnvExternalID, ""), PrivateZone: env.GetOrDefaultBool(EnvPrivateZone, false), WaitForRecordSetsChanged: env.GetOrDefaultBool(EnvWaitForRecordSetsChanged, true), TTL: env.GetOrDefaultInt(EnvTTL, 10), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *route53.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for the AWS Route 53 service. // // AWS Credentials are automatically detected in the following locations and prioritized in the following order: // 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, // AWS_REGION, [AWS_SESSION_TOKEN] // 2. Shared credentials file (defaults to ~/.aws/credentials) // 3. Amazon EC2 IAM role // // If AWS_HOSTED_ZONE_ID is not set, Lego tries to determine the correct public hosted zone via the FQDN. // // See also: https://github.com/aws/aws-sdk-go/wiki/configuring-sdk func NewDNSProvider() (*DNSProvider, error) { return NewDNSProviderConfig(NewDefaultConfig()) } // NewDNSProviderConfig takes a given config and returns a custom configured DNSProvider instance. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("route53: the configuration of the Route53 DNS provider is nil") } if config.Client != nil { return &DNSProvider{client: config.Client, config: config}, nil } ctx := context.Background() cfg, err := createAWSConfig(ctx, config) if err != nil { return nil, err } return &DNSProvider{ client: route53.NewFromConfig(cfg), config: config, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) hostedZoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("route53: failed to determine hosted zone ID: %w", err) } records, err := d.getExistingRecordSets(ctx, hostedZoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("route53: %w", err) } realValue := `"` + info.Value + `"` var found bool for _, record := range records { if ptr.Deref(record.Value) == realValue { found = true } } if !found { records = append(records, awstypes.ResourceRecord{Value: aws.String(realValue)}) } recordSet := &awstypes.ResourceRecordSet{ Name: aws.String(info.EffectiveFQDN), Type: "TXT", TTL: aws.Int64(int64(d.config.TTL)), ResourceRecords: records, } err = d.changeRecord(ctx, awstypes.ChangeActionUpsert, hostedZoneID, recordSet) if err != nil { return fmt.Errorf("route53: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) hostedZoneID, err := d.getHostedZoneID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("failed to determine Route 53 hosted zone ID: %w", err) } existingRecords, err := d.getExistingRecordSets(ctx, hostedZoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("route53: %w", err) } if len(existingRecords) == 0 { return nil } var nonLegoRecords []awstypes.ResourceRecord for _, record := range existingRecords { if ptr.Deref(record.Value) != `"`+info.Value+`"` { nonLegoRecords = append(nonLegoRecords, record) } } action := awstypes.ChangeActionUpsert recordSet := &awstypes.ResourceRecordSet{ Name: aws.String(info.EffectiveFQDN), Type: "TXT", TTL: aws.Int64(int64(d.config.TTL)), ResourceRecords: nonLegoRecords, } // If the records are only records created by lego. if len(nonLegoRecords) == 0 { action = awstypes.ChangeActionDelete recordSet.ResourceRecords = existingRecords } err = d.changeRecord(ctx, action, hostedZoneID, recordSet) if err != nil { return fmt.Errorf("route53: %w", err) } return nil } func (d *DNSProvider) changeRecord(ctx context.Context, action awstypes.ChangeAction, hostedZoneID string, recordSet *awstypes.ResourceRecordSet) error { recordSetInput := &route53.ChangeResourceRecordSetsInput{ HostedZoneId: aws.String(hostedZoneID), ChangeBatch: &awstypes.ChangeBatch{ Comment: aws.String("Managed by Lego"), Changes: []awstypes.Change{{ Action: action, ResourceRecordSet: recordSet, }}, }, } resp, err := d.client.ChangeResourceRecordSets(ctx, recordSetInput) if err != nil { return fmt.Errorf("failed to change record set: %w", err) } changeID := resp.ChangeInfo.Id if d.config.WaitForRecordSetsChanged { return wait.Retry(ctx, func() error { resp, err := d.client.GetChange(ctx, &route53.GetChangeInput{Id: changeID}) if err != nil { return fmt.Errorf("failed to query change status: %w", err) } if resp.ChangeInfo.Status != awstypes.ChangeStatusInsync { return fmt.Errorf("unable to retrieve change: ID=%s, status=%s", ptr.Deref(changeID), resp.ChangeInfo.Status) } return nil }, backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), backoff.WithMaxElapsedTime(d.config.PropagationTimeout), ) } return nil } func (d *DNSProvider) getExistingRecordSets(ctx context.Context, hostedZoneID, fqdn string) ([]awstypes.ResourceRecord, error) { listInput := &route53.ListResourceRecordSetsInput{ HostedZoneId: aws.String(hostedZoneID), StartRecordName: aws.String(fqdn), StartRecordType: "TXT", } recordSetsOutput, err := d.client.ListResourceRecordSets(ctx, listInput) if err != nil { return nil, err } if recordSetsOutput == nil { return nil, nil } var records []awstypes.ResourceRecord for _, recordSet := range recordSetsOutput.ResourceRecordSets { if ptr.Deref(recordSet.Name) == fqdn { records = append(records, recordSet.ResourceRecords...) } } return records, nil } func (d *DNSProvider) getHostedZoneID(ctx context.Context, fqdn string) (string, error) { if d.config.HostedZoneID != "" { return d.config.HostedZoneID, nil } authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err) } // .DNSName should not have a trailing dot reqParams := &route53.ListHostedZonesByNameInput{ DNSName: aws.String(dns01.UnFqdn(authZone)), } resp, err := d.client.ListHostedZonesByName(ctx, reqParams) if err != nil { return "", err } var hostedZoneID string for _, hostedZone := range resp.HostedZones { // .Name has a trailing dot if ptr.Deref(hostedZone.Name) == authZone && d.config.PrivateZone == hostedZone.Config.PrivateZone { hostedZoneID = ptr.Deref(hostedZone.Id) break } } if hostedZoneID == "" { return "", fmt.Errorf("zone %s not found for domain %s", authZone, fqdn) } hostedZoneID = strings.TrimPrefix(hostedZoneID, "/hostedzone/") return hostedZoneID, nil } func createAWSConfig(ctx context.Context, config *Config) (aws.Config, error) { if err := createAWSConfigCheckParams(config); err != nil { return aws.Config{}, err } optFns := []func(options *awsconfig.LoadOptions) error{ awsconfig.WithRetryer(func() aws.Retryer { return retry.NewStandard(func(options *retry.StandardOptions) { options.MaxAttempts = config.MaxRetries // It uses a basic exponential backoff algorithm that returns an initial // delay of ~400ms with an upper limit of ~30 seconds which should prevent // causing a high number of consecutive throttling errors. // For reference: Route 53 enforces an account-wide(!) 5req/s query limit. options.Backoff = retry.BackoffDelayerFunc(func(attempt int, err error) (time.Duration, error) { retryCount := min(attempt, 7) delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) return time.Duration(delay) * time.Millisecond, nil }) }) }), } if config.AccessKeyID != "" && config.SecretAccessKey != "" { optFns = append(optFns, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.SecretAccessKey, config.SessionToken)), ) } if config.Region != "" { optFns = append(optFns, awsconfig.WithRegion(config.Region)) } cfg, err := awsconfig.LoadDefaultConfig(ctx, optFns...) if err != nil { return aws.Config{}, err } if config.AssumeRoleArn != "" { cfg.Credentials = stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), config.AssumeRoleArn, func(options *stscreds.AssumeRoleOptions) { if config.ExternalID != "" { options.ExternalID = &config.ExternalID } }) } return cfg, nil } func createAWSConfigCheckParams(config *Config) error { if config == nil { return errors.New("config is nil") } switch { case config.SessionToken != "" && config.AccessKeyID == "" && config.SecretAccessKey == "": return errors.New("SessionToken must be supplied with AccessKeyID and SecretAccessKey") case config.AccessKeyID == "" && config.SecretAccessKey != "" || config.AccessKeyID != "" && config.SecretAccessKey == "": return errors.New("AccessKeyID and SecretAccessKey must be supplied together") } return nil } ================================================ FILE: providers/dns/route53/route53.toml ================================================ Name = "Amazon Route 53" Description = '''''' URL = "https://aws.amazon.com/route53/" Code = "route53" Since = "v0.3.0" Example = ''' AWS_ACCESS_KEY_ID=your_key_id \ AWS_SECRET_ACCESS_KEY=your_secret_access_key \ AWS_REGION=aws-region \ AWS_HOSTED_ZONE_ID=your_hosted_zone_id \ lego --dns route53 -d '*.example.com' -d example.com run ''' Additional = ''' ## Description AWS Credentials are automatically detected in the following locations and prioritized in the following order: 1. Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, [`AWS_SESSION_TOKEN`] 2. Shared credentials file (defaults to `~/.aws/credentials`, profiles can be specified using `AWS_PROFILE`) 3. Amazon EC2 IAM role The AWS Region is automatically detected in the following locations and prioritized in the following order: 1. Environment variables: `AWS_REGION` 2. Shared configuration file if `AWS_SDK_LOAD_CONFIG` is set (defaults to `~/.aws/config`, profiles can be specified using `AWS_PROFILE`) If `AWS_HOSTED_ZONE_ID` is not set, Lego tries to determine the correct public hosted zone via the FQDN. See also: - [sessions](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/sessions.html) - [Setting AWS Credentials](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-credentials) - [Setting AWS Region](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-the-region) ## IAM Policy Examples ### Broad privileges for testing purposes The following [IAM policy](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html) document grants access to the required APIs needed by lego to complete the DNS challenge. A word of caution: These permissions grant write access to any DNS record in any hosted zone, so it is recommended to narrow them down as much as possible if you are using this policy in production. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "route53:GetChange", "route53:ChangeResourceRecordSets", "route53:ListResourceRecordSets" ], "Resource": [ "arn:aws:route53:::hostedzone/*", "arn:aws:route53:::change/*" ] }, { "Effect": "Allow", "Action": "route53:ListHostedZonesByName", "Resource": "*" } ] } ``` ### Least privilege policy for production purposes The following AWS IAM policy document describes the least privilege permissions required for lego to complete the DNS challenge. Write access is limited to a specified hosted zone's DNS TXT records with a key of `_acme-challenge.example.com`. Replace `Z11111112222222333333` with your hosted zone ID and `example.com` with your domain name to use this policy. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "route53:GetChange", "Resource": "arn:aws:route53:::change/*" }, { "Effect": "Allow", "Action": "route53:ListHostedZonesByName", "Resource": "*" }, { "Effect": "Allow", "Action": [ "route53:ListResourceRecordSets" ], "Resource": [ "arn:aws:route53:::hostedzone/Z11111112222222333333" ] }, { "Effect": "Allow", "Action": [ "route53:ChangeResourceRecordSets" ], "Resource": [ "arn:aws:route53:::hostedzone/Z11111112222222333333" ], "Condition": { "ForAllValues:StringEquals": { "route53:ChangeResourceRecordSetsNormalizedRecordNames": [ "_acme-challenge.example.com" ], "route53:ChangeResourceRecordSetsRecordTypes": [ "TXT" ] } } } ] } ``` ''' [Configuration] [Configuration.Credentials] AWS_ACCESS_KEY_ID = "Managed by the AWS client. Access key ID (`AWS_ACCESS_KEY_ID_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead)" AWS_SECRET_ACCESS_KEY = "Managed by the AWS client. Secret access key (`AWS_SECRET_ACCESS_KEY_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead)" AWS_REGION = "Managed by the AWS client (`AWS_REGION_FILE` is not supported)" AWS_HOSTED_ZONE_ID = "Override the hosted zone ID." AWS_PROFILE = "Managed by the AWS client (`AWS_PROFILE_FILE` is not supported)" AWS_SDK_LOAD_CONFIG = "Managed by the AWS client. Retrieve the region from the CLI config file (`AWS_SDK_LOAD_CONFIG_FILE` is not supported)" AWS_ASSUME_ROLE_ARN = "Managed by the AWS Role ARN (`AWS_ASSUME_ROLE_ARN_FILE` is not supported)" AWS_EXTERNAL_ID = "Managed by STS AssumeRole API operation (`AWS_EXTERNAL_ID_FILE` is not supported)" AWS_WAIT_FOR_RECORD_SETS_CHANGED = "Wait for changes to be INSYNC (it can be unstable)" [Configuration.Additional] AWS_PRIVATE_ZONE = "Set to true to use private zones only (default: use public zones only)" AWS_SHARED_CREDENTIALS_FILE = "Managed by the AWS client. Shared credentials file." AWS_MAX_RETRIES = "The number of maximum returns the service will use to make an individual API request" AWS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" AWS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" AWS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" [Links] API = "https://docs.aws.amazon.com/Route53/latest/APIReference/API_Operations_Amazon_Route_53.html" GoClient = "https://github.com/aws/aws-sdk-go-v2" ================================================ FILE: providers/dns/route53/route53_integration_test.go ================================================ package route53 import ( "testing" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/stretchr/testify/require" ) func TestLiveTTL(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) domain := envTest.GetDomain() err = provider.Present(domain, "foo", "bar") require.NoError(t, err) // we need a separate R53 client here as the one in the DNS provider is unexported. fqdn := "_acme-challenge." + domain + "." ctx := t.Context() cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) svc := route53.NewFromConfig(cfg) defer func() { errC := provider.CleanUp(domain, "foo", "bar") if errC != nil { t.Log(errC) } }() zoneID, err := provider.getHostedZoneID(t.Context(), fqdn) require.NoError(t, err) params := &route53.ListResourceRecordSetsInput{ HostedZoneId: aws.String(zoneID), } resp, err := svc.ListResourceRecordSets(ctx, params) require.NoError(t, err) for _, v := range resp.ResourceRecordSets { if ptr.Deref(v.Name) == fqdn && v.Type == "TXT" && ptr.Deref(v.TTL) == 10 { return } } t.Fatalf("Could not find a TXT record for _acme-challenge.%s with a TTL of 10", domain) } ================================================ FILE: providers/dns/route53/route53_test.go ================================================ package route53 import ( "net/http/httptest" "os" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = "R53_DOMAIN" var envTest = tester.NewEnvTest( EnvAccessKeyID, EnvSecretAccessKey, EnvRegion, EnvHostedZoneID, EnvMaxRetries, EnvPrivateZone, EnvTTL, EnvPropagationTimeout, EnvPollingInterval, EnvWaitForRecordSetsChanged). WithDomain(envDomain). WithLiveTestRequirements(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion, envDomain) func Test_loadCredentials_FromEnv(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() _ = os.Setenv(EnvAccessKeyID, "123") _ = os.Setenv(EnvSecretAccessKey, "456") _ = os.Setenv(EnvRegion, "us-east-1") ctx := t.Context() cfg, err := awsconfig.LoadDefaultConfig(ctx) require.NoError(t, err) value, err := cfg.Credentials.Retrieve(ctx) require.NoError(t, err, "Expected credentials to be set from environment") expected := aws.Credentials{ AccessKeyID: "123", SecretAccessKey: "456", SessionToken: "", Source: "EnvConfigCredentials", } assert.Equal(t, expected, value) } func Test_loadRegion_FromEnv(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() _ = os.Setenv(EnvRegion, "foo") cfg, err := awsconfig.LoadDefaultConfig(t.Context()) require.NoError(t, err) assert.Equal(t, "foo", cfg.Region, "Region") } func Test_getHostedZoneID_FromEnv(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() expectedZoneID := "zoneID" _ = os.Setenv(EnvHostedZoneID, expectedZoneID) provider, err := NewDNSProvider() require.NoError(t, err) hostedZoneID, err := provider.getHostedZoneID(t.Context(), "whatever") require.NoError(t, err) assert.Equal(t, expectedZoneID, hostedZoneID) } func TestNewDefaultConfig(t *testing.T) { defer envTest.RestoreEnv() testCases := []struct { desc string envVars map[string]string expected *Config }{ { desc: "default configuration", expected: &Config{ MaxRetries: 5, TTL: 10, PropagationTimeout: 2 * time.Minute, PollingInterval: 4 * time.Second, WaitForRecordSetsChanged: true, }, }, { desc: "set values", envVars: map[string]string{ EnvMaxRetries: "10", EnvTTL: "99", EnvPropagationTimeout: "60", EnvPollingInterval: "60", EnvHostedZoneID: "abc123", EnvWaitForRecordSetsChanged: "false", }, expected: &Config{ MaxRetries: 10, TTL: 99, PropagationTimeout: 60 * time.Second, PollingInterval: 60 * time.Second, HostedZoneID: "abc123", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { envTest.ClearEnv() for key, value := range test.envVars { _ = os.Setenv(key, value) } config := NewDefaultConfig() assert.Equal(t, test.expected, config) }) } } func TestDNSProvider_Present(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() provider := servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { cfg := aws.Config{ HTTPClient: server.Client(), Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), Region: "mock-region", BaseEndpoint: aws.String(server.URL), RetryMaxAttempts: 1, } return &DNSProvider{ client: route53.NewFromConfig(cfg), config: NewDefaultConfig(), }, nil }, ). Route("GET /2013-04-01/hostedzonesbyname", servermock.ResponseFromFixture("listHostedZonesByNameResponse.xml"). WithHeader("Content-Type", "application/xml"), servermock.CheckQueryParameter().Strict(). With("dnsname", "example.com")). Route("POST /2013-04-01/hostedzone/ABCDEFG/rrset", servermock.ResponseFromFixture("changeResourceRecordSetsResponse.xml"). WithHeader("Content-Type", "application/xml")). Route("GET /2013-04-01/change/123456", servermock.ResponseFromFixture("getChangeResponse.xml"). WithHeader("Content-Type", "application/xml")). Route("GET /2013-04-01/hostedzone/ABCDEFG/rrset", servermock.Noop(). WithHeader("Content-Type", "application/xml"), servermock.CheckQueryParameter().Strict(). With("name", "_acme-challenge.example.com."). With("type", "TXT")). Build(t) domain := "example.com" keyAuth := "123456d==" err := provider.Present(domain, "", keyAuth) require.NoError(t, err) } func Test_createAWSConfig(t *testing.T) { testCases := []struct { desc string env map[string]string config *Config wantCreds aws.Credentials wantDefaultChain bool wantRegion string wantErr string }{ { desc: "config is nil", wantErr: "config is nil", }, { desc: "session token without access key id or secret access key", config: &Config{SessionToken: "foo"}, wantErr: "SessionToken must be supplied with AccessKeyID and SecretAccessKey", }, { desc: "access key id without secret access key", config: &Config{AccessKeyID: "foo"}, wantErr: "AccessKeyID and SecretAccessKey must be supplied together", }, { desc: "access key id without secret access key", config: &Config{SecretAccessKey: "foo"}, wantErr: "AccessKeyID and SecretAccessKey must be supplied together", }, { desc: "credentials from default chain", config: &Config{}, wantDefaultChain: true, }, { desc: "static credentials", config: &Config{ AccessKeyID: "one", SecretAccessKey: "two", }, wantCreds: aws.Credentials{ AccessKeyID: "one", SecretAccessKey: "two", SessionToken: "", Source: credentials.StaticCredentialsName, }, }, { desc: "static credentials with session token", config: &Config{ AccessKeyID: "one", SecretAccessKey: "two", SessionToken: "three", }, wantCreds: aws.Credentials{ AccessKeyID: "one", SecretAccessKey: "two", SessionToken: "three", Source: credentials.StaticCredentialsName, }, }, { desc: "region from env", config: &Config{}, env: map[string]string{ "AWS_REGION": "foo", }, wantDefaultChain: true, wantRegion: "foo", }, { desc: "static region", config: &Config{ Region: "one", }, env: map[string]string{ "AWS_REGION": "foo", }, wantDefaultChain: true, wantRegion: "one", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.env) ctx := t.Context() cfg, err := createAWSConfig(ctx, test.config) requireErr(t, err, test.wantErr) if err != nil { return } gotCreds, err := cfg.Credentials.Retrieve(ctx) if test.wantDefaultChain { assert.NotEqual(t, credentials.StaticCredentialsName, gotCreds.Source) } else { require.NoError(t, err) assert.Equal(t, test.wantCreds, gotCreds) } if test.wantRegion != "" { assert.Equal(t, test.wantRegion, cfg.Region) } }) } } func requireErr(t *testing.T, err error, wantErr string) { t.Helper() switch { case err != nil && wantErr == "": // force the assertion error. require.NoError(t, err) case err == nil && wantErr != "": // force the assertion error. require.EqualError(t, err, wantErr) case err != nil && wantErr != "": require.EqualError(t, err, wantErr) } } ================================================ FILE: providers/dns/safedns/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.ukfast.io/safedns/v1" const authorizationHeader = "Authorization" // Client the ANS SafeDNS client. type Client struct { authToken string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(authToken string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ authToken: authToken, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // AddRecord adds a DNS record. func (c *Client) AddRecord(ctx context.Context, zone string, record Record) (*AddRecordResponse, error) { endpoint := c.baseURL.JoinPath("zones", dns01.UnFqdn(zone), "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } respData := &AddRecordResponse{} err = c.do(req, respData) if err != nil { return nil, fmt.Errorf("add record: %w", err) } return respData, nil } // RemoveRecord removes a DNS record. func (c *Client) RemoveRecord(ctx context.Context, zone string, recordID int) error { endpoint := c.baseURL.JoinPath("zones", dns01.UnFqdn(zone), "records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } err = c.do(req, nil) if err != nil { return fmt.Errorf("remove record: %w", err) } return nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, c.authToken) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, errAPI) } ================================================ FILE: providers/dns/safedns/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /zones/example.com/records", servermock.ResponseFromFixture("add_record.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBodyFromFixture("add_record-request.json")). Build(t) record := Record{ Name: "_acme-challenge.example.com", Type: "TXT", Content: `"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"`, TTL: dns01.DefaultTTL, } response, err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &AddRecordResponse{ Data: struct { ID int `json:"id"` }{ ID: 1234567, }, Meta: struct { Location string `json:"location"` }{ Location: "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567", }, } assert.Equal(t, expected, response) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /zones/example.com/records", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) record := Record{ Name: "_acme-challenge.example.com", Type: "TXT", Content: `"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"`, TTL: dns01.DefaultTTL, } _, err := client.AddRecord(t.Context(), "example.com", record) require.EqualError(t, err, "add record: [status code: 401] Unauthenticated") } func TestClient_RemoveRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/example.com/records/1234567", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.RemoveRecord(t.Context(), "example.com", 1234567) require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/example.com/records/1234567", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.RemoveRecord(t.Context(), "example.com", 1234567) require.EqualError(t, err, "remove record: [status code: 401] Unauthenticated") } ================================================ FILE: providers/dns/safedns/internal/fixtures/add_record-request.json ================================================ { "name": "_acme-challenge.example.com", "type": "TXT", "content": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"", "ttl": 120 } ================================================ FILE: providers/dns/safedns/internal/fixtures/add_record.json ================================================ { "data": { "id": 1234567 }, "meta": { "location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567" } } ================================================ FILE: providers/dns/safedns/internal/fixtures/error.json ================================================ { "message": "Unauthenticated" } ================================================ FILE: providers/dns/safedns/internal/types.go ================================================ package internal type AddRecordResponse struct { Data struct { ID int `json:"id"` } `json:"data"` Meta struct { Location string `json:"location"` } } type Record struct { Name string `json:"name"` Type string `json:"type"` Content string `json:"content"` TTL int `json:"ttl"` } type APIError struct { Message string `json:"message"` } func (a APIError) Error() string { return a.Message } ================================================ FILE: providers/dns/safedns/safedns.go ================================================ // Package safedns implements a DNS provider for solving the DNS-01 challenge using ANS SafeDNS. package safedns import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/safedns/internal" "github.com/miekg/dns" ) // Environment variables. const ( envNamespace = "SAFEDNS_" EnvAuthToken = envNamespace + "AUTH_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AuthToken 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 { config *Config client *internal.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAuthToken) if err != nil { return nil, fmt.Errorf("safedns: %w", err) } config := NewDefaultConfig() config.AuthToken = values[EnvAuthToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ANS SafeDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("safedns: supplied configuration was nil") } if config.AuthToken == "" { return nil, errors.New("safedns: credentials missing") } client := internal.NewClient(config.AuthToken) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), }, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("safedns: could not find zone for domain %q: %w", domain, err) } record := internal.Record{ Name: dns01.UnFqdn(info.EffectiveFQDN), Type: "TXT", Content: fmt.Sprintf("%q", info.Value), TTL: d.config.TTL, } resp, err := d.client.AddRecord(context.Background(), zone, record) if err != nil { return fmt.Errorf("safedns: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = resp.Data.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record previously created. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("safedns: could not find zone for domain %q: %w", domain, err) } d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("safedns: unknown record ID for '%s'", info.EffectiveFQDN) } err = d.client.RemoveRecord(context.Background(), authZone, recordID) if err != nil { return fmt.Errorf("safedns: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/safedns/safedns.toml ================================================ Name = "ANS SafeDNS" Description = '''''' URL = "https://www.ans.co.uk/" Code = "safedns" Since = "v4.6.0" Example = ''' SAFEDNS_AUTH_TOKEN=xxxxxx \ lego --dns safedns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SAFEDNS_AUTH_TOKEN = "Authentication token" [Configuration.Additional] SAFEDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" SAFEDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" SAFEDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SAFEDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.ukfast.io/documentation/safedns" ================================================ FILE: providers/dns/safedns/safedns_test.go ================================================ package safedns import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAuthToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAuthToken: "", }, expected: "safedns: some credentials information are missing: SAFEDNS_AUTH_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string authToken string expected string }{ { desc: "success", authToken: "123", }, { desc: "missing credentials", expected: "safedns: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AuthToken = test.authToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/sakuracloud/sakuracloud.go ================================================ // Package sakuracloud implements a DNS provider for solving the DNS-01 challenge using SakuraCloud DNS. package sakuracloud import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" client "github.com/sacloud/api-client-go" "github.com/sacloud/iaas-api-go" "github.com/sacloud/iaas-api-go/defaults" "github.com/sacloud/iaas-api-go/helper/api" ) // Environment variables names. const ( envNamespace = "SAKURACLOUD_" EnvAccessToken = envNamespace + "ACCESS_TOKEN" EnvAccessTokenSecret = envNamespace + "ACCESS_TOKEN_SECRET" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string Secret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client iaas.DNSAPI } // NewDNSProvider returns a DNSProvider instance configured for SakuraCloud. // Credentials must be passed in the environment variables: // SAKURACLOUD_ACCESS_TOKEN & SAKURACLOUD_ACCESS_TOKEN_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccessToken, EnvAccessTokenSecret) if err != nil { return nil, fmt.Errorf("sakuracloud: %w", err) } config := NewDefaultConfig() config.Token = values[EnvAccessToken] config.Secret = values[EnvAccessTokenSecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for SakuraCloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("sakuracloud: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("sakuracloud: AccessToken is missing") } if config.Secret == "" { return nil, errors.New("sakuracloud: AccessSecret is missing") } defaultOption, err := api.DefaultOption() if err != nil { return nil, fmt.Errorf("sakuracloud: %w", err) } options := &api.CallerOptions{ Options: &client.Options{ AccessToken: config.Token, AccessTokenSecret: config.Secret, HttpClient: clientdebug.Wrap(config.HTTPClient), UserAgent: fmt.Sprintf("%s %s", iaas.DefaultUserAgent, useragent.Get()), }, } return &DNSProvider{ client: iaas.NewDNSOp(newCallerWithOptions(api.MergeOptions(defaultOption, options))), config: config, }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.addTXTRecord(context.Background(), info.EffectiveFQDN, info.Value, d.config.TTL) if err != nil { return fmt.Errorf("sakuracloud: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.cleanupTXTRecord(context.Background(), info.EffectiveFQDN, info.Value) if err != nil { return fmt.Errorf("sakuracloud: %w", err) } return 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 } // Extracted from https://github.com/sacloud/iaas-api-go/blob/af06b3ccc2c38625d2dc684ad39590d0ae13eed3/helper/api/caller.go#L36-L81 // Trace and fake are removed. // Related to https://github.com/sacloud/iaas-api-go/issues/376. func newCallerWithOptions(opts *api.CallerOptions) iaas.APICaller { return newCaller(opts) } func newCaller(opts *api.CallerOptions) iaas.APICaller { if opts.UserAgent == "" { opts.UserAgent = iaas.DefaultUserAgent } caller := iaas.NewClientWithOptions(opts.Options) defaults.DefaultStatePollingTimeout = 72 * time.Hour if opts.DefaultZone != "" { iaas.APIDefaultZone = opts.DefaultZone } if len(opts.Zones) > 0 { iaas.SakuraCloudZones = opts.Zones } if opts.APIRootURL != "" { if strings.HasSuffix(opts.APIRootURL, "/") { opts.APIRootURL = strings.TrimRight(opts.APIRootURL, "/") } iaas.SakuraCloudAPIRoot = opts.APIRootURL } return caller } ================================================ FILE: providers/dns/sakuracloud/sakuracloud.toml ================================================ Name = "Sakura Cloud" Description = '''''' URL = "https://cloud.sakura.ad.jp/" Code = "sakuracloud" Since = "v1.1.0" Example = ''' SAKURACLOUD_ACCESS_TOKEN=xxxxx \ SAKURACLOUD_ACCESS_TOKEN_SECRET=yyyyy \ lego --dns sakuracloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SAKURACLOUD_ACCESS_TOKEN = "Access token" SAKURACLOUD_ACCESS_TOKEN_SECRET = "Access token secret" [Configuration.Additional] SAKURACLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" SAKURACLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" SAKURACLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SAKURACLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://developer.sakura.ad.jp/cloud/api/1.1/" GoClient = "https://github.com/sacloud/iaas-api-go" ================================================ FILE: providers/dns/sakuracloud/sakuracloud_test.go ================================================ package sakuracloud import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAccessToken, EnvAccessTokenSecret). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessToken: "123", EnvAccessTokenSecret: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAccessToken: "", EnvAccessTokenSecret: "", }, expected: "sakuracloud: some credentials information are missing: SAKURACLOUD_ACCESS_TOKEN,SAKURACLOUD_ACCESS_TOKEN_SECRET", }, { desc: "missing access token", envVars: map[string]string{ EnvAccessToken: "", EnvAccessTokenSecret: "456", }, expected: "sakuracloud: some credentials information are missing: SAKURACLOUD_ACCESS_TOKEN", }, { desc: "missing token secret", envVars: map[string]string{ EnvAccessToken: "123", EnvAccessTokenSecret: "", }, expected: "sakuracloud: some credentials information are missing: SAKURACLOUD_ACCESS_TOKEN_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string secret string expected string }{ { desc: "success", token: "123", secret: "456", }, { desc: "missing credentials", expected: "sakuracloud: AccessToken is missing", }, { desc: "missing token", secret: "456", expected: "sakuracloud: AccessToken is missing", }, { desc: "missing secret", token: "123", expected: "sakuracloud: AccessSecret is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token config.Secret = test.secret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/sakuracloud/wrapper.go ================================================ package sakuracloud import ( "context" "fmt" "sync" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/sacloud/iaas-api-go" "github.com/sacloud/iaas-api-go/search" ) // This mutex is required for concurrent updates. // see: https://github.com/go-acme/lego/pull/850 var mu sync.Mutex func (d *DNSProvider) addTXTRecord(ctx context.Context, fqdn, value string, ttl int) error { mu.Lock() defer mu.Unlock() zone, err := d.getHostedZone(ctx, fqdn) if err != nil { return err } subDomain, err := dns01.ExtractSubDomain(fqdn, zone.Name) if err != nil { return err } records := append(zone.Records, &iaas.DNSRecord{ Name: subDomain, Type: "TXT", RData: value, TTL: ttl, }) _, err = d.client.UpdateSettings(ctx, zone.ID, &iaas.DNSUpdateSettingsRequest{ Records: records, SettingsHash: zone.SettingsHash, }) if err != nil { return fmt.Errorf("API call failed: %w", err) } return nil } func (d *DNSProvider) cleanupTXTRecord(ctx context.Context, fqdn, value string) error { mu.Lock() defer mu.Unlock() zone, err := d.getHostedZone(ctx, fqdn) if err != nil { return err } subDomain, err := dns01.ExtractSubDomain(fqdn, zone.Name) if err != nil { return err } var updRecords iaas.DNSRecords for _, r := range zone.Records { if !(r.Name == subDomain && r.Type == "TXT" && r.RData == value) { //nolint:staticcheck // Clearer without De Morgan's law. updRecords = append(updRecords, r) } } settings := &iaas.DNSUpdateSettingsRequest{ Records: updRecords, SettingsHash: zone.SettingsHash, } _, err = d.client.UpdateSettings(ctx, zone.ID, settings) if err != nil { return fmt.Errorf("API call failed: %w", err) } return nil } func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*iaas.DNS, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) } zoneName := dns01.UnFqdn(authZone) conditions := &iaas.FindCondition{ Filter: search.Filter{ search.Key("Name"): search.ExactMatch(zoneName), }, } res, err := d.client.Find(ctx, conditions) if err != nil { if iaas.IsNotFoundError(err) { return nil, fmt.Errorf("zone %s not found on SakuraCloud DNS: %w", zoneName, err) } return nil, fmt.Errorf("API call failed: %w", err) } for _, zone := range res.DNS { if zone.Name == zoneName { return zone, nil } } return nil, fmt.Errorf("zone %s not found", zoneName) } ================================================ FILE: providers/dns/sakuracloud/wrapper_test.go ================================================ package sakuracloud import ( "fmt" "sync" "testing" client "github.com/sacloud/api-client-go" "github.com/sacloud/iaas-api-go" "github.com/sacloud/iaas-api-go/helper/api" "github.com/stretchr/testify/require" ) func setupTest(t *testing.T) { t.Helper() t.Setenv("SAKURACLOUD_FAKE_MODE", "1") createDummyZone(t, fakeCaller()) } func fakeCaller() iaas.APICaller { return api.NewCallerWithOptions(&api.CallerOptions{ Options: &client.Options{ AccessToken: "dummy", AccessTokenSecret: "dummy", }, FakeMode: true, }) } func createDummyZone(t *testing.T, caller iaas.APICaller) { t.Helper() ctx := t.Context() dnsOp := iaas.NewDNSOp(caller) // cleanup zones, err := dnsOp.Find(ctx, &iaas.FindCondition{}) require.NoError(t, err) for _, zone := range zones.DNS { if zone.Name == "example.com" { err = dnsOp.Delete(ctx, zone.ID) require.NoError(t, err) break } } // create dummy zone _, err = iaas.NewDNSOp(caller).Create(t.Context(), &iaas.DNSCreateRequest{Name: "example.com"}) require.NoError(t, err) } func TestDNSProvider_addAndCleanupRecords(t *testing.T) { setupTest(t) config := NewDefaultConfig() config.Token = "token1" config.Secret = "secret1" p, err := NewDNSProviderConfig(config) require.NoError(t, err) t.Run("addTXTRecord", func(t *testing.T) { ctx := t.Context() err = p.addTXTRecord(ctx, "test.example.com.", "dummyValue", 10) require.NoError(t, err) updZone, e := p.getHostedZone(ctx, "test.example.com.") require.NoError(t, e) require.NotNil(t, updZone) require.Len(t, updZone.Records, 1) }) t.Run("cleanupTXTRecord", func(t *testing.T) { ctx := t.Context() err = p.cleanupTXTRecord(ctx, "test.example.com.", "dummyValue") require.NoError(t, err) updZone, e := p.getHostedZone(ctx, "test.example.com.") require.NoError(t, e) require.NotNil(t, updZone) require.Empty(t, updZone.Records) }) } func TestDNSProvider_concurrentAddAndCleanupRecords(t *testing.T) { setupTest(t) dummyRecordCount := 10 var providers []*DNSProvider for range dummyRecordCount { config := NewDefaultConfig() config.Token = "token3" config.Secret = "secret3" p, err := NewDNSProviderConfig(config) require.NoError(t, err) providers = append(providers, p) } var wg sync.WaitGroup t.Run("addTXTRecord", func(t *testing.T) { wg.Add(len(providers)) ctx := t.Context() for i, p := range providers { go func(j int, client *DNSProvider) { err := client.addTXTRecord(ctx, fmt.Sprintf("test%d.example.com.", j), "dummyValue", 10) require.NoError(t, err) wg.Done() }(i, p) } wg.Wait() updZone, err := providers[0].getHostedZone(ctx, "example.com.") require.NoError(t, err) require.NotNil(t, updZone) require.Len(t, updZone.Records, dummyRecordCount) }) t.Run("cleanupTXTRecord", func(t *testing.T) { wg.Add(len(providers)) ctx := t.Context() for i, p := range providers { go func(i int, client *DNSProvider) { err := client.cleanupTXTRecord(ctx, fmt.Sprintf("test%d.example.com.", i), "dummyValue") require.NoError(t, err) wg.Done() }(i, p) } wg.Wait() updZone, err := providers[0].getHostedZone(ctx, "example.com.") require.NoError(t, err) require.NotNil(t, updZone) require.Empty(t, updZone.Records) }) } ================================================ FILE: providers/dns/scaleway/scaleway.go ================================================ // Package scaleway implements a DNS provider for solving the DNS-01 challenge using Scaleway Domains API. // Token: https://www.scaleway.com/en/docs/generate-an-api-token/ package scaleway import ( "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" scwdomain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" "github.com/scaleway/scaleway-sdk-go/scw" ) // Environment variables names. const ( envNamespace = "SCALEWAY_" EnvAPIToken = envNamespace + "API_TOKEN" EnvProjectID = envNamespace + "PROJECT_ID" altEnvNamespace = "SCW_" EnvAccessKey = altEnvNamespace + "ACCESS_KEY" EnvSecretKey = altEnvNamespace + "SECRET_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const ( minTTL = 60 defaultPollingInterval = 10 * time.Second defaultPropagationTimeout = 120 * time.Second ) // The access key is not used by the Scaleway client. const dumpAccessKey = "SCWXXXXXXXXXXXXXXXXX" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ProjectID string Token string // TODO(ldez) rename to SecretKey in the next major. AccessKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ AccessKey: dumpAccessKey, TTL: env.GetOneWithFallback(EnvTTL, minTTL, strconv.Atoi, altEnvName(EnvTTL)), PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, defaultPropagationTimeout, env.ParseSecond, altEnvName(EnvPropagationTimeout)), PollingInterval: env.GetOneWithFallback(EnvPollingInterval, defaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *scwdomain.API } // NewDNSProvider returns a DNSProvider instance configured for Scaleway Domains API. // Credentials must be passed in the environment variables: // SCALEWAY_API_TOKEN, SCALEWAY_PROJECT_ID. func NewDNSProvider() (*DNSProvider, error) { values, err := env.GetWithFallback([]string{EnvSecretKey, EnvAPIToken}) if err != nil { return nil, fmt.Errorf("scaleway: %w", err) } config := NewDefaultConfig() config.Token = values[EnvSecretKey] config.AccessKey = env.GetOrDefaultString(EnvAccessKey, dumpAccessKey) config.ProjectID = env.GetOrFile(EnvProjectID) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for scaleway. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("scaleway: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("scaleway: credentials missing") } if config.TTL < minTTL { config.TTL = minTTL } configuration := []scw.ClientOption{ scw.WithAuth(config.AccessKey, config.Token), scw.WithUserAgent(useragent.Get()), } if config.HTTPClient != nil { configuration = append(configuration, scw.WithHTTPClient(clientdebug.Wrap(config.HTTPClient))) } if config.ProjectID != "" { configuration = append(configuration, scw.WithDefaultProjectID(config.ProjectID)) } // Create a Scaleway client clientScw, err := scw.NewClient(configuration...) if err != nil { return nil, fmt.Errorf("scaleway: %w", err) } return &DNSProvider{config: config, client: scwdomain.NewAPI(clientScw)}, 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 DNS-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) records := []*scwdomain.Record{{ Data: fmt.Sprintf(`%q`, info.Value), Name: info.EffectiveFQDN, TTL: uint32(d.config.TTL), Type: scwdomain.RecordTypeTXT, Comment: scw.StringPtr("used by lego"), }} req := &scwdomain.UpdateDNSZoneRecordsRequest{ DNSZone: info.EffectiveFQDN, Changes: []*scwdomain.RecordChange{{ Add: &scwdomain.RecordChangeAdd{Records: records}, }}, ReturnAllRecords: scw.BoolPtr(false), DisallowNewZoneCreation: true, } _, err := d.client.UpdateDNSZoneRecords(req) if err != nil { return fmt.Errorf("scaleway: %w", err) } return nil } // CleanUp removes a TXT record used for DNS-01 challenge. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) recordIdentifier := &scwdomain.RecordIdentifier{ Name: info.EffectiveFQDN, Type: scwdomain.RecordTypeTXT, Data: scw.StringPtr(fmt.Sprintf(`%q`, info.Value)), } req := &scwdomain.UpdateDNSZoneRecordsRequest{ DNSZone: info.EffectiveFQDN, Changes: []*scwdomain.RecordChange{{ Delete: &scwdomain.RecordChangeDelete{IDFields: recordIdentifier}, }}, ReturnAllRecords: scw.BoolPtr(false), DisallowNewZoneCreation: true, } _, err := d.client.UpdateDNSZoneRecords(req) if err != nil { return fmt.Errorf("scaleway: %w", err) } return nil } func altEnvName(v string) string { return strings.ReplaceAll(v, envNamespace, altEnvNamespace) } ================================================ FILE: providers/dns/scaleway/scaleway.toml ================================================ Name = "Scaleway" Description = '''''' URL = "https://developers.scaleway.com/" Code = "scaleway" Since = "v3.4.0" Example = ''' SCW_SECRET_KEY=xxxxxxx-xxxxx-xxxx-xxx-xxxxxx \ lego --dns scaleway -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SCW_SECRET_KEY = "Secret key" SCW_PROJECT_ID = "Project to use (optional)" [Configuration.Additional] SCW_ACCESS_KEY = "Access key" SCW_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" SCW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" SCW_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" SCW_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.scaleway.com/en/products/domain/dns/api/" ================================================ FILE: providers/dns/scaleway/scaleway_test.go ================================================ package scaleway import ( "fmt" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken, EnvSecretKey, EnvAccessKey, EnvProjectID). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "00000000-0000-0000-0000-000000000000", EnvProjectID: "", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIToken: "", EnvProjectID: "", }, expected: fmt.Sprintf("scaleway: some credentials information are missing: %s", EnvSecretKey), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string ttl int expected string }{ { desc: "success", token: "00000000-0000-0000-0000-000000000000", ttl: minTTL, }, { desc: "missing api key", token: "", ttl: minTTL, expected: "scaleway: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.TTL = test.ttl config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/selectel/selectel.go ================================================ // Package selectel implements a DNS provider for solving the DNS-01 challenge using Selectel Domains API. // Selectel Domain API reference: https://kb.selectel.com/23136054.html // Token: https://my.selectel.ru/profile/apikeys package selectel import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/selectel" ) // Environment variables names. const ( envNamespace = "SELECTEL_" EnvBaseURL = envNamespace + "BASE_URL" EnvAPIToken = envNamespace + "API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = selectel.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ BaseURL: env.GetOrDefaultString(EnvBaseURL, ""), TTL: env.GetOrDefaultInt(EnvTTL, selectel.MinTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), 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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Selectel Domains API. // API token must be passed in the environment variable SELECTEL_API_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("selectel: %w", err) } config := NewDefaultConfig() config.Token = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for selectel. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("selectel: the configuration of the DNS provider is nil") } provider, err := selectel.NewDNSProviderConfig(config) if err != nil { return nil, fmt.Errorf("selectel: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("selectel: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("selectel: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/selectel/selectel.toml ================================================ Name = "Selectel" Description = '''''' URL = "https://kb.selectel.com/" Code = "selectel" Since = "v1.2.0" Example = ''' SELECTEL_API_TOKEN=xxxxx \ lego --dns selectel -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SELECTEL_API_TOKEN = "API token" [Configuration.Additional] SELECTEL_BASE_URL = "API endpoint URL" SELECTEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" SELECTEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" SELECTEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" SELECTEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://kb.selectel.com/23136054.html" ================================================ FILE: providers/dns/selectel/selectel_test.go ================================================ package selectel import ( "fmt" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/internal/selectel" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAPIToken, EnvTTL) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIToken: "", }, expected: fmt.Sprintf("selectel: some credentials information are missing: %s", EnvAPIToken), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string ttl int expected string }{ { desc: "success", token: "123", ttl: 60, }, { desc: "missing api key", token: "", ttl: 60, expected: "selectel: credentials missing", }, { desc: "bad TTL value", token: "123", ttl: 59, expected: fmt.Sprintf("selectel: invalid TTL, TTL (59) must be greater than %d", selectel.MinTTL), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.TTL = test.ttl config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/selectelv2/selectelv2.go ================================================ // Package selectelv2 implements a DNS provider for solving the DNS-01 challenge using Selectel Domains APIv2. package selectelv2 import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/miekg/dns" selectelapi "github.com/selectel/domains-go/pkg/v2" "github.com/selectel/go-selvpcclient/v4/selvpcclient" "golang.org/x/net/idna" ) const ( envNamespace = "SELECTELV2_" EnvBaseURL = envNamespace + "BASE_URL" EnvUsernameOS = envNamespace + "USERNAME" EnvPasswordOS = envNamespace + "PASSWORD" EnvDomainName = envNamespace + "ACCOUNT_ID" EnvProjectID = envNamespace + "PROJECT_ID" EnvAuthRegion = envNamespace + "AUTH_REGION" EnvAuthURL = envNamespace + "AUTH_URL" EnvUserDomainName = envNamespace + "USER_DOMAIN_NAME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const ( defaultBaseURL = "https://api.selectel.ru/domains/v2" defaultAuthRegion = "ru-1" defaultAuthURL = "https://cloud.api.selcloud.ru/identity/v3/" ) const ( defaultTTL = 60 defaultPropagationTimeout = 120 * time.Second defaultPollingInterval = 5 * time.Second defaultHTTPTimeout = 30 * time.Second ) const tokenHeader = "X-Auth-Token" var errNotFound = errors.New("rrset not found") // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string Username string Password string DomainName string ProjectID string AuthURL string AuthRegion string UserDomainName 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{ BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL), AuthRegion: env.GetOrDefaultString(EnvAuthRegion, defaultAuthRegion), AuthURL: env.GetOrDefaultString(EnvAuthURL, defaultAuthURL), TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, defaultHTTPTimeout), }, } } type DNSProvider struct { baseClient selectelapi.DNSClient[selectelapi.Zone, selectelapi.RRSet] config *Config } // NewDNSProvider returns a DNSProvider instance configured for Selectel Domains APIv2. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsernameOS, EnvPasswordOS, EnvDomainName, EnvProjectID) if err != nil { return nil, fmt.Errorf("selectelv2: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsernameOS] config.Password = values[EnvPasswordOS] config.DomainName = values[EnvDomainName] config.ProjectID = values[EnvProjectID] config.UserDomainName = env.GetOrDefaultString(EnvUserDomainName, "") return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for selectel. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("selectelv2: the configuration of the DNS provider is nil") } if config.Username == "" { return nil, errors.New("selectelv2: missing username") } if config.Password == "" { return nil, errors.New("selectelv2: missing password") } if config.DomainName == "" { return nil, errors.New("selectelv2: missing account ID") } if config.ProjectID == "" { return nil, errors.New("selectelv2: missing project ID") } headers := http.Header{} useragent.SetHeader(headers) return &DNSProvider{ baseClient: selectelapi.NewClient(config.BaseURL, clientdebug.Wrap(config.HTTPClient), headers), 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 DNS-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() client, err := d.authorize(ctx) if err != nil { return fmt.Errorf("selectelv2: authorize: %w", err) } info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := client.getZone(ctx, domain) if err != nil { return fmt.Errorf("selectelv2: get zone: %w", err) } rrset, err := client.getRRset(ctx, dns01.UnFqdn(info.EffectiveFQDN), zone.ID) if err != nil { if !errors.Is(err, errNotFound) { return fmt.Errorf("selectelv2: get RRSet: %w", err) } newRRSet := &selectelapi.RRSet{ Name: info.EffectiveFQDN, Type: selectelapi.TXT, TTL: d.config.TTL, Records: []selectelapi.RecordItem{{Content: fmt.Sprintf("%q", info.Value)}}, } _, err = client.CreateRRSet(ctx, zone.ID, newRRSet) if err != nil { return fmt.Errorf("selectelv2: create RRSet: %w", err) } return nil } rrset.Records = append(rrset.Records, selectelapi.RecordItem{Content: fmt.Sprintf("%q", info.Value)}) err = client.UpdateRRSet(ctx, zone.ID, rrset.ID, rrset) if err != nil { return fmt.Errorf("selectelv2: update RRSet: %w", err) } return nil } // CleanUp removes a TXT record used for DNS-01 challenge. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() client, err := d.authorize(ctx) if err != nil { return fmt.Errorf("selectelv2: authorize: %w", err) } info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := client.getZone(ctx, domain) if err != nil { return fmt.Errorf("selectelv2: get zone: %w", err) } rrset, err := client.getRRset(ctx, dns01.UnFqdn(info.EffectiveFQDN), zone.ID) if err != nil { return fmt.Errorf("selectelv2: get RRSet: %w", err) } if len(rrset.Records) <= 1 { err = client.DeleteRRSet(ctx, zone.ID, rrset.ID) if err != nil { return fmt.Errorf("selectelv2: %w", err) } return nil } for i, item := range rrset.Records { if strings.Trim(item.Content, `"`) == info.Value { rrset.Records = append(rrset.Records[:i], rrset.Records[i+1:]...) break } } err = client.UpdateRRSet(ctx, zone.ID, rrset.ID, rrset) if err != nil { return fmt.Errorf("selectelv2: update RRSet: %w", err) } return nil } func (d *DNSProvider) authorize(ctx context.Context) (*clientWrapper, error) { token, err := obtainOpenstackToken(ctx, d.config) if err != nil { return nil, err } extraHeaders := http.Header{} extraHeaders.Set(tokenHeader, token) return &clientWrapper{ DNSClient: d.baseClient.WithHeaders(extraHeaders), }, nil } func obtainOpenstackToken(ctx context.Context, config *Config) (string, error) { vpcClient, err := selvpcclient.NewClient(&selvpcclient.ClientOptions{ Context: ctx, DomainName: config.DomainName, AuthURL: config.AuthURL, AuthRegion: config.AuthRegion, Username: config.Username, Password: config.Password, ProjectID: config.ProjectID, UserDomainName: config.UserDomainName, }) if err != nil { return "", fmt.Errorf("new VPC client: %w", err) } return vpcClient.GetXAuthToken(), nil } type clientWrapper struct { selectelapi.DNSClient[selectelapi.Zone, selectelapi.RRSet] } func (w *clientWrapper) getZone(ctx context.Context, name string) (*selectelapi.Zone, error) { unicodeName, err := idna.ToUnicode(name) if err != nil { return nil, fmt.Errorf("to unicode: %w", err) } params := &map[string]string{"filter": unicodeName} zones, err := w.ListZones(ctx, params) if err != nil { return nil, fmt.Errorf("list zone: %w", err) } for _, zone := range zones.GetItems() { if zone.Name == dns.Fqdn(unicodeName) { return zone, nil } } if len(strings.Split(dns01.UnFqdn(name), ".")) == 1 { return nil, fmt.Errorf("zone '%s' for challenge has not been found", name) } // after is always defined since if no dots present we exit above. _, after, _ := strings.Cut(name, ".") return w.getZone(ctx, after) } func (w *clientWrapper) getRRset(ctx context.Context, name, zoneID string) (*selectelapi.RRSet, error) { unicodeName, err := idna.ToUnicode(name) if err != nil { return nil, fmt.Errorf("to unicode: %w", err) } params := &map[string]string{"name": unicodeName, "rrset_types": string(selectelapi.TXT)} resp, err := w.ListRRSets(ctx, zoneID, params) if err != nil { return nil, fmt.Errorf("list rrset: %w", err) } for _, rrset := range resp.GetItems() { if rrset.Name == dns.Fqdn(unicodeName) { return rrset, nil } } return nil, errNotFound } ================================================ FILE: providers/dns/selectelv2/selectelv2.toml ================================================ Name = "Selectel v2" Description = '''''' URL = "https://selectel.ru" Code = "selectelv2" Since = "v4.17.0" Example = ''' SELECTELV2_USERNAME=trex \ SELECTELV2_PASSWORD=xxxxx \ SELECTELV2_ACCOUNT_ID=1234567 \ SELECTELV2_PROJECT_ID=111a11111aaa11aa1a11aaa11111aa1a \ lego --dns selectelv2 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SELECTELV2_USERNAME = "Openstack username" SELECTELV2_PASSWORD = "Openstack username's password" SELECTELV2_ACCOUNT_ID = "Selectel account ID (INT)" SELECTELV2_PROJECT_ID = "Cloud project ID (UUID)" [Configuration.Additional] SELECTELV2_BASE_URL = "API endpoint URL" SELECTELV2_AUTH_REGION = "Location for auth endpoint like ResellAPI or Keystone (default: 'ru-1')" SELECTELV2_AUTH_URL = "Identity endpoint (defaul: 'https://cloud.api.selcloud.ru/identity/v3/')" SELECTELV2_USER_DOMAIN_NAME = "To specify the domain name (account ID) where the user is located. (default: SELECTELV2_ACCOUNT_ID)" SELECTELV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" SELECTELV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" SELECTELV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" SELECTELV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.selectel.ru/docs/cloud-services/dns_api/dns_api_actual/" GoClient = "https://github.com/selectel/domains-go" ================================================ FILE: providers/dns/selectelv2/selectelv2_test.go ================================================ package selectelv2 import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsernameOS, EnvPasswordOS, EnvDomainName, EnvUserDomainName, EnvProjectID, EnvAuthRegion, EnvAuthURL, ). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsernameOS: "someName", EnvPasswordOS: "qwerty", EnvDomainName: "1", EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a", }, }, { desc: "missing username", envVars: map[string]string{ EnvPasswordOS: "qwerty", EnvDomainName: "1", EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a", }, expected: "selectelv2: some credentials information are missing: SELECTELV2_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsernameOS: "someName", EnvDomainName: "1", EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a", }, expected: "selectelv2: some credentials information are missing: SELECTELV2_PASSWORD", }, { desc: "missing account", envVars: map[string]string{ EnvUsernameOS: "someName", EnvPasswordOS: "qwerty", EnvProjectID: "111a11111aaa11aa1a11aaa11111aa1a", }, expected: "selectelv2: some credentials information are missing: SELECTELV2_ACCOUNT_ID", }, { desc: "missing project", envVars: map[string]string{ EnvUsernameOS: "someName", EnvPasswordOS: "qwerty", EnvDomainName: "1", }, expected: "selectelv2: some credentials information are missing: SELECTELV2_PROJECT_ID", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.baseClient) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string account string projectID string expected string }{ { desc: "success", username: "user", password: "secret", account: "1", projectID: "111a11111aaa11aa1a11aaa11111aa1a", }, { desc: "missing username", password: "secret", account: "1", projectID: "111a11111aaa11aa1a11aaa11111aa1a", expected: "selectelv2: missing username", }, { desc: "missing password", username: "user", account: "1", projectID: "111a11111aaa11aa1a11aaa11111aa1a", expected: "selectelv2: missing password", }, { desc: "missing account", username: "user", password: "secret", projectID: "111a11111aaa11aa1a11aaa11111aa1a", expected: "selectelv2: missing account ID", }, { desc: "missing projectID", username: "user", password: "secret", account: "1", expected: "selectelv2: missing project ID", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password config.DomainName = test.account config.ProjectID = test.projectID p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.baseClient) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/selfhostde/internal/client.go ================================================ package internal import ( "context" "fmt" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://selfhost.de/cgi-bin/api.pl" // Client the SelfHost client. type Client struct { username string password string baseURL string HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(username, password string) *Client { return &Client{ username: username, password: password, baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // UpdateTXTRecord updates content of an existing TXT record. func (c *Client) UpdateTXTRecord(ctx context.Context, recordID, content string) error { endpoint, err := url.Parse(c.baseURL) if err != nil { return fmt.Errorf("parse URL: %w", err) } query := endpoint.Query() query.Set("username", c.username) query.Set("password", c.password) query.Set("rid", recordID) query.Set("content", content) endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return fmt.Errorf("new HTTP request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } return nil } ================================================ FILE: providers/dns/selfhostde/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.baseURL = server.URL client.HTTPClient = server.Client() return client, nil } func TestClient_UpdateTXTRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", nil, servermock.CheckQueryParameter().Strict(). With("rid", "123456"). With("content", "txt"). With("username", "user"). With("password", "secret"), ). Build(t) err := client.UpdateTXTRecord(t.Context(), "123456", "txt") require.NoError(t, err) } func TestClient_UpdateTXTRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /", servermock.Noop().WithStatusCode(http.StatusBadRequest)). Build(t) err := client.UpdateTXTRecord(t.Context(), "123456", "txt") require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") } ================================================ FILE: providers/dns/selfhostde/internal/readme.md ================================================ # SelfHost.(de|eu) SelfHost doesn't provide an official API documentation and there are no endpoints for create a TXT record or delete a TXT record. ## More The documentation found at https://kirk.selfhost.de/cgi-bin/selfhost?p=document&name=api (PDF) describes the DynDNS/ddns API endpoint and is not used by our client. ================================================ FILE: providers/dns/selfhostde/mapping.go ================================================ package selfhostde import ( "errors" "fmt" "strings" ) const ( lineSep = "," recordSep = ":" ) type Seq struct { cursor int ids []string } func NewSeq(ids ...string) *Seq { return &Seq{ids: ids} } func (s *Seq) Next() string { if len(s.ids) == 1 { return s.ids[0] } v := s.ids[s.cursor] if s.cursor < len(s.ids)-1 { s.cursor++ } else { s.cursor = 0 } return v } func parseRecordsMapping(raw string) (map[string]*Seq, error) { raw = strings.ReplaceAll(raw, " ", "") if raw == "" { return nil, errors.New("empty mapping") } acc := map[string]*Seq{} for { index, err := safeIndex(raw, lineSep) if err != nil { return nil, err } if index != -1 { name, seq, err := parseLine(raw[:index]) if err != nil { return nil, err } acc[name] = seq // Data for the next iteration. raw = raw[index+1:] continue } name, seq, errP := parseLine(raw) if errP != nil { return nil, errP } acc[name] = seq return acc, nil } } func parseLine(line string) (string, *Seq, error) { idx, err := safeIndex(line, recordSep) if err != nil { return "", nil, err } if idx == -1 { return "", nil, fmt.Errorf("missing %q: %s", recordSep, line) } name, rawIDs := line[:idx], line[idx+1:] var ( ids []string count int ) for { idx, err = safeIndex(rawIDs, recordSep) if err != nil { return "", nil, err } if count == 2 { return "", nil, fmt.Errorf("too many record IDs for one domain: %s", line) } if idx != -1 { ids = append(ids, rawIDs[:idx]) count++ // Data for the next iteration. rawIDs = rawIDs[idx+1:] continue } ids = append(ids, rawIDs) return name, NewSeq(ids...), nil } } func safeIndex(v, sep string) (int, error) { index := strings.Index(v, sep) if index == 0 { return 0, fmt.Errorf("first char is %q: %s", sep, v) } if index == len(v)-1 { return 0, fmt.Errorf("last char is %q: %s", sep, v) } return index, nil } ================================================ FILE: providers/dns/selfhostde/mapping_test.go ================================================ package selfhostde import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_parseRecordsMapping(t *testing.T) { testCases := []struct { desc string rawData string expected map[string]*Seq }{ { desc: "one domain, one record id", rawData: "example.com:123", expected: map[string]*Seq{ "example.com": NewSeq("123"), }, }, { desc: "several domain, one record id", rawData: "example.com:123, example.org:456,foo.example.com:789", expected: map[string]*Seq{ "example.com": NewSeq("123"), "example.org": NewSeq("456"), "foo.example.com": NewSeq("789"), }, }, { desc: "one domain, 2 record ids", rawData: "example.com:123:456", expected: map[string]*Seq{ "example.com": NewSeq("123", "456"), }, }, { desc: "several domain, 2 record ids", rawData: "example.com:123:321, example.org:456:654,foo.example.com:789:987", expected: map[string]*Seq{ "example.com": NewSeq("123", "321"), "example.org": NewSeq("456", "654"), "foo.example.com": NewSeq("789", "987"), }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() mapping, err := parseRecordsMapping(test.rawData) require.NoError(t, err) assert.Equal(t, test.expected, mapping) }) } } func Test_parseRecordsMapping_error(t *testing.T) { testCases := []struct { desc string rawData string expected string }{ { desc: "empty", rawData: "", expected: "empty mapping", }, { desc: "only spaces", rawData: " ", expected: "empty mapping", }, { desc: "one domain, no record id", rawData: "example.com", expected: `missing ":": example.com`, }, { desc: "one domain, more than 2 record ids", rawData: "example.com:123:456:789", expected: "too many record IDs for one domain: example.com:123:456:789", }, { desc: "several domain, more than 2 record ids", rawData: "example.com:123, example.org:456:789:147", expected: "too many record IDs for one domain: example.org:456:789:147", }, { desc: "no ids, ends with 2 dots", rawData: "example.com:", expected: `last char is ":": example.com:`, }, { desc: "no ids,starts with 2 dots", rawData: ":example.com", expected: `first char is ":": :example.com`, }, { desc: "with ids but ends with 2 dots", rawData: "example.com:123:", expected: `last char is ":": 123:`, }, { desc: "only 2 dots", rawData: ":", expected: `first char is ":": :`, }, { desc: "only comma", rawData: ",", expected: `first char is ",": ,`, }, { desc: "ends with comma", rawData: "example.com,", expected: `last char is ",": example.com,`, }, { desc: "combo", rawData: "::::,::", expected: `first char is ":": ::::`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() _, err := parseRecordsMapping(test.rawData) require.EqualError(t, err, test.expected) }) } } func TestSeq_Next(t *testing.T) { testCases := []struct { desc string ids []string expected []string }{ { desc: "one value", ids: []string{"a"}, expected: []string{"a", "a", "a"}, }, { desc: "two values", ids: []string{"a", "b"}, expected: []string{"a", "b", "a", "b"}, }, { desc: "three values", ids: []string{"a", "b", "c"}, expected: []string{"a", "b", "c", "a", "b", "c", "a"}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() seq := NewSeq(test.ids...) for _, s := range test.expected { assert.Equal(t, s, seq.Next()) } }) } } ================================================ FILE: providers/dns/selfhostde/selfhostde.go ================================================ // Package selfhostde implements a DNS provider for solving the DNS-01 challenge using SelfHost.(de|eu). package selfhostde import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/selfhostde/internal" ) // Environment variables. const ( envNamespace = "SELFHOSTDE_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvRecordsMapping = envNamespace + "RECORDS_MAPPING" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string RecordsMapping map[string]*Seq recordsMappingMu sync.Mutex 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, 4*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } func (c *Config) getSeqNext(domain string) (string, error) { effectiveDomain := strings.TrimPrefix(domain, "_acme-challenge.") c.recordsMappingMu.Lock() defer c.recordsMappingMu.Unlock() seq, ok := c.RecordsMapping[effectiveDomain] if !ok { // fallback seq, ok = c.RecordsMapping[domain] if !ok { return "", fmt.Errorf("record mapping not found for %q", effectiveDomain) } } return seq.Next(), nil } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for SelfHost.(de|eu). func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword, EnvRecordsMapping) if err != nil { return nil, fmt.Errorf("selfhostde: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] mapping, err := parseRecordsMapping(values[EnvRecordsMapping]) if err != nil { return nil, fmt.Errorf("selfhostde: malformed records mapping: %w", err) } config.RecordsMapping = mapping return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for SelfHost.(de|eu). func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("selfhostde: supplied configuration is nil") } if config.Username == "" || config.Password == "" { return nil, errors.New("selfhostde: credentials missing") } if len(config.RecordsMapping) == 0 { return nil, errors.New("selfhostde: missing record mapping") } for domain, seq := range config.RecordsMapping { if seq == nil || len(seq.ids) == 0 { return nil, fmt.Errorf("selfhostde: missing record ID for %q", domain) } } client := internal.NewClient(config.Username, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) recordID, err := d.config.getSeqNext(dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("selfhostde: %w", err) } err = d.client.UpdateTXTRecord(context.Background(), recordID, info.Value) if err != nil { return fmt.Errorf("selfhostde: update DNS TXT record (id=%s): %w", recordID, err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record previously created. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("selfhostde: unknown record ID for %q", dns01.UnFqdn(info.EffectiveFQDN)) } err := d.client.UpdateTXTRecord(context.Background(), recordID, "empty") if err != nil { return fmt.Errorf("selfhostde: emptied DNS TXT record (id=%s): %w", recordID, err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/selfhostde/selfhostde.toml ================================================ Name = "SelfHost.(de|eu)" Description = '''''' URL = "https://www.selfhost.de" Code = "selfhostde" Since = "v4.19.0" Example = ''' SELFHOSTDE_USERNAME=xxx \ SELFHOSTDE_PASSWORD=yyy \ SELFHOSTDE_RECORDS_MAPPING=my.example.com:123 \ lego --dns selfhostde -d '*.example.com' -d example.com run ''' Additional = """ SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. So, before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), you must create: - one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. - two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. After that you must edit the TXT record(s) to get the ID(s). You then must prepare the `SELFHOSTDE_RECORDS_MAPPING` environment variable with the following format: ``` ::,::,:: ``` where each group of domain + record ID(s) is separated with a comma (`,`), and the domain and record ID(s) are separated with a colon (`:`). For example, if you want to create or renew a certificate for `my.example.org`, `*.my.example.org`, and `other.example.org`, you would need: - two separate records for `_acme-challenge.my.example.org` - and another separate record for `_acme-challenge.other.example.org` The resulting environment variable would then be: `SELFHOSTDE_RECORDS_MAPPING=my.example.com:123:456,other.example.com:789` """ [Configuration] [Configuration.Credentials] SELFHOSTDE_USERNAME = "Username" SELFHOSTDE_PASSWORD = "Password" SELFHOSTDE_RECORDS_MAPPING = "Record IDs mapping with domains (ex: example.com:123:456,example.org:789,foo.example.com:147)" [Configuration.Additional] SELFHOSTDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)" SELFHOSTDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)" SELFHOSTDE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SELFHOSTDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" ================================================ FILE: providers/dns/selfhostde/selfhostde_test.go ================================================ package selfhostde import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvRecordsMapping). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", EnvRecordsMapping: "example.com:123", }, }, { desc: "missing username", envVars: map[string]string{ EnvPassword: "secret", EnvRecordsMapping: "example.com:123", }, expected: "selfhostde: some credentials information are missing: SELFHOSTDE_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "user", EnvRecordsMapping: "example.com:123", }, expected: "selfhostde: some credentials information are missing: SELFHOSTDE_PASSWORD", }, { desc: "missing records mapping", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", }, expected: "selfhostde: some credentials information are missing: SELFHOSTDE_RECORDS_MAPPING", }, { desc: "invalid records mapping", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", EnvRecordsMapping: "example.com", }, expected: `selfhostde: malformed records mapping: missing ":": example.com`, }, { desc: "missing information", envVars: map[string]string{}, expected: "selfhostde: some credentials information are missing: SELFHOSTDE_USERNAME,SELFHOSTDE_PASSWORD,SELFHOSTDE_RECORDS_MAPPING", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string recordMapping map[string]*Seq expected string }{ { desc: "success", username: "user", password: "secret", recordMapping: map[string]*Seq{ "example.com": NewSeq("123"), }, }, { desc: "missing username", password: "secret", recordMapping: map[string]*Seq{ "example.com": NewSeq("123"), }, expected: "selfhostde: credentials missing", }, { desc: "missing password", username: "user", recordMapping: map[string]*Seq{ "example.com": NewSeq("123"), }, expected: "selfhostde: credentials missing", }, { desc: "missing sequence", username: "user", password: "secret", recordMapping: map[string]*Seq{ "example.com": nil, }, expected: `selfhostde: missing record ID for "example.com"`, }, { desc: "empty sequence", username: "user", password: "secret", recordMapping: map[string]*Seq{ "example.com": NewSeq(), }, expected: `selfhostde: missing record ID for "example.com"`, }, { desc: "missing records mapping", username: "user", password: "secret", expected: "selfhostde: missing record mapping", }, { desc: "empty records mapping", username: "user", password: "secret", recordMapping: map[string]*Seq{}, expected: "selfhostde: missing record mapping", }, { desc: "missing information", expected: "selfhostde: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password config.RecordsMapping = test.recordMapping p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/servercow/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const baseAPIURL = "https://api.servercow.de/dns/v1/domains" // Client the Servercow client. type Client struct { username string password string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a Servercow client. func NewClient(username, password string) *Client { baseURL, _ := url.Parse(baseAPIURL) return &Client{ username: username, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetRecords from API. func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) { endpoint := c.baseURL.JoinPath(domain) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var records []Record err = c.do(req, &records) if err != nil { return nil, err } return records, nil } // CreateUpdateRecord creates or updates a record. func (c *Client) CreateUpdateRecord(ctx context.Context, domain string, data Record) (*Message, error) { endpoint := c.baseURL.JoinPath(domain) req, err := newJSONRequest(ctx, http.MethodPost, endpoint, data) if err != nil { return nil, err } var msg Message err = c.do(req, &msg) if err != nil { return nil, err } if msg.ErrorMsg != "" { return nil, msg } return &msg, nil } // DeleteRecord deletes a record. func (c *Client) DeleteRecord(ctx context.Context, domain string, data Record) (*Message, error) { endpoint := c.baseURL.JoinPath(domain) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, data) if err != nil { return nil, err } var msg Message err = c.do(req, &msg) if err != nil { return nil, err } if msg.ErrorMsg != "" { return nil, msg } return &msg, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set("X-Auth-Username", c.username) req.Header.Set("X-Auth-Password", c.password) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() // Note the API always return 200 even if the authentication failed. if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = unmarshal(raw, result) if err != nil { return err } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") // Content-Type should be added even if there is no request body. req.Header.Set("Content-Type", "application/json") return req, nil } func unmarshal(raw []byte, v any) error { err := json.Unmarshal(raw, v) if err == nil { return nil } var utErr *json.UnmarshalTypeError if !errors.As(err, &utErr) { return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw)) } var apiErr Message errU := json.Unmarshal(raw, &apiErr) if errU != nil { return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw)) } return apiErr } ================================================ FILE: providers/dns/servercow/internal/client_test.go ================================================ package internal import ( "encoding/json" "net/http/httptest" "net/url" "os" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With("X-Auth-Username", "user"). With("X-Auth-Password", "secret"), ) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /example.com", servermock.ResponseFromFixture("records-01.json")). Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) recordsJSON, err := json.Marshal(records) require.NoError(t, err) expectedContent, err := os.ReadFile("./fixtures/records-01.json") require.NoError(t, err) assert.JSONEq(t, string(expectedContent), string(recordsJSON)) } func TestClient_GetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /example.com", servermock.JSONEncode(Message{ErrorMsg: "authentication failed"})). Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.Error(t, err) assert.Nil(t, records) } func TestClient_CreateUpdateRecord(t *testing.T) { client := mockBuilder(). Route("POST /example.com", servermock.JSONEncode(Message{Message: "ok"}), servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb"]}`)). Build(t) record := Record{ Name: "_acme-challenge.www", Type: "TXT", TTL: 30, Content: Value{"aaa", "bbb"}, } msg, err := client.CreateUpdateRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Message{Message: "ok"} assert.Equal(t, expected, msg) } func TestClient_CreateUpdateRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /example.com", servermock.JSONEncode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})). Build(t) record := Record{ Name: "_acme-challenge.www", } msg, err := client.CreateUpdateRecord(t.Context(), "example.com", record) require.Error(t, err) assert.Nil(t, msg) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /example.com", servermock.JSONEncode(Message{Message: "ok"}), servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.www","type":"TXT"}`)). Build(t) record := Record{ Name: "_acme-challenge.www", Type: "TXT", } msg, err := client.DeleteRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Message{Message: "ok"} assert.Equal(t, expected, msg) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /example.com", servermock.JSONEncode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})). Build(t) record := Record{ Name: "_acme-challenge.www", } msg, err := client.DeleteRecord(t.Context(), "example.com", record) require.Error(t, err) assert.Nil(t, msg) } ================================================ FILE: providers/dns/servercow/internal/fixtures/records-01.json ================================================ [ { "name": "letsencrypt", "ttl": 120, "type": "A", "content": "1.1.1.1" }, { "name": "diskover", "ttl": 120, "type": "CAA", "content": "0 issue \"letsencrypt.org\"" }, { "name": "diskover", "ttl": 120, "type": "AAAA", "content": ":::::" }, { "name": "diskover", "ttl": 120, "type": "A", "content": "1.1.1.1" }, { "name": "portainer", "ttl": 120, "type": "CAA", "content": "0 issue \"letsencrypt.org\"" }, { "name": "portainer", "ttl": 120, "type": "AAAA", "content": ":::::" }, { "name": "portainer", "ttl": 120, "type": "A", "content": "1.1.1.1" }, { "name": "lego", "ttl": 120, "type": "A", "content": "1.1.1.1" }, { "name": "traefik", "ttl": 120, "type": "CAA", "content": "0 issue \"letsencrypt.org\"" }, { "name": "traefik", "ttl": 120, "type": "AAAA", "content": ":::::" }, { "name": "traefik", "ttl": 120, "type": "A", "content": "1.1.1.1" }, { "name": "spaghetti", "ttl": 120, "type": "CAA", "content": "0 issue \"letsencrypt.org\"" }, { "name": "spaghetti", "ttl": 120, "type": "AAAA", "content": ":::::" }, { "name": "spaghetti", "ttl": 120, "type": "A", "content": "1.1.1.1" }, { "name": "dragonstone", "ttl": 120, "type": "CAA", "content": "0 issue \"letsencrypt.org\"" }, { "name": "dragonstone", "ttl": 120, "type": "A", "content": "1.1.1.1" }, { "name": "_acme-challenge.sample", "ttl": 20, "type": "TXT", "content": [ "txtxtxtxtxtxtxt", "acbdefghijklmnopqrstuvwxyz" ] }, { "name": "", "ttl": 120, "type": "CAA", "content": "0 issue \"letsencrypt.org\"" }, { "name": "", "ttl": 120, "type": "AAAA", "content": ":::::" }, { "name": "", "ttl": 120, "type": "A", "content": "1.1.1.1" } ] ================================================ FILE: providers/dns/servercow/internal/types.go ================================================ package internal import "encoding/json" // Record is the record representation. type Record struct { Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl,omitempty"` Content Value `json:"content,omitempty"` } // Value is the value of a record. // Allows to handle dynamic type (string and string array). type Value []string func (v Value) MarshalJSON() ([]byte, error) { if len(v) == 0 { return nil, nil } if len(v) == 1 { return json.Marshal(v[0]) } content, err := json.Marshal([]string(v)) if err != nil { return nil, err } return content, nil } func (v *Value) UnmarshalJSON(b []byte) error { if b[0] == '[' { return json.Unmarshal(b, (*[]string)(v)) } var s string if err := json.Unmarshal(b, &s); err != nil { return err } *v = append(*v, s) return nil } // Message is the basic response representation. // Can be an error. type Message struct { Message string `json:"message,omitempty"` ErrorMsg string `json:"error,omitempty"` } func (a Message) Error() string { return a.ErrorMsg } ================================================ FILE: providers/dns/servercow/internal/types_test.go ================================================ package internal import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestValue_MarshalJSON(t *testing.T) { testCases := []struct { desc string record Record expected string }{ { desc: "empty content", record: Record{ Name: "_acme-challenge.www", Type: "TXT", TTL: 30, Content: Value{}, }, expected: `{"name":"_acme-challenge.www","type":"TXT","ttl":30}`, }, { desc: "content with a single value", record: Record{ Name: "_acme-challenge.www", Type: "TXT", TTL: 30, Content: Value{"aaa"}, }, expected: `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":"aaa"}`, }, { desc: "content with multiple values", record: Record{ Name: "_acme-challenge.www", Type: "TXT", TTL: 30, Content: Value{"aaa", "bbb", "ccc"}, }, expected: `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb","ccc"]}`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { content, err := json.Marshal(test.record) require.NoError(t, err) assert.JSONEq(t, test.expected, string(content)) }) } } func TestValue_UnmarshalJSON(t *testing.T) { testCases := []struct { desc string data string expected Record }{ { desc: "empty content", data: `{"name":"_acme-challenge.www","type":"TXT","ttl":30}`, expected: Record{ Name: "_acme-challenge.www", Type: "TXT", TTL: 30, Content: Value(nil), }, }, { desc: "content with a single value", data: `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":"aaa"}`, expected: Record{ Name: "_acme-challenge.www", Type: "TXT", TTL: 30, Content: Value{"aaa"}, }, }, { desc: "content with multiple values", data: `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb","ccc"]}`, expected: Record{ Name: "_acme-challenge.www", Type: "TXT", TTL: 30, Content: Value{"aaa", "bbb", "ccc"}, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { record := Record{} err := json.Unmarshal([]byte(test.data), &record) require.NoError(t, err) assert.Equal(t, test.expected, record) }) } } ================================================ FILE: providers/dns/servercow/servercow.go ================================================ // Package servercow implements a DNS provider for solving the DNS-01 challenge using Servercow DNS. package servercow import ( "context" "errors" "fmt" "net/http" "slices" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/servercow/internal" ) // Environment variables names. const ( envNamespace = "SERVERCOW_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("servercow: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Servercow. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.Username == "" || config.Password == "" { return nil, errors.New("servercow: incomplete credentials, missing username and/or password") } client := internal.NewClient(config.Username, config.Password) if config.HTTPClient == nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := getAuthZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("servercow: %w", err) } ctx := context.Background() records, err := d.client.GetRecords(ctx, authZone) if err != nil { return fmt.Errorf("servercow: %w", err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("servercow: %w", err) } record := findRecords(records, recordName) // TXT record entry already existing if record != nil { if slices.Contains(record.Content, info.Value) { return nil } request := internal.Record{ Name: record.Name, TTL: record.TTL, Type: record.Type, Content: append(record.Content, info.Value), } _, err = d.client.CreateUpdateRecord(ctx, authZone, request) if err != nil { return fmt.Errorf("servercow: failed to update TXT records: %w", err) } return nil } request := internal.Record{ Type: "TXT", Name: recordName, TTL: d.config.TTL, Content: internal.Value{info.Value}, } _, err = d.client.CreateUpdateRecord(ctx, authZone, request) if err != nil { return fmt.Errorf("servercow: failed to create TXT record %s: %w", info.EffectiveFQDN, err) } return nil } // CleanUp removes the TXT record previously created. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := getAuthZone(info.EffectiveFQDN) if err != nil { return fmt.Errorf("servercow: %w", err) } ctx := context.Background() records, err := d.client.GetRecords(ctx, authZone) if err != nil { return fmt.Errorf("servercow: failed to get TXT records: %w", err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("servercow: %w", err) } record := findRecords(records, recordName) if record == nil { return nil } if !slices.Contains(record.Content, info.Value) { return nil } // only 1 record value, the whole record must be deleted. if len(record.Content) == 1 { _, err = d.client.DeleteRecord(ctx, authZone, *record) if err != nil { return fmt.Errorf("servercow: failed to delete TXT records: %w", err) } return nil } request := internal.Record{ Name: record.Name, Type: record.Type, TTL: record.TTL, } for _, val := range record.Content { if val != info.Value { request.Content = append(request.Content, val) } } _, err = d.client.CreateUpdateRecord(ctx, authZone, request) if err != nil { return fmt.Errorf("servercow: failed to update TXT records: %w", err) } return nil } func getAuthZone(domain string) (string, error) { authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return "", fmt.Errorf("could not find zone: %w", err) } return dns01.UnFqdn(authZone), nil } func findRecords(records []internal.Record, name string) *internal.Record { for _, r := range records { if r.Type == "TXT" && r.Name == name { return &r } } return nil } ================================================ FILE: providers/dns/servercow/servercow.toml ================================================ Name = "Servercow" Description = '''''' URL = "https://servercow.de/" Code = "servercow" Since = "v3.4.0" Example = ''' SERVERCOW_USERNAME=xxxxxxxx \ SERVERCOW_PASSWORD=xxxxxxxx \ lego --dns servercow -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SERVERCOW_USERNAME = "API username" SERVERCOW_PASSWORD = "API password" [Configuration.Additional] SERVERCOW_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" SERVERCOW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" SERVERCOW_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SERVERCOW_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://wiki.servercow.de/en/domains/dns_api/api-syntax/" ================================================ FILE: providers/dns/servercow/servercow_test.go ================================================ package servercow import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvUsername: "", EnvPassword: "", }, expected: "servercow: some credentials information are missing: SERVERCOW_USERNAME,SERVERCOW_PASSWORD", }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "api_password", }, expected: "servercow: some credentials information are missing: SERVERCOW_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "api_username", EnvPassword: "", }, expected: "servercow: some credentials information are missing: SERVERCOW_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string username string password string }{ { desc: "success", username: "api_username", password: "api_password", }, { desc: "missing credentials", expected: "servercow: incomplete credentials, missing username and/or password", }, { desc: "missing api key", username: "", password: "api_password", expected: "servercow: incomplete credentials, missing username and/or password", }, { desc: "missing secret key", username: "api_username", password: "", expected: "servercow: incomplete credentials, missing username and/or password", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/shellrent/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultBaseURL the default API endpoint. const defaultBaseURL = "https://manager.shellrent.com/api2" const authorizationHeader = "Authorization" // Client the Shellrent API client. type Client struct { username string token string baseURL *url.URL HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(username, token string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ token: token, username: username, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // ListServices lists service IDs. // https://api.shellrent.com/elenco-dei-servizi-acquistati func (c *Client) ListServices(ctx context.Context) ([]int, error) { endpoint := c.baseURL.JoinPath("purchase") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := Response[[]IntOrString]{} err = c.do(req, &result) if err != nil { return nil, err } if result.Code != 0 { return nil, result.Base } var ids []int for _, datum := range result.Data { ids = append(ids, datum.Value()) } return ids, nil } // GetServiceDetails gets service details. // https://api.shellrent.com/dettagli-servizio-acquistato func (c *Client) GetServiceDetails(ctx context.Context, serviceID int) (*ServiceDetails, error) { endpoint := c.baseURL.JoinPath("purchase", "details", strconv.Itoa(serviceID)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := Response[*ServiceDetails]{} err = c.do(req, &result) if err != nil { return nil, err } if result.Code != 0 { return nil, result.Base } return result.Data, nil } // GetDomainDetails gets domain details. // https://api.shellrent.com/dettagli-dominio func (c *Client) GetDomainDetails(ctx context.Context, domainID int) (*DomainDetails, error) { endpoint := c.baseURL.JoinPath("domain", "details", strconv.Itoa(domainID)) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } result := Response[*DomainDetails]{} err = c.do(req, &result) if err != nil { return nil, err } if result.Code != 0 { return nil, result.Base } return result.Data, nil } // CreateRecord created a record. // https://api.shellrent.com/creazione-record-dns-di-un-dominio func (c *Client) CreateRecord(ctx context.Context, domainID int, record Record) (int, error) { endpoint := c.baseURL.JoinPath("dns_record", "store", strconv.Itoa(domainID)) req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return 0, err } result := Response[*Record]{} err = c.do(req, &result) if err != nil { return 0, err } if result.Code != 0 { return 0, result.Base } return result.Data.ID.Value(), nil } // DeleteRecord deletes a record. // https://api.shellrent.com/eliminazione-record-dns-di-un-dominio func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error { endpoint := c.baseURL.JoinPath("dns_record", "remove", strconv.Itoa(domainID), strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } result := Response[any]{} err = c.do(req, &result) if err != nil { return err } if result.Code != 0 { return result.Base } return nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Set(authorizationHeader, c.username+"."+c.token) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response Base err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return response } // TTLRounder rounds the given TTL in seconds to the next accepted value. // Accepted TTL values are: // - 3600 // - 14400 // - 28800 // - 57600 // - 86400 func TTLRounder(ttl int) int { for _, validTTL := range []int{3600, 14400, 28800, 57600, 86400} { if ttl <= validTTL { return validTTL } } return 3600 } ================================================ FILE: providers/dns/shellrent/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("user.secret")) } func TestClient_ListServices(t *testing.T) { client := mockBuilder(). Route("GET /purchase", servermock.ResponseFromFixture("purchase.json")). Build(t) services, err := client.ListServices(t.Context()) require.NoError(t, err) expected := []int{2018, 10039, 10128} assert.Equal(t, expected, services) } func TestClient_ListServices_error(t *testing.T) { client := mockBuilder(). Route("GET /purchase", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.ListServices(t.Context()) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_ListServices_error_status(t *testing.T) { client := mockBuilder(). Route("GET /purchase", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.ListServices(t.Context()) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetServiceDetails(t *testing.T) { client := mockBuilder(). Route("GET /purchase/details/123", servermock.ResponseFromFixture("purchase-details.json")). Build(t) services, err := client.GetServiceDetails(t.Context(), 123) require.NoError(t, err) expected := &ServiceDetails{ID: 123, Name: "example", DomainID: 456} assert.Equal(t, expected, services) } func TestClient_GetServiceDetails_error(t *testing.T) { client := mockBuilder(). Route("GET /purchase/details/123", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.GetServiceDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetServiceDetails_error_status(t *testing.T) { client := mockBuilder(). Route("GET /purchase/details/123", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetServiceDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetDomainDetails(t *testing.T) { client := mockBuilder(). Route("GET /domain/details/123", servermock.ResponseFromFixture("domain-details.json")). Build(t) services, err := client.GetDomainDetails(t.Context(), 123) require.NoError(t, err) expected := &DomainDetails{ID: 123, DomainName: "example.com", DomainNameASCII: "example.com"} assert.Equal(t, expected, services) } func TestClient_GetDomainDetails_error(t *testing.T) { client := mockBuilder(). Route("GET /domain/details/123", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.GetDomainDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetDomainDetails_error_status(t *testing.T) { client := mockBuilder(). Route("GET /domain/details/123", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetDomainDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns_record/store/123", servermock.ResponseFromFixture("dns_record-store.json")). Build(t) services, err := client.CreateRecord(t.Context(), 123, Record{}) require.NoError(t, err) expected := 2255674 assert.Equal(t, expected, services) } func TestClient_CreateRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /dns_record/store/123", servermock.ResponseFromFixture("error.json")). Build(t) _, err := client.CreateRecord(t.Context(), 123, Record{}) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_CreateRecord_error_status(t *testing.T) { client := mockBuilder(). Route("POST /dns_record/store/123", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.CreateRecord(t.Context(), 123, Record{}) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("dns_record-remove.json")). Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("error.json")). Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_DeleteRecord_error_status(t *testing.T) { client := mockBuilder(). Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestTTLRounder(t *testing.T) { testCases := []struct { desc string value int expected int }{ { desc: "lower than 3600", value: 123, expected: 3600, }, { desc: "lower than 14400", value: 12341, expected: 14400, }, { desc: "lower than 28800", value: 28341, expected: 28800, }, { desc: "lower than 57600", value: 56600, expected: 57600, }, { desc: "rounded to 86400", value: 86000, expected: 86400, }, { desc: "default", value: 100000, expected: 3600, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() ttl := TTLRounder(test.value) assert.Equal(t, test.expected, ttl) }) } } ================================================ FILE: providers/dns/shellrent/internal/fixtures/dns_record-remove.json ================================================ { "error": 0, "message": "" } ================================================ FILE: providers/dns/shellrent/internal/fixtures/dns_record-store.json ================================================ { "error": 0, "title": "", "message": "Record DNS aggiunto con successo", "data": { "id": "2255674" } } ================================================ FILE: providers/dns/shellrent/internal/fixtures/domain-details.json ================================================ { "error": 0, "message": "", "data": { "id": 123, "domain_name": "example.com", "domain_name_ascii": "example.com" } } ================================================ FILE: providers/dns/shellrent/internal/fixtures/error.json ================================================ { "error": 2, "title": "", "message": "Token di autorizzazione non valido", "data": null } ================================================ FILE: providers/dns/shellrent/internal/fixtures/purchase-details.json ================================================ { "error": 0, "message": "", "data": { "ID": 123, "name": "example", "domain_id": 456 } } ================================================ FILE: providers/dns/shellrent/internal/fixtures/purchase.json ================================================ { "error": 0, "message": "", "data": [ 2018, 10039, 10128 ] } ================================================ FILE: providers/dns/shellrent/internal/types.go ================================================ package internal import ( "fmt" "strconv" ) type Response[T any] struct { Base Data T `json:"data"` } type Base struct { Code int `json:"error"` Message string `json:"message"` } func (b Base) Error() string { return fmt.Sprintf("code %d: %s", b.Code, b.Message) } type ServiceDetails struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` DomainID int `json:"domain_id,omitempty"` } type DomainDetails struct { ID int `json:"id"` DomainName string `json:"domain_name"` DomainNameASCII string `json:"domain_name_ascii"` } type Record struct { ID IntOrString `json:"id,omitempty"` Type string `json:"type,omitempty"` Host string `json:"host,omitempty"` TTL int `json:"ttl,omitempty"` // It can be set to the following values (number of seconds): 3600, 14400, 28800, 57600, 86400 Destination string `json:"destination,omitempty"` } type IntOrString int func (m *IntOrString) Value() int { if m == nil { return 0 } return int(*m) } func (m *IntOrString) UnmarshalJSON(data []byte) error { if len(data) == 0 { return nil } raw := string(data) if data[0] == '"' { var err error raw, err = strconv.Unquote(string(data)) if err != nil { return err } } v, err := strconv.Atoi(raw) if err != nil { return err } *m = IntOrString(v) return nil } ================================================ FILE: providers/dns/shellrent/shellrent.go ================================================ package shellrent import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/shellrent/internal" ) // Environment variables names. const ( envNamespace = "SHELLRENT_" EnvUsername = envNamespace + "USERNAME" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultTTL = 3600 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) type reqKey struct { domainID int recordID int } // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Token string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]reqKey recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Shellrent. // Credentials must be passed in the environment variable: SHELLRENT_USERNAME, SHELLRENT_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvToken) if err != nil { return nil, fmt.Errorf("shellrent: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Shellrent. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("shellrent: the configuration of the DNS provider is nil") } if config.Username == "" { return nil, errors.New("shellrent: missing credentials: username") } if config.Token == "" { return nil, errors.New("shellrent: missing credentials: token") } client := internal.NewClient(config.Username, config.Token) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]reqKey), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("shellrent: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.DomainName) if err != nil { return fmt.Errorf("shellrent: %w", err) } record := internal.Record{ Type: "TXT", Host: subDomain, TTL: internal.TTLRounder(d.config.TTL), Destination: info.Value, } recordID, err := d.client.CreateRecord(ctx, zone.ID, record) if err != nil { return fmt.Errorf("shellrent: create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = reqKey{domainID: zone.ID, recordID: recordID} d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) // gets the record's unique ID from when we created it d.recordIDsMu.Lock() key, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("shellrent: unknown request key for '%s' '%s'", info.EffectiveFQDN, token) } err := d.client.DeleteRecord(ctx, key.domainID, key.recordID) if err != nil { return fmt.Errorf("shellrent: delete record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) findZone(ctx context.Context, domain string) (*internal.DomainDetails, error) { services, err := d.client.ListServices(ctx) if err != nil { return nil, fmt.Errorf("list services: %w", err) } for _, service := range services { details, err := d.client.GetServiceDetails(ctx, service) if err != nil { return nil, fmt.Errorf("get service details: %w", err) } domainDetails, err := d.client.GetDomainDetails(ctx, details.DomainID) if err != nil { return nil, fmt.Errorf("get domain details: %w", err) } domain := domain for { i := strings.Index(domain, ".") if i == -1 { break } if strings.EqualFold(domainDetails.DomainName, domain) { return domainDetails, nil } domain = domain[i+1:] } } return nil, errors.New("zone not found") } ================================================ FILE: providers/dns/shellrent/shellrent.toml ================================================ Name = "Shellrent" Description = '''''' URL = "https://www.shellrent.com/" Code = "shellrent" Since = "v4.16.0" Example = ''' SHELLRENT_USERNAME=xxxx \ SHELLRENT_TOKEN=yyyy \ lego --dns shellrent -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SHELLRENT_USERNAME = "Username" SHELLRENT_TOKEN = "Token" [Configuration.Additional] SHELLRENT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" SHELLRENT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" SHELLRENT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" SHELLRENT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.shellrent.com/section/api2" ================================================ FILE: providers/dns/shellrent/shellrent_test.go ================================================ package shellrent import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvToken). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvToken: "secret", }, }, { desc: "missing username", envVars: map[string]string{ EnvToken: "secret", }, expected: "shellrent: some credentials information are missing: SHELLRENT_USERNAME", }, { desc: "missing token", envVars: map[string]string{ EnvUsername: "user", }, expected: "shellrent: some credentials information are missing: SHELLRENT_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string token string expected string }{ { desc: "success", username: "user", token: "secret", }, { desc: "missing username", username: "", token: "secret", expected: "shellrent: missing credentials: username", }, { desc: "missing token", username: "user", token: "", expected: "shellrent: missing credentials: token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/simply/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.simply.com/2/" // Client is a Simply.com API client. type Client struct { accountName string apiKey string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(accountName, apiKey string) (*Client, error) { if accountName == "" { return nil, errors.New("credentials missing: accountName") } if apiKey == "" { return nil, errors.New("credentials missing: apiKey") } baseURL, err := url.Parse(defaultBaseURL) if err != nil { return nil, err } return &Client{ accountName: accountName, apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, }, nil } // GetRecords lists all the records in the zone. func (c *Client) GetRecords(ctx context.Context, zoneName string) ([]Record, error) { endpoint := c.createEndpoint(zoneName, "/") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } result := &apiResponse[[]Record, json.RawMessage]{} err = c.do(req, result) if err != nil { return nil, err } return result.Records, nil } // AddRecord adds a record. func (c *Client) AddRecord(ctx context.Context, zoneName string, record Record) (int64, error) { endpoint := c.createEndpoint(zoneName, "/") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return 0, fmt.Errorf("failed to create request: %w", err) } result := &apiResponse[json.RawMessage, recordHeader]{} err = c.do(req, result) if err != nil { return 0, err } return result.Record.ID, nil } // EditRecord updates a record. func (c *Client) EditRecord(ctx context.Context, zoneName string, id int64, record Record) error { endpoint := c.createEndpoint(zoneName, strconv.FormatInt(id, 10)) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record) if err != nil { return fmt.Errorf("failed to create request: %w", err) } return c.do(req, &apiResponse[json.RawMessage, json.RawMessage]{}) } // DeleteRecord deletes a record. func (c *Client) DeleteRecord(ctx context.Context, zoneName string, id int64) error { endpoint := c.createEndpoint(zoneName, strconv.FormatInt(id, 10)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } return c.do(req, &apiResponse[json.RawMessage, json.RawMessage]{}) } func (c *Client) createEndpoint(zoneName, uri string) *url.URL { return c.baseURL.JoinPath("my", "products", zoneName, "dns", "records", strings.TrimSuffix(uri, "/")) } func (c *Client) do(req *http.Request, result Response) error { req.SetBasicAuth(c.accountName, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusInternalServerError { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if result.GetStatus() != http.StatusOK { return fmt.Errorf("unexpected error: %s", result.GetMessage()) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/simply/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("accountname", "apikey") if err != nil { return nil, err } client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("accountname", "apikey")) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /my/products/azone01/dns/records", servermock.ResponseFromFixture("get_records.json")). Build(t) records, err := client.GetRecords(t.Context(), "azone01") require.NoError(t, err) expected := []Record{ { ID: 1, Name: "@", TTL: 3600, Data: "ns1.simply.com", Type: "NS", Priority: 0, }, { ID: 2, Name: "@", TTL: 3600, Data: "ns2.simply.com", Type: "NS", Priority: 0, }, { ID: 3, Name: "@", TTL: 3600, Data: "ns3.simply.com", Type: "NS", Priority: 0, }, { ID: 4, Name: "@", TTL: 3600, Data: "ns4.simply.com", Type: "NS", Priority: 0, }, } assert.Equal(t, expected, records) } func TestClient_GetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /my/products/azone01/dns/records", servermock.ResponseFromFixture("bad_auth_error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) records, err := client.GetRecords(t.Context(), "azone01") require.Error(t, err) assert.Nil(t, records) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /my/products/azone01/dns/records", servermock.ResponseFromFixture("add_record.json")). Build(t) record := Record{ Name: "arecord01", Data: "content", Type: "TXT", TTL: 120, Priority: 0, } recordID, err := client.AddRecord(t.Context(), "azone01", record) require.NoError(t, err) assert.EqualValues(t, 123456789, recordID) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /my/products/azone01/dns/records", servermock.ResponseFromFixture("bad_zone_error.json"). WithStatusCode(http.StatusNotFound)). Build(t) record := Record{ Name: "arecord01", Data: "content", Type: "TXT", TTL: 120, Priority: 0, } recordID, err := client.AddRecord(t.Context(), "azone01", record) require.Error(t, err) assert.Zero(t, recordID) } func TestClient_EditRecord(t *testing.T) { client := mockBuilder(). Route("PUT /my/products/azone01/dns/records/123456789", servermock.ResponseFromFixture("success.json")). Build(t) record := Record{ Name: "arecord01", Data: "content", Type: "TXT", TTL: 120, Priority: 0, } err := client.EditRecord(t.Context(), "azone01", 123456789, record) require.NoError(t, err) } func TestClient_EditRecord_error(t *testing.T) { client := mockBuilder(). Route("PUT /my/products/azone01/dns/records/123456789", servermock.ResponseFromFixture("invalid_record_id.json"). WithStatusCode(http.StatusNotFound)). Build(t) record := Record{ Name: "arecord01", Data: "content", Type: "TXT", TTL: 120, Priority: 0, } err := client.EditRecord(t.Context(), "azone01", 123456789, record) require.Error(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /my/products/azone01/dns/records/123456789", servermock.ResponseFromFixture("success.json")). Build(t) err := client.DeleteRecord(t.Context(), "azone01", 123456789) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /my/products/azone01/dns/records/123456789", servermock.ResponseFromFixture("invalid_record_id.json"). WithStatusCode(http.StatusNotFound)). Build(t) err := client.DeleteRecord(t.Context(), "azone01", 123456789) require.Error(t, err) } ================================================ FILE: providers/dns/simply/internal/fixtures/add_record.json ================================================ { "status": 200, "message": "success", "record": { "id": 123456789 } } ================================================ FILE: providers/dns/simply/internal/fixtures/bad_auth_error.json ================================================ { "status": 400, "message": "Invalid account authorization" } ================================================ FILE: providers/dns/simply/internal/fixtures/bad_zone_error.json ================================================ { "status": 404, "message": "Unknown or invalid product reference" } ================================================ FILE: providers/dns/simply/internal/fixtures/get_records.json ================================================ { "status": 200, "message": "success", "records": [ { "record_id": 1, "name": "@", "ttl": 3600, "data": "ns1.simply.com", "type": "NS", "priority": 0 }, { "record_id": 2, "name": "@", "ttl": 3600, "data": "ns2.simply.com", "type": "NS", "priority": 0 }, { "record_id": 3, "name": "@", "ttl": 3600, "data": "ns3.simply.com", "type": "NS", "priority": 0 }, { "record_id": 4, "name": "@", "ttl": 3600, "data": "ns4.simply.com", "type": "NS", "priority": 0 } ] } ================================================ FILE: providers/dns/simply/internal/fixtures/invalid_record_id_error.json ================================================ { "status": 404, "message": "Unknown DNS record" } ================================================ FILE: providers/dns/simply/internal/fixtures/success.json ================================================ { "status": 200, "message": "success" } ================================================ FILE: providers/dns/simply/internal/types.go ================================================ package internal // Record represents the content of a DNS record. type Record struct { ID int64 `json:"record_id,omitempty"` Name string `json:"name,omitempty"` Data string `json:"data,omitempty"` Type string `json:"type,omitempty"` TTL int `json:"ttl,omitempty"` Priority int `json:"priority,omitempty"` } type Response interface { GetStatus() int GetMessage() string } // apiResponse represents an API response. type apiResponse[S any, R any] struct { Status int `json:"status"` Message string `json:"message"` Records S `json:"records,omitempty"` Record R `json:"record,omitempty"` } func (a apiResponse[S, R]) GetStatus() int { return a.Status } func (a apiResponse[S, R]) GetMessage() string { return a.Message } type recordHeader struct { ID int64 `json:"id"` } ================================================ FILE: providers/dns/simply/simply.go ================================================ // Package simply implements a DNS provider for solving the DNS-01 challenge using Simply.com. package simply import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/simply/internal" ) // Environment variables names. const ( envNamespace = "SIMPLY_" EnvAccountName = envNamespace + "ACCOUNT_NAME" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AccountName string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]int64 recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Simply.com. // Credentials must be passed in the environment variable: SIMPLY_ACCOUNT_NAME, SIMPLY_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccountName, EnvAPIKey) if err != nil { return nil, fmt.Errorf("simply: %w", err) } config := NewDefaultConfig() config.AccountName = values[EnvAccountName] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Simply.com. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("simply: the configuration of the DNS provider is nil") } if config.AccountName == "" { return nil, errors.New("simply: missing credentials: account name") } if config.APIKey == "" { return nil, errors.New("simply: missing credentials: api key") } client, err := internal.NewClient(config.AccountName, config.APIKey) if err != nil { return nil, fmt.Errorf("simply: failed to create client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int64), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("simply: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("regru: %w", err) } recordBody := internal.Record{ Name: subDomain, Data: info.Value, Type: "TXT", TTL: d.config.TTL, } recordID, err := d.client.AddRecord(context.Background(), authZone, recordBody) if err != nil { return fmt.Errorf("simply: failed to add record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("simply: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("simply: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.DeleteRecord(context.Background(), authZone, recordID) if err != nil { return fmt.Errorf("simply: failed to delete TXT records: fqdn=%s, recordID=%d: %w", info.EffectiveFQDN, recordID, err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/simply/simply.toml ================================================ Name = "Simply.com" Description = '''''' URL = "https://www.simply.com/en/domains/" Code = "simply" Since = "v4.4.0" Example = ''' SIMPLY_ACCOUNT_NAME=xxxxxx \ SIMPLY_API_KEY=yyyyyy \ lego --dns simply -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SIMPLY_ACCOUNT_NAME = "Account name" SIMPLY_API_KEY = "API key" [Configuration.Additional] SIMPLY_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" SIMPLY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" SIMPLY_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SIMPLY_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.simply.com/en/docs/api/" Spec = "https://generator.swagger.io/?url=https://api.simply.com/2/openapi.json#/" ================================================ FILE: providers/dns/simply/simply_test.go ================================================ package simply import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAccountName, EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccountName: "S000000", EnvAPIKey: "secret", }, }, { desc: "missing credentials: account name", envVars: map[string]string{ EnvAccountName: "", EnvAPIKey: "secret", }, expected: "simply: some credentials information are missing: SIMPLY_ACCOUNT_NAME", }, { desc: "missing credentials: api key", envVars: map[string]string{ EnvAccountName: "S000000", EnvAPIKey: "", }, expected: "simply: some credentials information are missing: SIMPLY_API_KEY", }, { desc: "missing credentials: all", envVars: map[string]string{ EnvAccountName: "", EnvAPIKey: "", }, expected: "simply: some credentials information are missing: SIMPLY_ACCOUNT_NAME,SIMPLY_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accountName string apiKey string expected string }{ { desc: "success", accountName: "S000000", apiKey: "secret", }, { desc: "missing account name", apiKey: "secret", expected: "simply: missing credentials: account name", }, { desc: "missing api key", accountName: "S000000", expected: "simply: missing credentials: api key", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccountName = test.accountName config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/sonic/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const baseURL = "https://public-api.sonic.net/dyndns" // Client Sonic client. type Client struct { userID string apiKey string baseURL string HTTPClient *http.Client } // NewClient creates a Client. func NewClient(userID, apiKey string) (*Client, error) { if userID == "" || apiKey == "" { return nil, errors.New("credentials are missing") } return &Client{ userID: userID, apiKey: apiKey, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // SetRecord creates or updates a TXT records. // Sonic does not provide a delete record API endpoint. // https://public-api.sonic.net/dyndns#updating_or_adding_host_records func (c *Client) SetRecord(ctx context.Context, hostname, value string, ttl int) error { payload := &Record{ UserID: c.userID, APIKey: c.apiKey, Hostname: hostname, Value: value, TTL: ttl, Type: "TXT", } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to create request JSON body: %w", err) } endpoint, err := url.JoinPath(c.baseURL, "host") if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("content-type", "application/json") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } r := APIResponse{} err = json.Unmarshal(raw, &r) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if r.Result != 200 { return fmt.Errorf("API response code: %d, %s", r.Result, r.Message) } return nil } ================================================ FILE: providers/dns/sonic/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("foo", "secret") if err != nil { return nil, err } client.baseURL = server.URL client.HTTPClient = server.Client() return client, nil } func TestClient_SetRecord(t *testing.T) { testCases := []struct { desc string response string assert require.ErrorAssertionFunc }{ { desc: "success", response: `{"message":"OK","result":200}`, assert: require.NoError, }, { desc: "failure", response: `{"message":"Not Found : the information you requested was not found.","result":404}`, assert: require.Error, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). Route("PUT /host", servermock.RawStringResponse(test.response), servermock.CheckRequestJSONBody(`{"userid":"foo","apikey":"secret","hostname":"example.com","value":"txttxttxt","ttl":10,"type":"TXT"}`)). Build(t) err := client.SetRecord(t.Context(), "example.com", "txttxttxt", 10) test.assert(t, err) }) } } ================================================ FILE: providers/dns/sonic/internal/types.go ================================================ package internal type APIResponse struct { Message string `json:"message"` Result int `json:"result"` } // Record holds the Sonic API representation of a Domain Record. type Record struct { UserID string `json:"userid"` APIKey string `json:"apikey"` Hostname string `json:"hostname"` Value string `json:"value"` TTL int `json:"ttl"` Type string `json:"type"` } ================================================ FILE: providers/dns/sonic/sonic.go ================================================ // Package sonic implements a DNS provider for solving the DNS-01 challenge using Sonic. package sonic import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/sonic/internal" ) // Environment variables names. const ( envNamespace = "SONIC_" EnvUserID = envNamespace + "USER_ID" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { UserID string APIKey string HTTPClient *http.Client PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration TTL int } // 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), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Sonic. // Credentials must be passed in the environment variables: // SONIC_USERID and SONIC_APIKEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUserID, EnvAPIKey) if err != nil { return nil, fmt.Errorf("sonic: %w", err) } config := NewDefaultConfig() config.UserID = values[EnvUserID] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Sonic. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("sonic: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.UserID, config.APIKey) if err != nil { return nil, fmt.Errorf("sonic: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.SetRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL) if err != nil { return fmt.Errorf("sonic: unable to create record for %s: %w", info.EffectiveFQDN, err) } return nil } // CleanUp removes the TXT records matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.SetRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), "_", d.config.TTL) if err != nil { return fmt.Errorf("sonic: unable to clean record for %s: %w", info.EffectiveFQDN, err) } return 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } ================================================ FILE: providers/dns/sonic/sonic.toml ================================================ Name = "Sonic" Description = '''''' URL = "https://www.sonic.com/" Code = "sonic" Since = "v4.4.0" Example = ''' SONIC_USER_ID=12345 \ SONIC_API_KEY=4d6fbf2f9ab0fa11697470918d37625851fc0c51 \ lego --dns sonic -d '*.example.com' -d example.com run ''' Additional = ''' ## API keys The API keys must be generated by calling the `dyndns/api_key` endpoint. Example: ```bash $ curl -X POST -H "Content-Type: application/json" --data '{"username":"notarealuser","password":"notarealpassword","hostname":"example.com"}' https://public-api.sonic.net/dyndns/api_key {"userid":"12345","apikey":"4d6fbf2f9ab0fa11697470918d37625851fc0c51","result":200,"message":"OK"} ``` See https://public-api.sonic.net/dyndns/#requesting_an_api_key for additional details. This `userid` and `apikey` combo allow modifications to any DNS entries connected to the managed domain (hostname). Hostname should be the toplevel domain managed e.g. `example.com` not `www.example.com`. ''' [Configuration] [Configuration.Credentials] SONIC_USER_ID = "User ID" SONIC_API_KEY = "API Key" [Configuration.Additional] SONIC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" SONIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" SONIC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SONIC_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" SONIC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://public-api.sonic.net/dyndns/" ================================================ FILE: providers/dns/sonic/sonic_test.go ================================================ package sonic import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvUserID). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUserID: "dummy", EnvAPIKey: "dummy", }, }, { desc: "missing all credentials", envVars: map[string]string{}, expected: "sonic: some credentials information are missing: SONIC_USER_ID,SONIC_API_KEY", }, { desc: "no userid", envVars: map[string]string{ EnvAPIKey: "dummy", }, expected: "sonic: some credentials information are missing: SONIC_USER_ID", }, { desc: "no apikey", envVars: map[string]string{ EnvUserID: "dummy", }, expected: `sonic: some credentials information are missing: SONIC_API_KEY`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string userID string apiKey string expected string }{ { desc: "success", userID: "dummy", apiKey: "dummy", }, { desc: "missing all credentials", expected: "sonic: credentials are missing", }, { desc: "missing userid", apiKey: "dummy", expected: "sonic: credentials are missing", }, { desc: "missing apikey", userID: "dummy", expected: "sonic: credentials are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.UserID = test.userID config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/spaceship/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://spaceship.dev/api/v1/" // Client the Spaceship API client. type Client struct { apiKey string apiSecret string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey, apiSecret string) (*Client, error) { if apiKey == "" || apiSecret == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, apiSecret: apiSecret, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) do(req *http.Request, result any) error { req.Header.Add("X-Api-Secret", c.apiSecret) req.Header.Add("X-Api-Key", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { endpoint := c.baseURL.JoinPath("dns", "records", domain) req, err := newJSONRequest(ctx, http.MethodPut, endpoint, Foo{Items: []Record{record}}) if err != nil { return err } err = c.do(req, nil) if err != nil { return err } return nil } func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { endpoint := c.baseURL.JoinPath("dns", "records", domain) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, []Record{record}) if err != nil { return err } err = c.do(req, nil) if err != nil { return err } return nil } func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) { endpoint := c.baseURL.JoinPath("dns", "records", domain) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result GetRecordsResponse err = c.do(req, &result) if err != nil { return nil, err } return result.Items, nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/spaceship/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("key", "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). With("X-Api-Key", "key"). With("X-Api-Secret", "secret"), ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("PUT /dns/records/example.com", nil, servermock.CheckRequestJSONBody(`{"items":[{"type":"TXT","name":"@","ttl":60}]}`)). Build(t) record := Record{ Type: "TXT", Name: "@", TTL: 60, } err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("PUT /dns/records/example.com", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnprocessableEntity)). Build(t) record := Record{ Type: "TXT", Name: "@", TTL: 60, } err := client.AddRecord(t.Context(), "example.com", record) require.EqualError(t, err, "^$, name: The domain name contains invalid characters") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/records/example.com", nil, servermock.CheckRequestJSONBody(`[{"type":"TXT","name":"@","ttl":60}]`)). Build(t) record := Record{ Type: "TXT", Name: "@", TTL: 60, } err := client.DeleteRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/records/example.com", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnprocessableEntity)). Build(t) record := Record{ Type: "TXT", Name: "@", TTL: 60, } err := client.DeleteRecord(t.Context(), "example.com", record) require.EqualError(t, err, "^$, name: The domain name contains invalid characters") } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/records/example.com", servermock.ResponseFromFixture("get-records.json")). Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ {Type: "A", Name: "@", TTL: 3600}, } assert.Equal(t, expected, records) } func TestClient_GetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /dns/records/example.com", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnprocessableEntity)). Build(t) _, err := client.GetRecords(t.Context(), "example.com") require.EqualError(t, err, "^$, name: The domain name contains invalid characters") } ================================================ FILE: providers/dns/spaceship/internal/fixtures/error.json ================================================ { "detail": "^$", "data": [ { "field": "name", "details": "The domain name contains invalid characters" } ] } ================================================ FILE: providers/dns/spaceship/internal/fixtures/get-records.json ================================================ { "items": [ { "type": "A", "name": "@", "ttl": 3600, "group": { "type": "custom" } } ], "total": 100 } ================================================ FILE: providers/dns/spaceship/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type APIError struct { Detail string `json:"detail"` Data []struct { Field string `json:"field"` Details string `json:"details"` } `json:"data"` } func (a *APIError) Error() string { msg := []string{a.Detail} for _, datum := range a.Data { msg = append(msg, fmt.Sprintf("%s: %s", datum.Field, datum.Details)) } return strings.Join(msg, ", ") } type Foo struct { Force bool `json:"force,omitempty"` Items []Record `json:"items,omitempty"` } type Record struct { Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` Address string `json:"address,omitempty"` Nameserver string `json:"nameserver,omitempty"` AliasName string `json:"aliasName,omitempty"` Pointer string `json:"pointer,omitempty"` CName string `json:"cname,omitempty"` Exchange string `json:"exchange,omitempty"` TTL int `json:"ttl,omitempty"` } type GetRecordsResponse struct { Items []Record `json:"items"` Total int `json:"total"` } ================================================ FILE: providers/dns/spaceship/spaceship.go ================================================ // Package spaceship implements a DNS provider for solving the DNS-01 challenge using Spaceship. package spaceship import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/spaceship/internal" ) // Environment variables names. const ( envNamespace = "SPACESHIP_" EnvAPIKey = envNamespace + "API_KEY" EnvAPISecret = envNamespace + "API_SECRET" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string APISecret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Spaceship. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvAPISecret) if err != nil { return nil, fmt.Errorf("spaceship: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.APISecret = values[EnvAPISecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Spaceship. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("spaceship: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIKey, config.APISecret) if err != nil { return nil, fmt.Errorf("spaceship: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("spaceship: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("spaceship: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Value: info.Value, TTL: d.config.TTL, } err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("spaceship: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("spaceship: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("spaceship: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Value: info.Value, } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("spaceship: %w", err) } return 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 } ================================================ FILE: providers/dns/spaceship/spaceship.toml ================================================ Name = "Spaceship" Description = '''''' URL = "https://www.spaceship.com/" Code = "spaceship" Since = "v4.22.0" Example = ''' SPACESHIP_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ SPACESHIP_API_SECRET="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns spaceship -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] SPACESHIP_API_KEY = "API key" SPACESHIP_API_SECRET = "API secret" [Configuration.Additional] SPACESHIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" SPACESHIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" SPACESHIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SPACESHIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://docs.spaceship.dev/#tag/DNS-records" ================================================ FILE: providers/dns/spaceship/spaceship_test.go ================================================ package spaceship import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvAPISecret).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", EnvAPISecret: "secret", }, }, { desc: "missing API key", envVars: map[string]string{ EnvAPIKey: "", EnvAPISecret: "secret", }, expected: "spaceship: some credentials information are missing: SPACESHIP_API_KEY", }, { desc: "missing API secret", envVars: map[string]string{ EnvAPIKey: "key", EnvAPISecret: "", }, expected: "spaceship: some credentials information are missing: SPACESHIP_API_SECRET", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "spaceship: some credentials information are missing: SPACESHIP_API_KEY,SPACESHIP_API_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string apiSecret string expected string }{ { desc: "success", apiKey: "key", apiSecret: "secret", }, { desc: "missing API key", apiSecret: "secret", expected: "spaceship: credentials missing", }, { desc: "missing API secret", apiKey: "key", expected: "spaceship: credentials missing", }, { desc: "missing credentials", expected: "spaceship: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.APISecret = test.apiSecret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/stackpath/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/net/publicsuffix" ) const defaultBaseURL = "https://gateway.stackpath.com/dns/v1/stacks/" // Client the API client for Stackpath. type Client struct { stackID string baseURL *url.URL httpClient *http.Client } // NewClient creates a new Client. func NewClient(stackID string, hc *http.Client) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ baseURL: baseURL, stackID: stackID, httpClient: hc, } } // GetZones gets all zones. // https://stackpath.dev/reference/getzones func (c *Client) GetZones(ctx context.Context, domain string) (*Zone, error) { endpoint := c.baseURL.JoinPath(c.stackID, "zones") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } tld, err := publicsuffix.EffectiveTLDPlusOne(dns01.UnFqdn(domain)) if err != nil { return nil, err } query := req.URL.Query() query.Add("page_request.filter", fmt.Sprintf("domain='%s'", tld)) req.URL.RawQuery = query.Encode() var zones Zones err = c.do(req, &zones) if err != nil { return nil, err } if len(zones.Zones) == 0 { return nil, fmt.Errorf("did not find zone with domain %s", domain) } return &zones.Zones[0], nil } // GetZoneRecords gets all records. // https://stackpath.dev/reference/getzonerecords func (c *Client) GetZoneRecords(ctx context.Context, name string, zone *Zone) ([]Record, error) { endpoint := c.baseURL.JoinPath(c.stackID, "zones", zone.ID, "records") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } query := req.URL.Query() query.Add("page_request.filter", fmt.Sprintf("name='%s' and type='TXT'", name)) req.URL.RawQuery = query.Encode() var records Records err = c.do(req, &records) if err != nil { return nil, err } if len(records.Records) == 0 { return nil, fmt.Errorf("did not find record with name %s", name) } return records.Records, nil } // CreateZoneRecord creates a record. // https://stackpath.dev/reference/createzonerecord func (c *Client) CreateZoneRecord(ctx context.Context, zone *Zone, record Record) error { endpoint := c.baseURL.JoinPath(c.stackID, "zones", zone.ID, "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return err } return c.do(req, nil) } // DeleteZoneRecord deletes a record. // https://stackpath.dev/reference/deletezonerecord func (c *Client) DeleteZoneRecord(ctx context.Context, zone *Zone, record Record) error { endpoint := c.baseURL.JoinPath(c.stackID, "zones", zone.ID, "records", record.ID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func (c *Client) do(req *http.Request, result any) error { resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) errResp := &ErrorResponse{} err := json.Unmarshal(raw, errResp) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errResp } ================================================ FILE: providers/dns/stackpath/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("STACK_ID", server.Client()) client.baseURL, _ = url.Parse(server.URL + "/") return client, nil }, servermock.CheckHeader().WithJSONHeaders(), ) } func TestClient_GetZoneRecords(t *testing.T) { client := mockBuilder(). Route("GET /STACK_ID/zones/A/records", servermock.ResponseFromFixture("get_zone_records.json"), servermock.CheckQueryParameter().Strict(). With("page_request.filter", "name='foo1' and type='TXT'")). Build(t) records, err := client.GetZoneRecords(t.Context(), "foo1", &Zone{ID: "A", Domain: "test"}) require.NoError(t, err) expected := []Record{ {ID: "1", Name: "foo1", Type: "TXT", TTL: 120, Data: "txtTXTtxt"}, {ID: "2", Name: "foo2", Type: "TXT", TTL: 121, Data: "TXTtxtTXT"}, } assert.Equal(t, expected, records) } func TestClient_GetZoneRecords_apiError(t *testing.T) { client := mockBuilder(). Route("GET /STACK_ID/zones/A/records", servermock.RawStringResponse(` { "code": 401, "error": "an unauthorized request is attempted." }`).WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetZoneRecords(t.Context(), "foo1", &Zone{ID: "A", Domain: "test"}) expected := &ErrorResponse{Code: 401, Message: "an unauthorized request is attempted."} assert.Equal(t, expected, err) } func TestClient_GetZones(t *testing.T) { client := mockBuilder(). Route("GET /STACK_ID/zones", servermock.ResponseFromFixture("get_zones.json"), servermock.CheckQueryParameter().Strict(). With("page_request.filter", "domain='foo.com'")). Build(t) zone, err := client.GetZones(t.Context(), "sub.foo.com") require.NoError(t, err) expected := &Zone{ID: "A", Domain: "foo.com"} assert.Equal(t, expected, zone) } ================================================ FILE: providers/dns/stackpath/internal/fixtures/get_zone_records.json ================================================ { "records": [ {"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"}, {"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"} ] } ================================================ FILE: providers/dns/stackpath/internal/fixtures/get_zones.json ================================================ { "pageInfo": { "totalCount": "5", "hasPreviousPage": false, "hasNextPage": false, "startCursor": "1", "endCursor": "1" }, "zones": [ { "stackId": "my_stack", "accountId": "my_account", "id": "A", "domain": "foo.com", "version": "1", "labels": { "property1": "val1", "property2": "val2" }, "created": "2018-10-07T02:31:49Z", "updated": "2018-10-07T02:31:49Z", "nameservers": [ "1.1.1.1" ], "verified": "2018-10-07T02:31:49Z", "status": "ACTIVE", "disabled": false } ] } ================================================ FILE: providers/dns/stackpath/internal/identity.go ================================================ package internal import ( "context" "net/http" "golang.org/x/oauth2/clientcredentials" ) const defaultAuthURL = "https://gateway.stackpath.com/identity/v1/oauth2/token" func CreateOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client { config := &clientcredentials.Config{ TokenURL: defaultAuthURL, ClientID: clientID, ClientSecret: clientSecret, } return config.Client(ctx) } ================================================ FILE: providers/dns/stackpath/internal/types.go ================================================ package internal import "fmt" // Zones is the response struct from the Stackpath api GetZones. type Zones struct { Zones []Zone `json:"zones"` } // Zone a DNS zone representation. type Zone struct { ID string Domain string } // Records is the response struct from the Stackpath api GetZoneRecords. type Records struct { Records []Record `json:"records"` } // Record a DNS record representation. type Record struct { ID string `json:"id,omitempty"` Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl"` Data string `json:"data"` } // ErrorResponse the API error response representation. type ErrorResponse struct { Code int `json:"code"` Message string `json:"error"` } func (e *ErrorResponse) Error() string { return fmt.Sprintf("%d %s", e.Code, e.Message) } ================================================ FILE: providers/dns/stackpath/stackpath.go ================================================ // Package stackpath implements a DNS provider for solving the DNS-01 challenge using Stackpath DNS. // https://developer.stackpath.com/en/api/dns/ package stackpath import ( "context" "errors" "fmt" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/stackpath/internal" ) // Environment variables names. const ( envNamespace = "STACKPATH_" EnvClientID = envNamespace + "CLIENT_ID" EnvClientSecret = envNamespace + "CLIENT_SECRET" EnvStackID = envNamespace + "STACK_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ClientID string ClientSecret string StackID string TTL int PropagationTimeout time.Duration PollingInterval time.Duration } // 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), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Stackpath. // Credentials must be passed in the environment variables: // STACKPATH_CLIENT_ID, STACKPATH_CLIENT_SECRET, and STACKPATH_STACK_ID. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvClientID, EnvClientSecret, EnvStackID) if err != nil { return nil, fmt.Errorf("stackpath: %w", err) } config := NewDefaultConfig() config.ClientID = values[EnvClientID] config.ClientSecret = values[EnvClientSecret] config.StackID = values[EnvStackID] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Stackpath. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("stackpath: the configuration of the DNS provider is nil") } if config.ClientID == "" || config.ClientSecret == "" { return nil, errors.New("stackpath: credentials missing") } if config.StackID == "" { return nil, errors.New("stackpath: stack id missing") } return &DNSProvider{ config: config, client: internal.NewClient(config.StackID, clientdebug.Wrap( internal.CreateOAuthClient(context.Background(), config.ClientID, config.ClientSecret), ), ), }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.client.GetZones(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("stackpath: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Domain) if err != nil { return fmt.Errorf("stackpath: %w", err) } record := internal.Record{ Name: subDomain, Type: "TXT", TTL: d.config.TTL, Data: info.Value, } return d.client.CreateZoneRecord(ctx, zone, record) } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.client.GetZones(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("stackpath: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.Domain) if err != nil { return fmt.Errorf("stackpath: %w", err) } records, err := d.client.GetZoneRecords(ctx, subDomain, zone) if err != nil { return err } for _, record := range records { err = d.client.DeleteZoneRecord(ctx, zone, record) if err != nil { log.Printf("stackpath: failed to delete TXT record: %v", err) } } return 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 } ================================================ FILE: providers/dns/stackpath/stackpath.toml ================================================ Name = "Stackpath" Description = '''''' URL = "https://www.stackpath.com/" Code = "stackpath" Since = "v1.1.0" Example = ''' STACKPATH_CLIENT_ID=xxxxx \ STACKPATH_CLIENT_SECRET=yyyyy \ STACKPATH_STACK_ID=zzzzz \ lego --dns stackpath -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] STACKPATH_CLIENT_ID = "Client ID" STACKPATH_CLIENT_SECRET = "Client secret" STACKPATH_STACK_ID = "Stack ID" [Configuration.Additional] STACKPATH_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" STACKPATH_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" STACKPATH_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" [Links] API = "https://developer.stackpath.com/en/api/dns/#tag/Zone" ================================================ FILE: providers/dns/stackpath/stackpath_test.go ================================================ package stackpath import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvClientID, EnvClientSecret, EnvStackID). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvClientID: "test@example.com", EnvClientSecret: "123", EnvStackID: "ID", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvClientID: "", EnvClientSecret: "", EnvStackID: "", }, expected: "stackpath: some credentials information are missing: STACKPATH_CLIENT_ID,STACKPATH_CLIENT_SECRET,STACKPATH_STACK_ID", }, { desc: "missing client id", envVars: map[string]string{ EnvClientID: "", EnvClientSecret: "123", EnvStackID: "ID", }, expected: "stackpath: some credentials information are missing: STACKPATH_CLIENT_ID", }, { desc: "missing client secret", envVars: map[string]string{ EnvClientID: "test@example.com", EnvClientSecret: "", EnvStackID: "ID", }, expected: "stackpath: some credentials information are missing: STACKPATH_CLIENT_SECRET", }, { desc: "missing stack id", envVars: map[string]string{ EnvClientID: "test@example.com", EnvClientSecret: "123", EnvStackID: "", }, expected: "stackpath: some credentials information are missing: STACKPATH_STACK_ID", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) assert.NotNil(t, p) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := map[string]struct { config *Config expectedErr string }{ "no_config": { config: nil, expectedErr: "stackpath: the configuration of the DNS provider is nil", }, "no_client_id": { config: &Config{ ClientSecret: "secret", StackID: "stackID", }, expectedErr: "stackpath: credentials missing", }, "no_client_secret": { config: &Config{ ClientID: "clientID", StackID: "stackID", }, expectedErr: "stackpath: credentials missing", }, "no_stack_id": { config: &Config{ ClientID: "clientID", ClientSecret: "secret", }, expectedErr: "stackpath: stack id missing", }, } for desc, test := range testCases { t.Run(desc, func(t *testing.T) { t.Parallel() p, err := NewDNSProviderConfig(test.config) require.EqualError(t, err, test.expectedErr) assert.Nil(t, p) }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/syse/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://www.syse.no/api" // Client the Syse API client. type Client struct { credentials map[string]string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(credentials map[string]string) (*Client, error) { if len(credentials) == 0 { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ credentials: credentials, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) (*Record, error) { endpoint := c.BaseURL.JoinPath("dns", zone) req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } req.SetBasicAuth(zone, c.credentials[zone]) result := new(Record) err = c.do(req, result) if err != nil { return nil, err } return result, nil } func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error { endpoint := c.BaseURL.JoinPath("dns", zone, recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } req.SetBasicAuth(zone, c.credentials[zone]) return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/syse/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(map[string]string{ "example.com": "secret", }) if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/example.com", servermock.ResponseFromFixture("create_record.json"), servermock.CheckRequestJSONBodyFromFixture("create_record-request.json")). Build(t) record := Record{ Type: "TXT", Prefix: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", Active: true, TTL: 120, } result, err := client.CreateRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Record{ ID: "1234", Type: "TXT", Prefix: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", Active: true, TTL: 120, } assert.Equal(t, expected, result) } func TestClient_CreateRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /dns/example.com", servermock.RawStringResponse(http.StatusText(http.StatusUnauthorized)). WithStatusCode(http.StatusUnauthorized)). Build(t) record := Record{ Type: "TXT", Prefix: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", Active: true, TTL: 120, } _, err := client.CreateRecord(t.Context(), "example.com", record) require.EqualError(t, err, "unexpected status code: [status code: 401] body: Unauthorized") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/example.com/1234", servermock.Noop()). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "1234") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/example.com/1234", servermock.RawStringResponse(http.StatusText(http.StatusUnauthorized)). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(t.Context(), "example.com", "1234") require.EqualError(t, err, "unexpected status code: [status code: 401] body: Unauthorized") } ================================================ FILE: providers/dns/syse/internal/fixtures/create_record-request.json ================================================ { "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "active": true, "ttl": 120, "prefix": "_acme-challenge", "type": "TXT" } ================================================ FILE: providers/dns/syse/internal/fixtures/create_record.json ================================================ { "id": "1234", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "active": true, "ttl": 120, "prefix": "_acme-challenge", "type": "TXT" } ================================================ FILE: providers/dns/syse/internal/types.go ================================================ package internal type Record struct { ID string `json:"id,omitempty"` Type string `json:"type,omitempty"` Prefix string `json:"prefix,omitempty"` Content string `json:"content,omitempty"` Priority int `json:"prio,omitempty"` TTL int `json:"ttl,omitempty"` Active bool `json:"active,omitempty"` } ================================================ FILE: providers/dns/syse/syse.go ================================================ // Package syse implements a DNS provider for solving the DNS-01 challenge using Syse. package syse import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/syse/internal" ) // Environment variables names. const ( envNamespace = "SYSE_" EnvCredentials = envNamespace + "CREDENTIALS" 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 { Credentials map[string]string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 1200*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Syse. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvCredentials) if err != nil { return nil, fmt.Errorf("syse: %w", err) } config := NewDefaultConfig() credentials, err := env.ParsePairs(values[EnvCredentials]) if err != nil { return nil, fmt.Errorf("syse: credentials: %w", err) } config.Credentials = credentials return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Syse. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("syse: the configuration of the DNS provider is nil") } if len(config.Credentials) == 0 { return nil, errors.New("syse: missing credentials") } for domain, password := range config.Credentials { if domain == "" { return nil, fmt.Errorf(`syse: missing domain: "%s:%s"`, domain, password) } if password == "" { return nil, fmt.Errorf(`syse: missing password: "%s:%s"`, domain, password) } } client, err := internal.NewClient(config.Credentials) if err != nil { return nil, fmt.Errorf("syse: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("syse: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("syse: %w", err) } record := internal.Record{ Type: "TXT", Prefix: subDomain, Content: info.Value, TTL: d.config.TTL, Active: true, } newRecord, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("syse: create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("syse: could not find zone for domain %q: %w", domain, err) } // gets the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("syse: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID) if err != nil { return fmt.Errorf("syse: delete record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/syse/syse.toml ================================================ Name = "Syse" Description = '''''' URL = "https://www.syse.no/" Code = "syse" Since = "v4.30.0" Example = ''' SYSE_CREDENTIALS=example.com:password \ lego --dns syse -d '*.example.com' -d example.com run SYSE_CREDENTIALS=example.org:password1,example.com:password2 \ lego --dns syse -d '*.example.org' -d example.org -d '*.example.com' -d example.com ''' [Configuration] [Configuration.Credentials] SYSE_CREDENTIALS = "Comma-separated list of `zone:password` credential pairs" [Configuration.Additional] SYSE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" SYSE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 1200)" SYSE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" SYSE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.syse.no/api/dns" ================================================ FILE: providers/dns/syse/syse_test.go ================================================ package syse import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvCredentials).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvCredentials: "example.org:123", }, }, { desc: "success multiple domains", envVars: map[string]string{ EnvCredentials: "example.org:123,example.com:456,example.net:789", }, }, { desc: "invalid credentials", envVars: map[string]string{ EnvCredentials: ",", }, expected: `syse: credentials: incorrect pair: `, }, { desc: "missing password", envVars: map[string]string{ EnvCredentials: "example.org:", }, expected: `syse: missing password: "example.org:"`, }, { desc: "missing domain", envVars: map[string]string{ EnvCredentials: ":123", }, expected: `syse: missing domain: ":123"`, }, { desc: "invalid credentials, partial", envVars: map[string]string{ EnvCredentials: "example.org:123,example.net", }, expected: "syse: credentials: incorrect pair: example.net", }, { desc: "missing credentials", envVars: map[string]string{ EnvCredentials: "", }, expected: "syse: some credentials information are missing: SYSE_CREDENTIALS", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string creds map[string]string expected string }{ { desc: "success", creds: map[string]string{"example.org": "123"}, }, { desc: "success multiple domains", creds: map[string]string{ "example.org": "123", "example.com": "456", "example.net": "789", }, }, { desc: "missing credentials", expected: "syse: missing credentials", }, { desc: "missing domain", creds: map[string]string{"": "123"}, expected: `syse: missing domain: ":123"`, }, { desc: "missing password", creds: map[string]string{"example.org": ""}, expected: `syse: missing password: "example.org:"`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Credentials = test.creds p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.Credentials = map[string]string{ "example.org": "secret", } config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("/", servermock.DumpRequest()). Route("POST /dns/example.com", servermock.ResponseFromInternal("create_record.json"), servermock.CheckRequestJSONBodyFromInternal("create_record-request.json")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /dns/example.com/1234", servermock.Noop()). Build(t) provider.recordIDs["abc"] = "1234" err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/technitium/internal/client.go ================================================ package internal import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const statusSuccess = "ok" // Client the Technitium API client. type Client struct { apiToken string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(baseURL, apiToken string) (*Client, error) { if apiToken == "" { return nil, errors.New("missing credentials") } if baseURL == "" { return nil, errors.New("missing server URL") } apiEndpoint, err := url.Parse(baseURL) if err != nil { return nil, err } return &Client{ apiToken: apiToken, baseURL: apiEndpoint, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // AddRecord adds a resource record for an authoritative zone. // https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md#add-record func (c *Client) AddRecord(ctx context.Context, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("api", "zones", "records", "add") req, err := c.newFormRequest(ctx, endpoint, record) if err != nil { return nil, fmt.Errorf("create request: %w", err) } result := &APIResponse[AddRecordResponse]{} err = c.do(req, result) if err != nil { return nil, err } if result.Status != statusSuccess { return nil, result } return result.Response.AddedRecord, nil } // DeleteRecord deletes a record from an authoritative zone. // https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md#delete-record func (c *Client) DeleteRecord(ctx context.Context, record Record) error { endpoint := c.baseURL.JoinPath("api", "zones", "records", "delete") req, err := c.newFormRequest(ctx, endpoint, record) if err != nil { return fmt.Errorf("create request: %w", err) } result := &APIResponse[any]{} err = c.do(req, result) if err != nil { return err } if result.Status != statusSuccess { return result } return nil } func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusBadRequest { return parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) newFormRequest(ctx context.Context, endpoint *url.URL, payload any) (*http.Request, error) { values := url.Values{} if payload != nil { var err error values, err = querystring.Values(payload) if err != nil { return nil, fmt.Errorf("failed to create request body: %w", err) } } values.Set("token", c.apiToken) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(values.Encode())) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } if payload != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIResponse[any] err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/technitium/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient(server.URL, "secret") if err != nil { return nil, err } client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader().WithContentTypeFromURLEncoded()) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /api/zones/records/add", servermock.ResponseFromFixture("add-record.json"), servermock.CheckForm().Strict(). With("domain", "_acme-challenge.example.com"). With("text", "txtTXTtxt"). With("type", "TXT"). With("token", "secret")). Build(t) record := Record{ Domain: "_acme-challenge.example.com", Type: "TXT", Text: "txtTXTtxt", } newRecord, err := client.AddRecord(t.Context(), record) require.NoError(t, err) expected := &Record{Name: "example.com", Type: "A"} assert.Equal(t, expected, newRecord) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /api/zones/records/add", servermock.ResponseFromFixture("error.json")). Build(t) record := Record{ Domain: "_acme-challenge.example.com", Type: "TXT", Text: "txtTXTtxt", } _, err := client.AddRecord(t.Context(), record) require.Error(t, err) assert.EqualError(t, err, "Status: error, ErrorMessage: error message, StackTrace: application stack trace, InnerErrorMessage: inner exception message") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /api/zones/records/delete", servermock.ResponseFromFixture("delete-record.json"), servermock.CheckForm().Strict(). With("domain", "_acme-challenge.example.com"). With("text", "txtTXTtxt"). With("type", "TXT"). With("token", "secret")). Build(t) record := Record{ Domain: "_acme-challenge.example.com", Type: "TXT", Text: "txtTXTtxt", } err := client.DeleteRecord(t.Context(), record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /api/zones/records/delete", servermock.ResponseFromFixture("error.json")). Build(t) record := Record{ Domain: "_acme-challenge.example.com", Type: "TXT", Text: "txtTXTtxt", } err := client.DeleteRecord(t.Context(), record) require.Error(t, err) assert.EqualError(t, err, "Status: error, ErrorMessage: error message, StackTrace: application stack trace, InnerErrorMessage: inner exception message") } ================================================ FILE: providers/dns/technitium/internal/fixtures/add-record.json ================================================ { "response": { "zone": { "name": "example.com", "type": "Primary", "internal": false, "dnssecStatus": "SignedWithNSEC", "disabled": false }, "addedRecord": { "disabled": false, "name": "example.com", "type": "A", "ttl": 3600, "rData": { "ipAddress": "3.3.3.3" }, "dnssecStatus": "Unknown", "lastUsedOn": "0001-01-01T00:00:00" } }, "status": "ok" } ================================================ FILE: providers/dns/technitium/internal/fixtures/delete-record.json ================================================ { "response": {}, "status": "ok" } ================================================ FILE: providers/dns/technitium/internal/fixtures/error.json ================================================ { "status": "error", "errorMessage": "error message", "stackTrace": "application stack trace", "innerErrorMessage": "inner exception message" } ================================================ FILE: providers/dns/technitium/internal/types.go ================================================ package internal import "fmt" type APIResponse[T any] struct { Status string `json:"status"` // ok/error/invalid-token Response T `json:"response"` ErrorMessage string `json:"errorMessage"` StackTrace string `json:"stackTrace"` InnerErrorMessage string `json:"innerErrorMessage"` } func (a *APIResponse[T]) Error() string { msg := fmt.Sprintf("Status: %s", a.Status) if a.ErrorMessage != "" { msg += fmt.Sprintf(", ErrorMessage: %s", a.ErrorMessage) } if a.StackTrace != "" { msg += fmt.Sprintf(", StackTrace: %s", a.StackTrace) } if a.InnerErrorMessage != "" { msg += fmt.Sprintf(", InnerErrorMessage: %s", a.InnerErrorMessage) } return msg } type AddRecordResponse struct { Zone *Zone `json:"zone"` AddedRecord *Record `json:"addedRecord"` } type Record struct { Name string `json:"name,omitempty" url:"-"` Domain string `json:"domain,omitempty" url:"domain"` Type string `json:"type,omitempty" url:"type"` Text string `json:"text,omitempty" url:"text"` } type Zone struct { Name string `json:"name"` Type string `json:"type"` } ================================================ FILE: providers/dns/technitium/technitium.go ================================================ // Package technitium implements a DNS provider for solving the DNS-01 challenge using Technitium. package technitium import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/technitium/internal" ) // Environment variables names. const ( envNamespace = "TECHNITIUM_" EnvServerBaseURL = envNamespace + "SERVER_BASE_URL" EnvAPIToken = envNamespace + "API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for Technitium. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvServerBaseURL, EnvAPIToken) if err != nil { return nil, fmt.Errorf("technitium: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvServerBaseURL] config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Technitium. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("technitium: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.BaseURL, config.APIToken) if err != nil { return nil, fmt.Errorf("technitium: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) record := internal.Record{ Domain: info.EffectiveFQDN, Type: "TXT", Text: info.Value, } _, err := d.client.AddRecord(context.Background(), record) if err != nil { return fmt.Errorf("technitium: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) record := internal.Record{ Domain: info.EffectiveFQDN, Type: "TXT", Text: info.Value, } err := d.client.DeleteRecord(context.Background(), record) if err != nil { return fmt.Errorf("technitium: delete record: %w", err) } return 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 } ================================================ FILE: providers/dns/technitium/technitium.toml ================================================ Name = "Technitium" Description = '''''' URL = "https://technitium.com/" Code = "technitium" Since = "v4.20.0" Example = ''' TECHNITIUM_SERVER_BASE_URL="https://localhost:5380" \ TECHNITIUM_API_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns technitium -d '*.example.com' -d example.com run ''' Additional = ''' Technitium DNS Server supports Dynamic Updates (RFC2136) for primary zones, so you can also use the [RFC2136 provider](https://go-acme.github.io/lego/dns/rfc2136/index.html). [RFC2136 provider](https://go-acme.github.io/lego/dns/rfc2136/index.html) is much better compared to the HTTP API option from security perspective. Technitium recommends to use it in production over the HTTP API. ''' [Configuration] [Configuration.Credentials] TECHNITIUM_SERVER_BASE_URL = "Server base URL" TECHNITIUM_API_TOKEN = "API token" [Configuration.Additional] TECHNITIUM_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" TECHNITIUM_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" TECHNITIUM_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" TECHNITIUM_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://github.com/TechnitiumSoftware/DnsServer/blob/0f83d23e605956b66ac76921199e241d9cc061bd/APIDOCS.md" Article = "https://blog.technitium.com/2023/03/" ================================================ FILE: providers/dns/technitium/technitium_test.go ================================================ package technitium import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvServerBaseURL, EnvAPIToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvServerBaseURL: "https://localhost:5380", EnvAPIToken: "secret", }, }, { desc: "missing server base URL", envVars: map[string]string{ EnvServerBaseURL: "", EnvAPIToken: "secret", }, expected: "technitium: some credentials information are missing: TECHNITIUM_SERVER_BASE_URL", }, { desc: "missing token", envVars: map[string]string{ EnvServerBaseURL: "https://localhost:5380", EnvAPIToken: "", }, expected: "technitium: some credentials information are missing: TECHNITIUM_API_TOKEN", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "technitium: some credentials information are missing: TECHNITIUM_SERVER_BASE_URL,TECHNITIUM_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string baseURL string token string expected string }{ { desc: "success", baseURL: "https://localhost:5380", token: "secret", }, { desc: "missing server base URL", token: "secret", expected: "technitium: missing server URL", }, { desc: "missing token", baseURL: "https://localhost:5380", expected: "technitium: missing credentials", }, { desc: "missing credentials", expected: "technitium: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.BaseURL = test.baseURL config.APIToken = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/tencentcloud/tencentcloud.go ================================================ // Package tencentcloud implements a DNS provider for solving the DNS-01 challenge using Tencent Cloud DNS. package tencentcloud import ( "context" "errors" "fmt" "math" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" dnspod "github.com/go-acme/tencentclouddnspod/v20210323" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" ) // Environment variables names. const ( envNamespace = "TENCENTCLOUD_" EnvSecretID = envNamespace + "SECRET_ID" EnvSecretKey = envNamespace + "SECRET_KEY" EnvRegion = envNamespace + "REGION" EnvSessionToken = envNamespace + "SESSION_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { SecretID string SecretKey string Region string SessionToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *dnspod.Client } // NewDNSProvider returns a DNSProvider instance configured for Tencent Cloud DNS. // Credentials must be passed in the environment variable: TENCENTCLOUD_SECRET_ID, TENCENTCLOUD_SECRET_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvSecretID, EnvSecretKey) if err != nil { return nil, fmt.Errorf("tencentcloud: %w", err) } config := NewDefaultConfig() config.SecretID = values[EnvSecretID] config.SecretKey = values[EnvSecretKey] config.Region = env.GetOrDefaultString(EnvRegion, "") config.SessionToken = env.GetOrDefaultString(EnvSessionToken, "") return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Tencent Cloud DNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("tencentcloud: the configuration of the DNS provider is nil") } var credential *common.Credential switch { case config.SecretID != "" && config.SecretKey != "" && config.SessionToken != "": credential = common.NewTokenCredential(config.SecretID, config.SecretKey, config.SessionToken) case config.SecretID != "" && config.SecretKey != "": credential = common.NewCredential(config.SecretID, config.SecretKey) default: return nil, errors.New("tencentcloud: credentials missing") } cpf := profile.NewClientProfile() cpf.HttpProfile.Endpoint = "dnspod.tencentcloudapi.com" cpf.HttpProfile.ReqTimeout = int(math.Round(config.HTTPTimeout.Seconds())) client, err := dnspod.NewClient(credential, config.Region, cpf) if err != nil { return nil, fmt.Errorf("tencentcloud: %w", err) } return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("tencentcloud: failed to get hosted zone: %w", err) } recordName, err := extractRecordName(info.EffectiveFQDN, *zone.Name) if err != nil { return fmt.Errorf("tencentcloud: failed to extract record name: %w", err) } request := dnspod.NewCreateRecordRequest() request.Domain = zone.Name request.DomainId = zone.DomainId request.SubDomain = common.StringPtr(recordName) request.RecordType = common.StringPtr("TXT") request.RecordLine = common.StringPtr("默认") request.Value = common.StringPtr(info.Value) request.TTL = common.Uint64Ptr(uint64(d.config.TTL)) _, err = dnspod.CreateRecordWithContext(ctx, d.client, request) if err != nil { return fmt.Errorf("dnspod: API call failed: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() zone, err := d.getHostedZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("tencentcloud: failed to get hosted zone: %w", err) } records, err := d.findTxtRecords(ctx, zone, info.EffectiveFQDN) if err != nil { return fmt.Errorf("tencentcloud: failed to find TXT records: %w", err) } for _, record := range records { request := dnspod.NewDeleteRecordRequest() request.Domain = zone.Name request.DomainId = zone.DomainId request.RecordId = record.RecordId _, err := dnspod.DeleteRecordWithContext(ctx, d.client, request) if err != nil { return fmt.Errorf("tencentcloud: delete record failed: %w", err) } } return nil } ================================================ FILE: providers/dns/tencentcloud/tencentcloud.toml ================================================ Name = "Tencent Cloud DNS" Description = '''''' URL = "https://cloud.tencent.com/product/dns" Code = "tencentcloud" Since = "v4.6.0" Example = ''' TENCENTCLOUD_SECRET_ID=abcdefghijklmnopqrstuvwx \ TENCENTCLOUD_SECRET_KEY=your-secret-key \ lego --dns tencentcloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] TENCENTCLOUD_SECRET_ID = "Access key ID" TENCENTCLOUD_SECRET_KEY = "Access Key secret" [Configuration.Additional] TENCENTCLOUD_SESSION_TOKEN = "Access Key token" TENCENTCLOUD_REGION = "Region" TENCENTCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" TENCENTCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" TENCENTCLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" TENCENTCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://cloud.tencent.com/document/product/1427/56153" GoClient = "https://github.com/tencentcloud/tencentcloud-sdk-go" ================================================ FILE: providers/dns/tencentcloud/tencentcloud_test.go ================================================ package tencentcloud import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvSecretID, EnvSecretKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvSecretID: "123", EnvSecretKey: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvSecretID: "", EnvSecretKey: "", }, expected: "tencentcloud: some credentials information are missing: TENCENTCLOUD_SECRET_ID,TENCENTCLOUD_SECRET_KEY", }, { desc: "missing access id", envVars: map[string]string{ EnvSecretID: "", EnvSecretKey: "456", }, expected: "tencentcloud: some credentials information are missing: TENCENTCLOUD_SECRET_ID", }, { desc: "missing secret key", envVars: map[string]string{ EnvSecretID: "123", EnvSecretKey: "", }, expected: "tencentcloud: some credentials information are missing: TENCENTCLOUD_SECRET_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string secretID string secretKey string expected string }{ { desc: "success", secretID: "123", secretKey: "456", }, { desc: "missing credentials", expected: "tencentcloud: credentials missing", }, { desc: "missing secret id", secretKey: "456", expected: "tencentcloud: credentials missing", }, { desc: "missing secret key", secretID: "123", expected: "tencentcloud: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.SecretID = test.secretID config.SecretKey = test.secretKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/tencentcloud/wrapper.go ================================================ package tencentcloud import ( "context" "errors" "fmt" "github.com/go-acme/lego/v4/challenge/dns01" dnspod "github.com/go-acme/tencentclouddnspod/v20210323" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" errorsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors" "golang.org/x/net/idna" ) func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (*dnspod.DomainListItem, error) { request := dnspod.NewDescribeDomainListRequest() var domains []*dnspod.DomainListItem for { response, err := dnspod.DescribeDomainListWithContext(ctx, d.client, request) if err != nil { return nil, fmt.Errorf("API call failed: %w", err) } domains = append(domains, response.Response.DomainList...) if uint64(len(domains)) >= *response.Response.DomainCountInfo.AllTotal { break } request.Offset = common.Int64Ptr(int64(len(domains))) } authZone, err := dns01.FindZoneByFqdn(domain) if err != nil { return nil, fmt.Errorf("could not find zone: %w", err) } var hostedZone *dnspod.DomainListItem for _, zone := range domains { unfqdn := dns01.UnFqdn(authZone) if *zone.Name == unfqdn || *zone.Punycode == unfqdn { hostedZone = zone } } if hostedZone == nil { return nil, fmt.Errorf("zone %s not found in dnspod for domain %s", authZone, domain) } return hostedZone, nil } func (d *DNSProvider) findTxtRecords(ctx context.Context, zone *dnspod.DomainListItem, fqdn string) ([]*dnspod.RecordListItem, error) { recordName, err := extractRecordName(fqdn, *zone.Name) if err != nil { return nil, err } request := dnspod.NewDescribeRecordListRequest() request.Domain = zone.Name request.DomainId = zone.DomainId request.Subdomain = common.StringPtr(recordName) request.RecordType = common.StringPtr("TXT") request.RecordLine = common.StringPtr("默认") response, err := dnspod.DescribeRecordListWithContext(ctx, d.client, request) if err != nil { var sdkError *errorsdk.TencentCloudSDKError if errors.As(err, &sdkError) { if sdkError.Code == dnspod.RESOURCENOTFOUND_NODATAOFRECORD { return nil, nil } } return nil, err } return response.Response.RecordList, nil } func extractRecordName(fqdn, zone string) (string, error) { asciiDomain, err := idna.ToASCII(zone) if err != nil { return "", fmt.Errorf("fail to convert punycode: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, asciiDomain) if err != nil { return "", err } return subDomain, nil } ================================================ FILE: providers/dns/timewebcloud/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) const defaultBaseURL = "https://api.timeweb.cloud/api" // Client Timeweb Cloud client. type Client struct { baseURL *url.URL httpClient *http.Client } // NewClient creates a Client. func NewClient(hc *http.Client) *Client { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } return &Client{ baseURL: baseURL, httpClient: hc, } } // CreateRecord creates a DNS record. // https://timeweb.cloud/api-docs#tag/Domeny/operation/createDomainDNSRecord func (c *Client) CreateRecord(ctx context.Context, zone string, record DNSRecord) (*DNSRecord, error) { endpoint := c.baseURL.JoinPath("v1", "domains", dns01.UnFqdn(zone), "dns-records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } respData := &CreateRecordResponse{} err = c.do(req, respData) if err != nil { return nil, err } return respData.DNSRecord, nil } // DeleteRecord deletes a DNS record. // https://timeweb.cloud/api-docs#tag/Domeny/operation/deleteDomainDNSRecord func (c *Client) DeleteRecord(ctx context.Context, zone string, recordID int) error { endpoint := c.baseURL.JoinPath("v1", "domains", dns01.UnFqdn(zone), "dns-records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response ErrorResponse err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return response } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 10 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/timewebcloud/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer secret"), ) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /v1/domains/example.com/dns-records", servermock.ResponseFromFixture("createDomainDNSRecord.json"), servermock.CheckRequestJSONBody(`{"type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","subdomain":"_acme-challenge"}`)). Build(t) payload := DNSRecord{ Type: "TXT", Value: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", SubDomain: "_acme-challenge", } response, err := client.CreateRecord(t.Context(), "example.com.", payload) require.NoError(t, err) expected := &DNSRecord{ Type: "TXT", ID: 123, } assert.Equal(t, expected, response) } func TestClient_CreateRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /v1/domains/example.com/dns-records", servermock.ResponseFromFixture("error_bad_request.json"). WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.CreateRecord(t.Context(), "example.com.", DNSRecord{}) require.Error(t, err) assert.EqualError(t, err, "400: Value must be a number conforming to the specified constraints (bad_request) [15095f25-aac3-4d60-a788-96cb5136f186]") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/domains/example.com/dns-records/123", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.DeleteRecord(t.Context(), "example.com.", 123) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /v1/domains/example.com/dns-records/123", servermock.ResponseFromFixture("error_unauthorized.json"). WithStatusCode(http.StatusBadRequest)). Build(t) err := client.DeleteRecord(t.Context(), "example.com.", 123) require.Error(t, err) assert.EqualError(t, err, "401: Unauthorized (unauthorized) [15095f25-aac3-4d60-a788-96cb5136f186]") } ================================================ FILE: providers/dns/timewebcloud/internal/fixtures/createDomainDNSRecord.json ================================================ { "dns_record": { "type": "TXT", "id": 123, "data": { "priority": 0, "subdomain": "example.com", "value": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI" } }, "response_id": "15095f25-aac3-4d60-a788-96cb5136f186" } ================================================ FILE: providers/dns/timewebcloud/internal/fixtures/error_bad_request.json ================================================ { "status_code": 400, "message": "Value must be a number conforming to the specified constraints", "error_code": "bad_request", "response_id": "15095f25-aac3-4d60-a788-96cb5136f186" } ================================================ FILE: providers/dns/timewebcloud/internal/fixtures/error_unauthorized.json ================================================ { "status_code": 401, "message": "Unauthorized", "error_code": "unauthorized", "response_id": "15095f25-aac3-4d60-a788-96cb5136f186" } ================================================ FILE: providers/dns/timewebcloud/internal/readme.md ================================================ There is an [official API client](https://github.com/timeweb-cloud/sdk-go) but this client is completely broken: - the code is generated and the module name is `github.com/GIT_USER_ID/GIT_REPO_ID` - the code contains redeclared constants - Even with fixes to the module name and the redeclared constants, the module doesn't compile. https://github.com/timeweb-cloud/sdk-go/pull/1 So, for now, this API client is unusable. ================================================ FILE: providers/dns/timewebcloud/internal/types.go ================================================ package internal import "fmt" type DNSRecord struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value,omitempty"` // SubDomain is the full name of a subdomain (not only the subdomain label). SubDomain string `json:"subdomain,omitempty"` } type CreateRecordResponse struct { DNSRecord *DNSRecord `json:"dns_record,omitempty"` } type ErrorResponse struct { StatusCode int `json:"status_code,omitempty"` ErrorCode string `json:"error_code,omitempty"` Message string `json:"message,omitempty"` ResponseID string `json:"response_id,omitempty"` } func (a ErrorResponse) Error() string { return fmt.Sprintf("%d: %s (%s) [%s]", a.StatusCode, a.Message, a.ErrorCode, a.ResponseID) } ================================================ FILE: providers/dns/timewebcloud/timewebcloud.go ================================================ // Package timewebcloud implements a DNS provider for solving the DNS-01 challenge using Timeweb Cloud. package timewebcloud import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/timewebcloud/internal" ) // Environment variables names. const ( envNamespace = "TIMEWEBCLOUD_" EnvAuthToken = envNamespace + "AUTH_TOKEN" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AuthToken string HTTPClient *http.Client PropagationTimeout time.Duration PollingInterval time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Timeweb Cloud. // API token must be passed in the environment variable TIMEWEBCLOUD_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAuthToken) if err != nil { return nil, fmt.Errorf("timewebcloud: %w", err) } config := NewDefaultConfig() config.AuthToken = values[EnvAuthToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig returns a DNSProvider instance configured for Timeweb Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("timewebcloud: the configuration of the DNS provider is nil") } if config.AuthToken == "" { return nil, errors.New("timewebcloud: authentication token is missing") } client := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), ), ) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), }, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("timewebcloud: could not find zone for domain %q: %w", domain, err) } record := internal.DNSRecord{ Type: "TXT", Value: info.Value, SubDomain: dns01.UnFqdn(info.EffectiveFQDN), } response, err := d.client.CreateRecord(context.Background(), authZone, record) if err != nil { return fmt.Errorf("timewebcloud: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = response.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("timewebcloud: could not find zone for domain %q: %w", domain, err) } d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("timewebcloud: unknown record ID for '%s'", info.EffectiveFQDN) } err = d.client.DeleteRecord(context.Background(), authZone, recordID) if err != nil { return fmt.Errorf("timewebcloud: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/timewebcloud/timewebcloud.toml ================================================ Name = "Timeweb Cloud" Description = '''''' URL = "https://timeweb.cloud/" Code = "timewebcloud" Since = "v4.20.0" Example = ''' TIMEWEBCLOUD_AUTH_TOKEN=xxxxxx \ lego --dns timewebcloud -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] TIMEWEBCLOUD_AUTH_TOKEN = "Authentication token" [Configuration.Additional] TIMEWEBCLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" TIMEWEBCLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" TIMEWEBCLOUD_HTTP_TIMEOUT = "API request timeout in seconds (Default: 10)" [Links] API = "https://timeweb.cloud/api-docs" ================================================ FILE: providers/dns/timewebcloud/timewebcloud_test.go ================================================ package timewebcloud import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAuthToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthToken: "johndoe", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAuthToken: "", }, expected: "timewebcloud: some credentials information are missing: TIMEWEBCLOUD_AUTH_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string authToken string expected string }{ { desc: "success", authToken: "123", }, { desc: "missing credentials", expected: "timewebcloud: authentication token is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AuthToken = test.authToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/todaynic/internal/client.go ================================================ package internal import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://todapi.now.cn:2443" // Client the TodayNIC API client. type Client struct { authUserID string apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(authUserID, apiKey string) (*Client, error) { if authUserID == "" || apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ authUserID: authUserID, apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddRecord(ctx context.Context, record Record) (int, error) { endpoint := c.BaseURL.JoinPath("api", "dns", "add-domain-record.json") query, err := querystring.Values(record) if err != nil { return 0, err } req, err := c.newRequest(ctx, endpoint, query) if err != nil { return 0, err } var result APIResponse err = c.do(req, &result) if err != nil { return 0, err } return result.ID, nil } func (c *Client) DeleteRecord(ctx context.Context, recordID int) error { endpoint := c.BaseURL.JoinPath("api", "dns", "delete-domain-record.json") query := endpoint.Query() query.Set("Id", strconv.Itoa(recordID)) req, err := c.newRequest(ctx, endpoint, query) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func (c *Client) newRequest(ctx context.Context, endpoint *url.URL, query url.Values) (*http.Request, error) { query.Set("auth-userid", c.authUserID) query.Set("api-key", c.apiKey) endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/todaynic/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("user123", "secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("GET /api/dns/add-domain-record.json", servermock.ResponseFromFixture("add_record.json"), servermock.CheckQueryParameter().Strict(). With("Domain", "example.com"). With("Host", "_acme-challenge"). With("Type", "TXT"). With("Value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). With("Ttl", "600"). With("auth-userid", "user123"). With("api-key", "secret"), ). Build(t) record := Record{ Domain: "example.com", Host: "_acme-challenge", Type: "TXT", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: "600", } recordID, err := client.AddRecord(t.Context(), record) require.NoError(t, err) assert.Equal(t, 11554102, recordID) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /api/dns/add-domain-record.json", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusNotFound), ). Build(t) record := Record{ Domain: "example.com", Host: "_acme-challenge", Type: "TXT", Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: "600", } _, err := client.AddRecord(t.Context(), record) require.EqualError(t, err, "host.repeat (2d5876b2-f272-43e9-acc1-4c6a3d3683b1)") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("GET /api/dns/delete-domain-record.json", servermock.ResponseFromFixture("add_record.json"), servermock.CheckQueryParameter().Strict(). With("Id", "123"). With("auth-userid", "user123"). With("api-key", "secret"), ). Build(t) err := client.DeleteRecord(t.Context(), 123) require.NoError(t, err) } ================================================ FILE: providers/dns/todaynic/internal/fixtures/add_record.json ================================================ { "RequestId": "f60ea4d9-67ef-49fa-bbae-06178a6e7293", "Id": 11554102 } ================================================ FILE: providers/dns/todaynic/internal/fixtures/error.json ================================================ { "RequestId": "2d5876b2-f272-43e9-acc1-4c6a3d3683b1", "error": "host.repeat" } ================================================ FILE: providers/dns/todaynic/internal/types.go ================================================ package internal import "fmt" type APIError struct { RequestID string `json:"RequestId"` Message string `json:"error"` } func (a *APIError) Error() string { return fmt.Sprintf("%s (%s)", a.Message, a.RequestID) } type Record struct { Domain string `url:"Domain,omitempty"` Host string `url:"Host,omitempty"` Type string `url:"Type,omitempty"` Value string `url:"Value,omitempty"` Mx string `url:"Mx,omitempty"` TTL string `url:"Ttl,omitempty"` } type APIResponse struct { RequestID string `json:"RequestId"` ID int `json:"Id"` } ================================================ FILE: providers/dns/todaynic/todaynic.go ================================================ // Package todaynic implements a DNS provider for solving the DNS-01 challenge using TodayNIC. package todaynic import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/todaynic/internal" ) // Environment variables names. const ( envNamespace = "TODAYNIC_" EnvAuthUserID = envNamespace + "AUTH_USER_ID" EnvAPIKey = envNamespace + "API_KEY" 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 { AuthUserID string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 600), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for TodayNIC. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAuthUserID, EnvAPIKey) if err != nil { return nil, fmt.Errorf("todaynic: %w", err) } config := NewDefaultConfig() config.AuthUserID = values[EnvAuthUserID] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for TodayNIC. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("todaynic: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.AuthUserID, config.APIKey) if err != nil { return nil, fmt.Errorf("todaynic: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("todaynic: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("todaynic: %w", err) } record := internal.Record{ Domain: dns01.UnFqdn(authZone), Host: subDomain, Type: "TXT", Value: info.Value, TTL: strconv.Itoa(d.config.TTL), } recordID, err := d.client.AddRecord(context.Background(), record) if err != nil { return fmt.Errorf("todaynic: add record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = recordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("todaynic: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err := d.client.DeleteRecord(context.Background(), recordID) if err != nil { return fmt.Errorf("todaynic: delete record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/todaynic/todaynic.toml ================================================ Name = "TodayNIC/时代互联" Description = '''''' URL = "https://www.todaynic.com/" Code = "todaynic" Since = "v4.32.0" Example = ''' TODAYNIC_AUTH_USER_ID="xxx" \ TODAYNIC_API_KEY="yyy" \ lego --dns todaynic -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] TODAYNIC_AUTH_USER_ID = "account ID" TODAYNIC_API_KEY = "API key" [Configuration.Additional] TODAYNIC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" TODAYNIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" TODAYNIC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" TODAYNIC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.todaynic.com/partner/mode_Http_Api_detail.php" apipost = "https://docs.apipost.net/docs/detail/49dcef10a876000?target_id=0" ================================================ FILE: providers/dns/todaynic/todaynic_test.go ================================================ package todaynic import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAuthUserID, EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthUserID: "user123", EnvAPIKey: "secret", }, }, { desc: "missing user ID", envVars: map[string]string{ EnvAuthUserID: "", EnvAPIKey: "secret", }, expected: "todaynic: some credentials information are missing: TODAYNIC_AUTH_USER_ID", }, { desc: "missing API key", envVars: map[string]string{ EnvAuthUserID: "user123", EnvAPIKey: "", }, expected: "todaynic: some credentials information are missing: TODAYNIC_API_KEY", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "todaynic: some credentials information are missing: TODAYNIC_AUTH_USER_ID,TODAYNIC_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string authUserID string apiKey string expected string }{ { desc: "success", authUserID: "user123", apiKey: "secret", }, { desc: "missing user ID", apiKey: "secret", expected: "todaynic: credentials missing", }, { desc: "missing API key", authUserID: "user123", expected: "todaynic: credentials missing", }, { desc: "missing credentials", expected: "todaynic: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AuthUserID = test.authUserID config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.AuthUserID = "user123" config.APIKey = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("GET /api/dns/add-domain-record.json", servermock.ResponseFromInternal("add_record.json"), servermock.CheckQueryParameter().Strict(). With("Domain", "example.com"). With("Host", "_acme-challenge"). With("Type", "TXT"). With("Value", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"). With("Ttl", "600"). With("auth-userid", "user123"). With("api-key", "secret"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /api/dns/delete-domain-record.json", servermock.ResponseFromInternal("add_record.json"), servermock.CheckQueryParameter().Strict(). With("Id", "123"). With("auth-userid", "user123"). With("api-key", "secret"), ). Build(t) provider.recordIDs["abc"] = 123 err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/transip/fixtures/private.key ================================================ ================================================ FILE: providers/dns/transip/transip.go ================================================ // Package transip implements a DNS provider for solving the DNS-01 challenge using TransIP. package transip import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/transip/gotransip/v6" transipdomain "github.com/transip/gotransip/v6/domain" ) // Environment variables names. const ( envNamespace = "TRANSIP_" EnvAccountName = envNamespace + "ACCOUNT_NAME" EnvPrivateKeyPath = envNamespace + "PRIVATE_KEY_PATH" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AccountName string PrivateKeyPath string PropagationTimeout time.Duration PollingInterval time.Duration TTL int64 HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: int64(env.GetOrDefaultInt(EnvTTL, 10)), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config repository transipdomain.Repository } // NewDNSProvider returns a DNSProvider instance configured for TransIP. // Credentials must be passed in the environment variables: // TRANSIP_ACCOUNTNAME, TRANSIP_PRIVATEKEYPATH. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccountName, EnvPrivateKeyPath) if err != nil { return nil, fmt.Errorf("transip: %w", err) } config := NewDefaultConfig() config.AccountName = values[EnvAccountName] config.PrivateKeyPath = values[EnvPrivateKeyPath] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for TransIP. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("transip: the configuration of the DNS provider is nil") } cfg := gotransip.ClientConfiguration{ AccountName: config.AccountName, PrivateKeyPath: config.PrivateKeyPath, } if config.HTTPClient != nil { cfg.HTTPClient = config.HTTPClient } else { // Uses an explicit default HTTP client because the desec.NewDefaultClientOptions uses the http.DefaultClient. cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second} } client, err := gotransip.NewClient(cfg) if err != nil { return nil, fmt.Errorf("transip: %w", err) } repo := transipdomain.Repository{Client: client} return &DNSProvider{repository: repo, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("transip: could not find zone for domain %q: %w", domain, err) } // get the subDomain subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("transip: %w", err) } domainName := dns01.UnFqdn(authZone) entry := transipdomain.DNSEntry{ Name: subDomain, Expire: int(d.config.TTL), Type: "TXT", Content: info.Value, } err = d.repository.AddDNSEntry(domainName, entry) if err != nil { return fmt.Errorf("transip: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("transip: could not find zone for domain %q: %w", domain, err) } // get the subDomain subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("transip: %w", err) } domainName := dns01.UnFqdn(authZone) // get all DNS entries dnsEntries, err := d.repository.GetDNSEntries(domainName) if err != nil { return fmt.Errorf("transip: error for %s in CleanUp: %w", info.EffectiveFQDN, err) } // loop through the existing entries and remove the specific record for _, entry := range dnsEntries { if entry.Name == subDomain && entry.Content == info.Value { if err = d.repository.RemoveDNSEntry(domainName, entry); err != nil { return fmt.Errorf("transip: couldn't get Record ID in CleanUp: %w", err) } return nil } } return nil } ================================================ FILE: providers/dns/transip/transip.toml ================================================ Name = "TransIP" Description = '''''' URL = "https://www.transip.nl/" Code = "transip" Since = "v2.0.0" Example = ''' TRANSIP_ACCOUNT_NAME = "Account name" \ TRANSIP_PRIVATE_KEY_PATH = "transip.key" \ lego --dns transip -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] TRANSIP_ACCOUNT_NAME = "Account name" TRANSIP_PRIVATE_KEY_PATH = "Private key path" [Configuration.Additional] TRANSIP_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" TRANSIP_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" TRANSIP_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" TRANSIP_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.transip.eu/rest/docs.html" GoClient = "https://github.com/transip/gotransip" ================================================ FILE: providers/dns/transip/transip_test.go ================================================ package transip import ( "os" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAccountName, EnvPrivateKeyPath). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccountName: "johndoe", EnvPrivateKeyPath: "./fixtures/private.key", }, }, { desc: "missing all credentials", envVars: map[string]string{ EnvAccountName: "", EnvPrivateKeyPath: "", }, expected: "transip: some credentials information are missing: TRANSIP_ACCOUNT_NAME,TRANSIP_PRIVATE_KEY_PATH", }, { desc: "missing account name", envVars: map[string]string{ EnvAccountName: "", EnvPrivateKeyPath: "./fixtures/private.key", }, expected: "transip: some credentials information are missing: TRANSIP_ACCOUNT_NAME", }, { desc: "missing private key path", envVars: map[string]string{ EnvAccountName: "johndoe", EnvPrivateKeyPath: "", }, expected: "transip: some credentials information are missing: TRANSIP_PRIVATE_KEY_PATH", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.repository) } else { require.EqualError(t, err, test.expected) } }) } // The error message for a file not existing is different on Windows and Linux. // Therefore, we test if the error type is the same. t.Run("could not open private key path", func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(map[string]string{ EnvAccountName: "johndoe", EnvPrivateKeyPath: "./fixtures/non/existent/private.key", }) _, err := NewDNSProvider() require.ErrorIs(t, err, os.ErrNotExist) }) } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accountName string privateKeyPath string expected string }{ { desc: "success", accountName: "johndoe", privateKeyPath: "./fixtures/private.key", }, { desc: "missing all credentials", expected: "transip: AccountName is required", }, { desc: "missing account name", privateKeyPath: "./fixtures/private.key", expected: "transip: AccountName is required", }, { desc: "missing private key path", accountName: "johndoe", expected: "transip: PrivateKeyReader, token or PrivateKeyReader is required", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccountName = test.accountName config.PrivateKeyPath = test.privateKeyPath p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.repository) } else { require.EqualError(t, err, test.expected) } }) } // The error message for a file not existing is different on Windows and Linux. // Therefore, we test if the error type is the same. t.Run("could not open private key path", func(t *testing.T) { config := NewDefaultConfig() config.AccountName = "johndoe" config.PrivateKeyPath = "./fixtures/non/existent/private.key" _, err := NewDNSProviderConfig(config) require.ErrorIs(t, err, os.ErrNotExist) }) } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/ultradns/ultradns.go ================================================ // Package ultradns implements a DNS provider for solving the DNS-01 challenge using ultradns. package ultradns import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/ultradns/ultradns-go-sdk/pkg/client" "github.com/ultradns/ultradns-go-sdk/pkg/record" "github.com/ultradns/ultradns-go-sdk/pkg/rrset" ) // Environment variables names. const ( envNamespace = "ULTRADNS_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvEndpoint = envNamespace + "ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) const defaultEndpoint = "https://api.ultradns.com/" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *client.Client } // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string Endpoint string TTL int PropagationTimeout time.Duration PollingInterval time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ Endpoint: env.GetOrDefaultString(EnvEndpoint, defaultEndpoint), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), } } // NewDNSProvider returns a DNSProvider instance configured for ultradns. // Credentials must be passed in the environment variables: // ULTRADNS_USERNAME and ULTRADNS_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("ultradns: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ultradns. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("ultradns: the configuration of the DNS provider is nil") } ultraConfig := client.Config{ Username: config.Username, Password: config.Password, HostURL: config.Endpoint, UserAgent: useragent.Get(), } uClient, err := client.NewClient(ultraConfig) if err != nil { return nil, fmt.Errorf("ultradns: %w", err) } return &DNSProvider{config: config, client: uClient}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ultradns: could not find zone for domain %q: %w", domain, err) } recordService, err := record.Get(d.client) if err != nil { return fmt.Errorf("ultradns: %w", err) } rrSetKeyData := &rrset.RRSetKey{ Owner: info.EffectiveFQDN, Zone: authZone, RecordType: "TXT", } resp, _, _ := recordService.Read(rrSetKeyData) rrSetData := &rrset.RRSet{ OwnerName: info.EffectiveFQDN, TTL: d.config.TTL, RRType: "TXT", RData: []string{info.Value}, } if resp != nil && resp.StatusCode == http.StatusOK { _, err = recordService.Update(rrSetKeyData, rrSetData) } else { _, err = recordService.Create(rrSetKeyData, rrSetData) } if err != nil { return fmt.Errorf("ultradns: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("ultradns: could not find zone for domain %q: %w", domain, err) } recordService, err := record.Get(d.client) if err != nil { return fmt.Errorf("ultradns: %w", err) } rrSetKeyData := &rrset.RRSetKey{ Owner: info.EffectiveFQDN, Zone: authZone, RecordType: "TXT", } _, err = recordService.Delete(rrSetKeyData) if err != nil { return fmt.Errorf("ultradns: %w", err) } return nil } ================================================ FILE: providers/dns/ultradns/ultradns.toml ================================================ Name = "Ultradns" Description = '''''' URL = "https://vercara.com/authoritative-dns" Code = "ultradns" Since = "v4.10.0" Example = ''' ULTRADNS_USERNAME=username \ ULTRADNS_PASSWORD=password \ lego --dns ultradns -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ULTRADNS_USERNAME = "API Username" ULTRADNS_PASSWORD = "API Password" [Configuration.Additional] ULTRADNS_ENDPOINT = "API endpoint URL, defaults to https://api.ultradns.com/" ULTRADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" ULTRADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" ULTRADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" [Links] API = "https://ultra-portalstatic.ultradns.com/static/docs/REST-API_User_Guide.pdf" GoClient = "https://github.com/ultradns/ultradns-go-sdk" ================================================ FILE: providers/dns/ultradns/ultradns_test.go ================================================ package ultradns import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvUsername, EnvPassword, EnvEndpoint, EnvTTL, EnvPropagationTimeout, EnvPollingInterval). WithDomain(envDomain) func TestNewDefaultConfig(t *testing.T) { defer envTest.RestoreEnv() testCases := []struct { desc string envVars map[string]string expected *Config }{ { desc: "default configuration", expected: &Config{ Endpoint: "https://api.ultradns.com/", TTL: 120, PropagationTimeout: 2 * time.Minute, PollingInterval: 4 * time.Second, }, }, { desc: "input configuration", envVars: map[string]string{ EnvEndpoint: "https://example.com/", EnvTTL: "99", EnvPropagationTimeout: "60", EnvPollingInterval: "60", }, expected: &Config{ Endpoint: "https://example.com/", TTL: 99, PropagationTimeout: 60 * time.Second, PollingInterval: 60 * time.Second, }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { envTest.ClearEnv() envTest.Apply(test.envVars) config := NewDefaultConfig() assert.Equal(t, test.expected, config) }) } } func TestNewDNSProvider(t *testing.T) { defer envTest.RestoreEnv() testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "missing username and password", expected: "ultradns: some credentials information are missing: ULTRADNS_USERNAME,ULTRADNS_PASSWORD", }, { desc: "missing username", envVars: map[string]string{ EnvPassword: "password", }, expected: "ultradns: some credentials information are missing: ULTRADNS_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "username", }, expected: "ultradns: some credentials information are missing: ULTRADNS_PASSWORD", }, { desc: "success", envVars: map[string]string{ EnvUsername: "username", EnvPassword: "password", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.config) assert.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "api_username", password: "api_password", }, { desc: "missing credentials", expected: "ultradns: Missing required parameters: [ username, password ]", }, { desc: "missing username", username: "", password: "api_password", expected: "ultradns: Missing required parameters: [ username ]", }, { desc: "missing password", username: "api_username", password: "", expected: "ultradns: Missing required parameters: [ password ]", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { envTest.ClearEnv() config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/uniteddomains/uniteddomains.go ================================================ // Package uniteddomains implements a DNS provider for solving the DNS-01 challenge using United-Domains. package uniteddomains import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/ionos" ) // Environment variables names. const ( envNamespace = "UNITEDDOMAINS_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://dnsapi.united-domains.de/dns" const minTTL = 300 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = ionos.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute), 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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for United-Domains. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("uniteddomains: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for United-Domains. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("uniteddomains: the configuration of the DNS provider is nil") } provider, err := ionos.NewDNSProviderConfig(config, defaultBaseURL) if err != nil { return nil, fmt.Errorf("uniteddomains: %w", err) } return &DNSProvider{prv: provider}, 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.prv.Timeout() } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("uniteddomains: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("uniteddomains: %w", err) } return nil } ================================================ FILE: providers/dns/uniteddomains/uniteddomains.toml ================================================ Name = "United-Domains" Description = '''''' URL = "https://www.united-domains.de/" Code = "uniteddomains" Since = "v4.29.0" Example = ''' UNITEDDOMAINS_API_KEY=xxxxxxxx \ lego --dns uniteddomains -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] UNITEDDOMAINS_API_KEY = "API key `.` https://www.united-domains.de/help/faq-article/getting-started-with-the-united-domains-dns-api/" [Configuration.Additional] UNITEDDOMAINS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" UNITEDDOMAINS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 900)" UNITEDDOMAINS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" UNITEDDOMAINS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.united-domains.de/dns-apidoc/" ================================================ FILE: providers/dns/uniteddomains/uniteddomains_test.go ================================================ package uniteddomains import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIKey: "", }, expected: "uniteddomains: some credentials information are missing: UNITEDDOMAINS_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string tll int expected string }{ { desc: "success", apiKey: "123", tll: minTTL, }, { desc: "missing credentials", tll: minTTL, expected: "uniteddomains: credentials missing", }, { desc: "invalid TTL", apiKey: "123", tll: 30, expected: "uniteddomains: invalid TTL, TTL (30) must be greater than 300", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.TTL = test.tll p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/variomedia/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api.variomedia.de" const authorizationHeader = "Authorization" // Client the API client for Variomedia. type Client struct { apiToken string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiToken string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiToken: apiToken, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // CreateDNSRecord creates a new DNS entry. // https://api.variomedia.de/docs/dns-records.html#erstellen func (c *Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateDNSRecordResponse, error) { endpoint := c.baseURL.JoinPath("dns-records") data := CreateDNSRecordRequest{Data: Data{ Type: "dns-record", Attributes: record, }} req, err := newJSONRequest(ctx, http.MethodPost, endpoint, data) if err != nil { return nil, err } var result CreateDNSRecordResponse err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } // DeleteDNSRecord deletes a DNS record. // https://api.variomedia.de/docs/dns-records.html#l%C3%B6schen func (c *Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordResponse, error) { endpoint := c.baseURL.JoinPath("dns-records", id) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return nil, err } var result DeleteRecordResponse err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } // GetJob returns a single job based on its ID. // https://api.variomedia.de/docs/job-queue.html func (c *Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error) { endpoint := c.baseURL.JoinPath("queue-jobs", id) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } var result GetJobResponse err = c.do(req, &result) if err != nil { return nil, err } return &result, nil } func (c *Client) do(req *http.Request, data any) error { req.Header.Set(authorizationHeader, "token "+c.apiToken) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, data) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/vnd.variomedia.v1+json") if payload != nil { req.Header.Set("Content-Type", "application/vnd.api+json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return errAPI } ================================================ FILE: providers/dns/variomedia/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithAccept("application/vnd.variomedia.v1+json"). WithAuthorization("token secret")) } func TestClient_CreateDNSRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns-records", servermock.ResponseFromFixture("POST_dns-records.json"), servermock.CheckHeader(). WithContentType("application/vnd.api+json"), servermock.CheckRequestJSONBody(`{"data":{"type":"dns-record","attributes":{"record_type":"TXT","name":"_acme-challenge","domain":"example.com","data":"test","ttl":300}}}`)). Build(t) record := DNSRecord{ RecordType: "TXT", Name: "_acme-challenge", Domain: "example.com", Data: "test", TTL: 300, } resp, err := client.CreateDNSRecord(t.Context(), record) require.NoError(t, err) expected := &CreateDNSRecordResponse{ Data: struct { Type string `json:"type"` ID string `json:"id"` Attributes struct { Status string `json:"status"` } `json:"attributes"` Links struct { QueueJob string `json:"queue-job"` DNSRecord string `json:"dns-record"` } `json:"links"` }{ Type: "queue-job", ID: "18181818", Attributes: struct { Status string `json:"status"` }{ Status: "pending", }, Links: struct { QueueJob string `json:"queue-job"` DNSRecord string `json:"dns-record"` }{ QueueJob: "https://api.variomedia.de/queue-jobs/18181818", DNSRecord: "https://api.variomedia.de/dns-records/19191919", }, }, } assert.Equal(t, expected, resp) } func TestClient_DeleteDNSRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns-records/test", servermock.ResponseFromFixture("DELETE_dns-records_pending.json")). Build(t) resp, err := client.DeleteDNSRecord(t.Context(), "test") require.NoError(t, err) expected := &DeleteRecordResponse{ Data: struct { ID string `json:"id"` Type string `json:"type"` Attributes struct { JobType string `json:"job_type"` Status string `json:"status"` } `json:"attributes"` Links struct { Self string `json:"self"` Object string `json:"object"` } `json:"links"` }{ ID: "303030", Type: "queue-job", Attributes: struct { JobType string `json:"job_type"` Status string `json:"status"` }{ Status: "pending", }, }, } assert.Equal(t, expected, resp) } func TestClient_GetJob(t *testing.T) { client := mockBuilder(). Route("GET /queue-jobs/test", servermock.ResponseFromFixture("GET_queue-jobs.json")). Build(t) resp, err := client.GetJob(t.Context(), "test") require.NoError(t, err) expected := &GetJobResponse{ Data: struct { ID string `json:"id"` Type string `json:"type"` Attributes struct { JobType string `json:"job_type"` Status string `json:"status"` } `json:"attributes"` Links struct { Self string `json:"self"` Object string `json:"object"` } `json:"links"` }{ ID: "171717", Type: "queue-job", Attributes: struct { JobType string `json:"job_type"` Status string `json:"status"` }{ JobType: "dns-record", Status: "done", }, Links: struct { Self string `json:"self"` Object string `json:"object"` }{ Self: "https://api.variomedia.de/queue-jobs/171717", Object: "https://api.variomedia.de/dns-records/212121", }, }, } assert.Equal(t, expected, resp) } ================================================ FILE: providers/dns/variomedia/internal/fixtures/DELETE_dns-records_done.json ================================================ { "data": { "id": "303030", "type": "queue-job", "attributes": { "job_type": "dns-record", "status": "done" }, "relationships": { "owner": { "data": { "id": "505050", "type": "customer" } } }, "links": { "self": "https://api.variomedia.de/queue-jobs/303030", "object": "https://api.variomedia.de/dns-records/212121" } }, "links": { "self": "https://api.variomedia.de/queue-jobs/303030" } } ================================================ FILE: providers/dns/variomedia/internal/fixtures/DELETE_dns-records_pending.json ================================================ { "data": { "id": "303030", "type": "queue-job", "attributes": { "status": "pending" }, "links": { "queue-job": "https://api.variomedia.de/queue-jobs/303030" } }, "links": { "self": "https://api.variomedia.de/dns-records/212121" } } ================================================ FILE: providers/dns/variomedia/internal/fixtures/GET_dns-records.json ================================================ { "data": { "id": "20202020", "type": "dns-record", "links": { "self": "https://api.variomedia.de/dns-records/20202020" }, "attributes": { "record_type": "TXT", "fqdn": "my-test-record.example.com", "fqdn_ace": "my-test-record.example.com", "name": "my-test-record", "name_ace": "my-test-record", "domain": "example.com", "data": "test", "ttl": 300 } }, "links": { "self": "https://api.variomedia.de/dns-records" } } ================================================ FILE: providers/dns/variomedia/internal/fixtures/GET_queue-jobs.json ================================================ { "data": { "id": "171717", "type": "queue-job", "links": { "self": "https://api.variomedia.de/queue-jobs/171717", "object": "https://api.variomedia.de/dns-records/212121" }, "attributes": { "job_type": "dns-record", "status": "done" }, "relationships": { "owner": { "data": { "id": "505050", "type": "customer" } } } }, "links": { "self": "https://api.variomedia.de/queue-jobs/171717" } } ================================================ FILE: providers/dns/variomedia/internal/fixtures/POST_dns-records.json ================================================ { "data": { "type": "queue-job", "id": "18181818", "attributes": { "status": "pending" }, "links": { "queue-job": "https://api.variomedia.de/queue-jobs/18181818", "dns-record": "https://api.variomedia.de/dns-records/19191919" } }, "links": { "self": "https://api.variomedia.de/dns-records" } } ================================================ FILE: providers/dns/variomedia/internal/fixtures/error.json ================================================ { "errors": [ { "status": "401", "title": "The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), or your browser doesn't understand how to supply the credentials required.", "id": "unauthorized" } ], "links": { "self": "https://api.variomedia.de/dns-records" } } ================================================ FILE: providers/dns/variomedia/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type CreateDNSRecordRequest struct { Data Data `json:"data"` } type Data struct { Type string `json:"type"` Attributes DNSRecord `json:"attributes"` } type DNSRecord struct { RecordType string `json:"record_type,omitempty"` Name string `json:"name,omitempty"` Domain string `json:"domain,omitempty"` Data string `json:"data,omitempty"` TTL int `json:"ttl,omitempty"` } type APIError struct { Errors []ErrorItem `json:"errors"` } func (a APIError) Error() string { var parts []string for _, data := range a.Errors { parts = append(parts, fmt.Sprintf("status: %s, title: %s, id: %s", data.Status, data.Title, data.ID)) } return strings.Join(parts, ", ") } type ErrorItem struct { Status string `json:"status,omitempty"` Title string `json:"title,omitempty"` ID string `json:"id,omitempty"` } type CreateDNSRecordResponse struct { Data struct { Type string `json:"type"` ID string `json:"id"` Attributes struct { Status string `json:"status"` } `json:"attributes"` Links struct { QueueJob string `json:"queue-job"` DNSRecord string `json:"dns-record"` } `json:"links"` } `json:"data"` } type GetJobResponse struct { Data struct { ID string `json:"id"` Type string `json:"type"` Attributes struct { JobType string `json:"job_type"` Status string `json:"status"` } `json:"attributes"` Links struct { Self string `json:"self"` Object string `json:"object"` } `json:"links"` } `json:"data"` } type DeleteRecordResponse struct { Data struct { ID string `json:"id"` Type string `json:"type"` Attributes struct { JobType string `json:"job_type"` Status string `json:"status"` } `json:"attributes"` Links struct { Self string `json:"self"` Object string `json:"object"` } `json:"links"` } `json:"data"` } ================================================ FILE: providers/dns/variomedia/variomedia.go ================================================ // Package variomedia implements a DNS provider for solving the DNS-01 challenge using Variomedia DNS. package variomedia import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/variomedia/internal" ) // Environment variables names. const ( envNamespace = "VARIOMEDIA_" EnvAPIToken = envNamespace + "API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIToken string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("variomedia: %w", err) } config := NewDefaultConfig() config.APIToken = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Variomedia. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.APIToken == "" { return nil, errors.New("variomedia: missing credentials") } client := internal.NewClient(config.APIToken) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, 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 } // Sequential All DNS challenges for this provider will be resolved sequentially. // Returns the interval between each iteration. func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("variomedia: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("variomedia: %w", err) } ctx := context.Background() record := internal.DNSRecord{ RecordType: "TXT", Name: subDomain, Domain: dns01.UnFqdn(authZone), Data: info.Value, TTL: d.config.TTL, } cdrr, err := d.client.CreateDNSRecord(ctx, record) if err != nil { return fmt.Errorf("variomedia: %w", err) } err = d.waitJob(ctx, domain, cdrr.Data.ID) if err != nil { return fmt.Errorf("variomedia: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = strings.TrimPrefix(cdrr.Data.Links.DNSRecord, "https://api.variomedia.de/dns-records/") d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record previously created. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) ctx := context.Background() // get the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("variomedia: unknown record ID for '%s'", info.EffectiveFQDN) } ddrr, err := d.client.DeleteDNSRecord(ctx, recordID) if err != nil { return fmt.Errorf("variomedia: %w", err) } err = d.waitJob(ctx, domain, ddrr.Data.ID) if err != nil { return fmt.Errorf("variomedia: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) waitJob(ctx context.Context, domain, id string) error { return wait.Retry(ctx, func() error { result, err := d.client.GetJob(ctx, id) if err != nil { return fmt.Errorf("apply change on %s: %w", domain, err) } log.Infof("variomedia: [%s] %s: %s %s", domain, result.Data.ID, result.Data.Attributes.JobType, result.Data.Attributes.Status) if result.Data.Attributes.Status != "done" { return fmt.Errorf("apply change on %s: status: %s", domain, result.Data.Attributes.Status) } return nil }, backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), backoff.WithMaxElapsedTime(d.config.PropagationTimeout), ) } ================================================ FILE: providers/dns/variomedia/variomedia.toml ================================================ Name = "Variomedia" Description = '''''' URL = "https://www.variomedia.de/" Code = "variomedia" Since = "v4.8.0" Example = ''' VARIOMEDIA_API_TOKEN=xxxx \ lego --dns variomedia -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] VARIOMEDIA_API_TOKEN = "API token" [Configuration.Additional] VARIOMEDIA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" VARIOMEDIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" VARIOMEDIA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" VARIOMEDIA_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" VARIOMEDIA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.variomedia.de/docs/dns-records.html" ================================================ FILE: providers/dns/variomedia/variomedia_test.go ================================================ package variomedia import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "secret", }, }, { desc: "missing API token", expected: "variomedia: some credentials information are missing: VARIOMEDIA_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string apiToken string }{ { desc: "success", apiToken: "secret", }, { desc: "missing api token", apiToken: "", expected: "variomedia: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIToken = test.apiToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/vegadns/fixtures/create_record.json ================================================ { "status": "ok", "record": { "name": "_acme-challenge.example.com", "value": "my_challenge", "record_type": "TXT", "ttl": 3600, "record_id": 3, "location_id": null, "domain_id": 1 } } ================================================ FILE: providers/dns/vegadns/fixtures/record_delete.json ================================================ { "status": "ok" } ================================================ FILE: providers/dns/vegadns/fixtures/records.json ================================================ { "status": "ok", "total_records": 2, "domain": { "status": "active", "domain": "example.com", "owner_id": 0, "domain_id": 1 }, "records": [ { "retry": "2048", "minimum": "2560", "refresh": "16384", "email": "hostmaster.example.com", "record_type": "SOA", "expire": "1048576", "ttl": 86400, "record_id": 1, "nameserver": "ns1.example.com", "domain_id": 1, "serial": "" }, { "name": "example.com", "value": "ns1.example.com", "record_type": "NS", "ttl": 3600, "record_id": 2, "location_id": null, "domain_id": 1 }, { "name": "_acme-challenge.example.com", "value": "my_challenge", "record_type": "TXT", "ttl": 3600, "record_id": 3, "location_id": null, "domain_id": 1 } ] } ================================================ FILE: providers/dns/vegadns/fixtures/token.json ================================================ { "access_token": "699dd4ff-e381-46b8-8bf8-5de49dd56c1f", "token_type": "bearer", "expires_in": 3600 } ================================================ FILE: providers/dns/vegadns/vegadns.go ================================================ // Package vegadns implements a DNS provider for solving the DNS-01 challenge using VegaDNS. package vegadns import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/vegadns" ) // Environment variables names. const ( envNamespace = "VEGADNS_" EnvKey = "SECRET_VEGADNS_KEY" EnvSecret = "SECRET_VEGADNS_SECRET" EnvURL = envNamespace + "URL" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string APIKey string APISecret string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 10), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 12*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, time.Minute), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *vegadns.Client } // NewDNSProvider returns a DNSProvider instance configured for VegaDNS. // Credentials must be passed in the environment variables: // VEGADNS_URL, SECRET_VEGADNS_KEY, SECRET_VEGADNS_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvURL) if err != nil { return nil, fmt.Errorf("vegadns: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvURL] config.APIKey = env.GetOrFile(EnvKey) config.APISecret = env.GetOrFile(EnvSecret) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for VegaDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("vegadns: the configuration of the DNS provider is nil") } if config.HTTPClient == nil { config.HTTPClient = &http.Client{Timeout: 30 * time.Second} } config.HTTPClient = clientdebug.Wrap(config.HTTPClient) client, err := vegadns.NewClient(config.BaseURL, vegadns.WithOAuth(config.APIKey, config.APISecret), vegadns.WithHTTPClient(config.HTTPClient), ) if err != nil { return nil, fmt.Errorf("vegadns: %w", err) } 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 { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) domainID, err := d.findDomainID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("vegadns: find domain ID for %s: %w", info.EffectiveFQDN, err) } err = d.client.CreateTXTRecord(ctx, domainID, dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL) if err != nil { return fmt.Errorf("vegadns: create TXT record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) domainID, err := d.findDomainID(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("vegadns: find domain ID for %s: %w", info.EffectiveFQDN, err) } recordID, err := d.findRecordID(ctx, domainID, dns01.UnFqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("vegadns: find record ID for %d: %w", domainID, err) } err = d.client.DeleteRecord(ctx, recordID) if err != nil { return fmt.Errorf("vegadns: delete record: %w", err) } return nil } func (d *DNSProvider) findDomainID(ctx context.Context, fqdn string) (int, error) { for host := range dns01.UnFqdnDomainsSeq(fqdn) { id, err := d.client.GetDomainID(ctx, host) if err != nil { continue } return id, nil } return 0, errors.New("domain not found") } func (d *DNSProvider) findRecordID(ctx context.Context, domainID int, name string) (int, error) { records, err := d.client.GetRecords(ctx, domainID) if err != nil { return 0, fmt.Errorf("get records: %w", err) } for _, r := range records { if r.Name == name && r.RecordType == "TXT" { return r.RecordID, nil } } return 0, errors.New("record not found") } ================================================ FILE: providers/dns/vegadns/vegadns.toml ================================================ Name = "VegaDNS" Description = '''''' URL = "https://github.com/shupp/VegaDNS-API" Code = "vegadns" Since = "v1.1.0" Example = '''''' [Configuration] [Configuration.Credentials] SECRET_VEGADNS_KEY = "API key" SECRET_VEGADNS_SECRET = "API secret" VEGADNS_URL = "API endpoint URL" [Configuration.Additional] VEGADNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 60)" VEGADNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 720)" VEGADNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" [Links] API = "https://github.com/shupp/VegaDNS-API" GoClient = "https://github.com/OpenDNS/vegadns2client" ================================================ FILE: providers/dns/vegadns/vegadns_test.go ================================================ package vegadns import ( "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testDomain = "example.com" var envTest = tester.NewEnvTest(EnvKey, EnvSecret, EnvURL) func TestNewDNSProvider_Fail(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() _, err := NewDNSProvider() require.Error(t, err, "VEGADNS_URL env missing") } func TestDNSProvider_TimeoutSuccess(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() provider := mockBuilder().Build(t) timeout, interval := provider.Timeout() assert.Equal(t, 12*time.Minute, timeout) assert.Equal(t, 1*time.Minute, interval) } func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string handler http.Handler builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "success", builder: mockBuilder(). Route("POST /1.0/token", servermock.ResponseFromFixture("token.json")). Route("GET /1.0/domains", getDomainHandler()). Route("POST /1.0/records", servermock.ResponseFromFixture("create_record.json"). WithStatusCode(http.StatusCreated)), }, { desc: "fail to find the zone", builder: mockBuilder(). Route("POST /1.0/token", servermock.ResponseFromFixture("token.json")). Route("GET /1.0/domains", servermock.Noop(). WithStatusCode(http.StatusNotFound)), expectedError: "vegadns: find domain ID for _acme-challenge.example.com.: domain not found", }, { desc: "fail to create TXT record", builder: mockBuilder(). Route("POST /1.0/token", servermock.ResponseFromFixture("token.json")). Route("GET /1.0/domains", getDomainHandler()). Route("POST /1.0/records", servermock.Noop(). WithStatusCode(http.StatusBadRequest)), expectedError: "vegadns: create TXT record: bad answer from VegaDNS (code: 400, message: )", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() provider := test.builder.Build(t) err := provider.Present(testDomain, "token", "keyAuth") if test.expectedError == "" { assert.NoError(t, err) } else { assert.EqualError(t, err, test.expectedError) } }) } } func TestDNSProvider_CleanUp(t *testing.T) { testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "success", builder: mockBuilder(). Route("POST /1.0/token", servermock.ResponseFromFixture("token.json")). Route("GET /1.0/domains", getDomainHandler()). Route("GET /1.0/records", servermock.ResponseFromFixture("records.json"), servermock.CheckQueryParameter().With("domain_id", "1")). Route("DELETE /1.0/records/3", servermock.ResponseFromFixture("record_delete.json")), }, { desc: "fail to find the zone", builder: mockBuilder(). Route("POST /1.0/token", servermock.ResponseFromFixture("token.json")). Route("GET /1.0/domains", servermock.Noop(). WithStatusCode(http.StatusNotFound)), expectedError: "vegadns: find domain ID for _acme-challenge.example.com.: domain not found", }, { desc: "fail to get record ID", builder: mockBuilder(). Route("POST /1.0/token", servermock.ResponseFromFixture("token.json")). Route("GET /1.0/domains", getDomainHandler()). Route("GET /1.0/records", servermock.Noop(). WithStatusCode(http.StatusNotFound), servermock.CheckQueryParameter().With("domain_id", "1")), expectedError: "vegadns: find record ID for 1: get records: bad answer from VegaDNS (code: 404, message: )", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() provider := test.builder.Build(t) err := provider.CleanUp(testDomain, "token", "keyAuth") if test.expectedError == "" { assert.NoError(t, err) } else { assert.EqualError(t, err, test.expectedError) } }) } } func getDomainHandler() http.HandlerFunc { return func(rw http.ResponseWriter, req *http.Request) { if req.URL.Query().Get("search") == testDomain { fmt.Fprint(rw, ` { "domains":[ { "domain_id":1, "domain":"example.com", "status":"active", "owner_id":0 } ] } `) return } rw.WriteHeader(http.StatusNotFound) } } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { envTest.Apply(map[string]string{ EnvKey: "key", EnvSecret: "secret", EnvURL: server.URL, }) return NewDNSProvider() }) } ================================================ FILE: providers/dns/vercel/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) const defaultBaseURL = "https://api.vercel.com" // Client Vercel client. type Client struct { teamID string baseURL *url.URL httpClient *http.Client } // NewClient creates a Client. func NewClient(hc *http.Client, teamID string) *Client { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } return &Client{ teamID: teamID, baseURL: baseURL, httpClient: hc, } } // CreateRecord creates a DNS record. // https://vercel.com/docs/rest-api#endpoints/dns/create-a-dns-record func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) (*CreateRecordResponse, error) { endpoint := c.baseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } respData := &CreateRecordResponse{} err = c.do(req, respData) if err != nil { return nil, err } return respData, nil } // DeleteRecord deletes a DNS record. // https://vercel.com/docs/rest-api#endpoints/dns/delete-a-dns-record func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error { endpoint := c.baseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { if c.teamID != "" { query := req.URL.Query() query.Add("teamId", c.teamID) req.URL.RawQuery = query.Encode() } resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIErrorResponse err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, response.Error) } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/vercel/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "123") client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer secret")) } func TestClient_CreateRecord(t *testing.T) { client := mockBuilder(). Route("POST /v2/domains/example.com/records", servermock.RawStringResponse(`{ "uid": "9e2eab60-0ba5-4dff-b481-2999c9764b84", "updated": 1 }`), servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.example.com.","type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":60}`), servermock.CheckQueryParameter().Strict(). With("teamId", "123")). Build(t) record := Record{ Name: "_acme-challenge.example.com.", Type: "TXT", Value: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", TTL: 60, } resp, err := client.CreateRecord(t.Context(), "example.com.", record) require.NoError(t, err) expected := &CreateRecordResponse{ UID: "9e2eab60-0ba5-4dff-b481-2999c9764b84", Updated: 1, } assert.Equal(t, expected, resp) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /v2/domains/example.com/records/1234567", nil, servermock.CheckQueryParameter().Strict(). With("teamId", "123")). Build(t) err := client.DeleteRecord(t.Context(), "example.com.", "1234567") require.NoError(t, err) } ================================================ FILE: providers/dns/vercel/internal/types.go ================================================ package internal import "fmt" type Record struct { ID string `json:"id,omitempty"` Slug string `json:"slug,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value,omitempty"` TTL int `json:"ttl,omitempty"` } // CreateRecordResponse represents a response from Vercel's API after making a DNS record. type CreateRecordResponse struct { UID string `json:"uid"` Updated int `json:"updated,omitempty"` } type APIErrorResponse struct { Error *APIError `json:"error"` } type APIError struct { Code string `json:"code"` Message string `json:"message"` } func (a APIError) Error() string { return fmt.Sprintf("%s: %s", a.Code, a.Message) } ================================================ FILE: providers/dns/vercel/vercel.go ================================================ // Package vercel implements a DNS provider for solving the DNS-01 challenge using Vercel DNS. package vercel import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/vercel/internal" ) // Environment variables names. const ( envNamespace = "VERCEL_" EnvAuthToken = envNamespace + "API_TOKEN" EnvTeamID = envNamespace + "TEAM_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AuthToken string TeamID 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, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client recordIDs map[string]string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Vercel. // Credentials must be passed in the environment variables: VERCEL_API_TOKEN, VERCEL_TEAM_ID. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAuthToken) if err != nil { return nil, fmt.Errorf("vercel: %w", err) } config := NewDefaultConfig() config.AuthToken = values[EnvAuthToken] config.TeamID = env.GetOrDefaultString(EnvTeamID, "") return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Digital Ocean. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("vercel: the configuration of the DNS provider is nil") } if config.AuthToken == "" { return nil, errors.New("vercel: credentials missing") } client := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), ), config.TeamID, ) return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]string), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("vercel: could not find zone for domain %q: %w", domain, err) } record := internal.Record{ Name: info.EffectiveFQDN, Type: "TXT", Value: info.Value, TTL: d.config.TTL, } respData, err := d.client.CreateRecord(context.Background(), authZone, record) if err != nil { return fmt.Errorf("vercel: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = respData.UID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("vercel: could not find zone for domain %q: %w", domain, err) } // get the record's unique ID from when we created it d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("vercel: unknown record ID for '%s'", info.EffectiveFQDN) } err = d.client.DeleteRecord(context.Background(), authZone, recordID) if err != nil { return fmt.Errorf("vercel: %w", err) } // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/vercel/vercel.toml ================================================ Name = "Vercel" Description = '''''' URL = "https://vercel.com" Code = "vercel" Since = "v4.7.0" Example = ''' VERCEL_API_TOKEN=xxxxxx \ lego --dns vercel -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] VERCEL_API_TOKEN = "Authentication token" [Configuration.Additional] VERCEL_TEAM_ID = "Team ID (ex: team_xxxxxxxxxxxxxxxxxxxxxxxx)" VERCEL_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" VERCEL_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" VERCEL_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" VERCEL_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://vercel.com/docs/rest-api#endpoints/dns" ================================================ FILE: providers/dns/vercel/vercel_test.go ================================================ package vercel import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAuthToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAuthToken: "", }, expected: "vercel: some credentials information are missing: VERCEL_API_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string authToken string expected string }{ { desc: "success", authToken: "123", }, { desc: "missing credentials", expected: "vercel: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AuthToken = test.authToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/versio/fixtures/error_failToCreateTXT.json ================================================ { "error": { "code": 400, "message": "ProcessError|DNS record invalid type _acme-challenge.example.eu. TST" } } ================================================ FILE: providers/dns/versio/fixtures/error_failToFindZone.json ================================================ { "error": { "code": 401, "message": "ObjectDoesNotExist|Domain not found" } } ================================================ FILE: providers/dns/versio/fixtures/token.json ================================================ { "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f", "token_type":"bearer", "expires_in":3600 } ================================================ FILE: providers/dns/versio/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultBaseURL default API endpoint. const DefaultBaseURL = "https://www.versio.nl/api/v1/" // Client the API client for Versio DNS. type Client struct { username string password string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(username, password string) *Client { baseURL, _ := url.Parse(DefaultBaseURL) return &Client{ username: username, password: password, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // UpdateDomain updates domain information. // https://www.versio.nl/RESTapidoc/#api-Domains-Update func (c *Client) UpdateDomain(ctx context.Context, domain string, msg *DomainInfo) (*DomainInfoResponse, error) { endpoint := c.BaseURL.JoinPath("domains", domain, "update") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, msg) if err != nil { return nil, err } respData := &DomainInfoResponse{} err = c.do(req, respData) if err != nil { return nil, err } return respData, nil } // GetDomain gets domain information. // https://www.versio.nl/RESTapidoc/#api-Domains-Domain func (c *Client) GetDomain(ctx context.Context, domain string) (*DomainInfoResponse, error) { endpoint := c.BaseURL.JoinPath("domains", domain) query := endpoint.Query() query.Set("show_dns_records", "true") endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } respData := &DomainInfoResponse{} err = c.do(req, respData) if err != nil { return nil, err } return respData, nil } func (c *Client) do(req *http.Request, result any) error { if c.username != "" && c.password != "" { req.SetBasicAuth(c.username, c.password) } resp, err := c.HTTPClient.Do(req) if resp != nil { defer func() { _ = resp.Body.Close() }() } if err != nil { return errutils.NewHTTPDoError(req, err) } if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if err = json.Unmarshal(raw, result); err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) response := &ErrorResponse{} err := json.Unmarshal(raw, response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, response.Message) } ================================================ FILE: providers/dns/versio/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("user", "secret")) } func TestClient_GetDomain(t *testing.T) { client := mockBuilder(). Route("GET /domains/example.com", servermock.ResponseFromFixture("get-domain.json"), servermock.CheckQueryParameter().Strict(). With("show_dns_records", "true")). Build(t) records, err := client.GetDomain(t.Context(), "example.com") require.NoError(t, err) expected := &DomainInfoResponse{DomainInfo: DomainInfo{DNSRecords: []Record{ {Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600}, {Type: "TXT", Name: "example.com", Value: "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", Priority: 0, TTL: 3600}, {Type: "A", Name: "example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "ftp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "localhost.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "pop.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "smtp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "www.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "dev.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "_domainkey.domain.com.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "MX", Name: "example.com", Value: "spamfilter2.axc.eu", Priority: 0, TTL: 3600}, {Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400}, }}} assert.Equal(t, expected, records) } func TestClient_GetDomain_error(t *testing.T) { client := mockBuilder(). Route("GET /domains/example.com", servermock.ResponseFromFixture("get-domain-error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetDomain(t.Context(), "example.com") require.ErrorAs(t, err, &ErrorMessage{}) } func TestClient_UpdateDomain(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/update", servermock.ResponseFromFixture("update-domain.json"), servermock.CheckRequestJSONBodyFromFixture("update-domain-request.json")). Build(t) msg := &DomainInfo{DNSRecords: []Record{ {Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600}, {Type: "TXT", Name: "example.com", Value: "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", Priority: 0, TTL: 3600}, {Type: "A", Name: "example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "ftp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "localhost.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "pop.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "smtp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "www.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "dev.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "_domainkey.domain.com.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "MX", Name: "example.com", Value: "spamfilter2.axc.eu", Priority: 0, TTL: 3600}, {Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400}, }} records, err := client.UpdateDomain(t.Context(), "example.com", msg) require.NoError(t, err) expected := &DomainInfoResponse{DomainInfo: DomainInfo{DNSRecords: []Record{ {Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600}, {Type: "TXT", Name: "example.com", Value: "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", Priority: 0, TTL: 3600}, {Type: "A", Name: "example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "ftp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "localhost.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "pop.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "smtp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "www.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "dev.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "_domainkey.domain.com.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "MX", Name: "example.com", Value: "spamfilter2.axc.eu", Priority: 0, TTL: 3600}, {Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400}, }}} assert.Equal(t, expected, records) } func TestClient_UpdateDomain_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/update", servermock.ResponseFromFixture("update-domain-error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) msg := &DomainInfo{DNSRecords: []Record{ {Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600}, {Type: "TXT", Name: "example.com", Value: "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", Priority: 0, TTL: 3600}, {Type: "A", Name: "example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "ftp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "localhost.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "pop.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "smtp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "www.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "dev.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "A", Name: "_domainkey.domain.com.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400}, {Type: "MX", Name: "example.com", Value: "spamfilter2.axc.eu", Priority: 0, TTL: 3600}, {Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400}, }} _, err := client.UpdateDomain(t.Context(), "example.com", msg) require.ErrorAs(t, err, &ErrorMessage{}) } ================================================ FILE: providers/dns/versio/internal/fixtures/README.md ================================================ Note: the snippets from the API documentation are wrong: invalid field type (ex: prio, TTL), and JSON format contains errors. So the files inside the fixtures have been partially adapted to fit the reality. ================================================ FILE: providers/dns/versio/internal/fixtures/get-domain-error.json ================================================ { "error": { "code": 401, "message": "You are not authorized to access this resource. Did you supply the correct credentials and have you IP (xxxx:xxxx:xxx:xxxx:xxxx:xxxx:xxxx:xxxx) whitelisted for API use?" } } ================================================ FILE: providers/dns/versio/internal/fixtures/get-domain.json ================================================ { "domainInfo": { "domain": "example.com", "status": "OK", "expire-date": "2020-10-01", "registrant_id": "4334", "reseller_id": "3253", "category_id": "674", "dnstemplate_id": "674", "lock": false, "auto_renew": false, "epp_code": "3fFerggEg", "ns": [], "dns_management": true, "dns_records": [ { "type": "MX", "name": "example.com", "value": "fallback.axc.eu", "prio": 20, "ttl": 3600 }, { "type": "TXT", "name": "example.com", "value": "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", "prio": 0, "ttl": 3600 }, { "type": "A", "name": "example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "ftp.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "localhost.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "pop.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "smtp.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "www.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "dev.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "_domainkey.domain.com.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "MX", "name": "example.com", "value": "spamfilter2.axc.eu", "prio": 0, "ttl": 3600 }, { "type": "A", "name": "redirect.example.com", "value": "localhost", "prio": 10, "ttl": 14400 } ], "dns_redirections": [ { "from": "redirect.example.com", "destination": "http:\/\/www.google.nl" } ], "dnssec_keys": [ { "flags": 256, "algorithm": 3, "public_key": "AwEAAZKsuPDwO1+Usao2X1rgdFhdT3LAxy5cbRNFNEy1qsauwSIYov5SU4GlG6ylXIVQwHF5AWfbD7lcZzw1IlNegvaLnoirJjcYZhz4ppQU5+M/1hfH7aNZIsyz7AhHwX7gpOeUdGBXTiXQ3m7ksGccVQ79h7yl2fiBDCryBSf49vOTqo3dI7KZM48vmeqOxPth3ANMXzt6osHENGIchdGgIOVy5Y7AsVecL4V+lbn2t47fFfJ2O9PwuuDBzO0HCCT/mmYVsvZ33kgc7QPFKB3LojoXdHFHl1jCsC98phIVGzJR54H2xRohQvfC2WAXFEx+YNDW1yv7zQFrUVVMFwCe/E8=" }, { "flags": 257, "algorithm": 8, "public_key": "AwEAAZKsuPDwO1+Usao2X1rgdFhdT3LAxy5cbRNFNEy1qsauwSIYov5SU4GlG6ylXIVQwHF5AWfbD7lcZzw1IlNegvaLnoirJjcYZhz4ppQU5+M/1hfH7aNZIsyz7AhHwX7gpOeUdGBXTiXQ3m7ksGccVQ79h7yl2fiBDCryBSf49vOTqo3dI7KZM48vmeqOxPth3ANMXzt6osHENGIchdGgIOVy5Y7AsVecL4V+lbn2t47fFfJ2O9PwuuDBzO0HCCT/mmYVsvZ33kgc7QPFKB3LojoXdHFHl1jCsC98phIVGzJR54H2xRohQvfC2WAXFEx+YNDW1yv7zQFrUVVMFwCe/E8=" } ] } } ================================================ FILE: providers/dns/versio/internal/fixtures/update-domain-error.json ================================================ { "error": { "code": 401, "message": "You are not authorized to access this resource. Did you supply the correct credentials and have you IP (xxxx:xxxx:xxx:xxxx:xxxx:xxxx:xxxx:xxxx) whitelisted for API use?" } } ================================================ FILE: providers/dns/versio/internal/fixtures/update-domain-request.json ================================================ { "dns_records": [ { "type": "MX", "name": "example.com", "value": "fallback.axc.eu", "prio": 20, "ttl": 3600 }, { "type": "TXT", "name": "example.com", "value": "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", "ttl": 3600 }, { "type": "A", "name": "example.com", "value": "185.13.227.159", "ttl": 14400 }, { "type": "A", "name": "ftp.example.com", "value": "185.13.227.159", "ttl": 14400 }, { "type": "A", "name": "localhost.example.com", "value": "185.13.227.159", "ttl": 14400 }, { "type": "A", "name": "pop.example.com", "value": "185.13.227.159", "ttl": 14400 }, { "type": "A", "name": "smtp.example.com", "value": "185.13.227.159", "ttl": 14400 }, { "type": "A", "name": "www.example.com", "value": "185.13.227.159", "ttl": 14400 }, { "type": "A", "name": "dev.example.com", "value": "185.13.227.159", "ttl": 14400 }, { "type": "A", "name": "_domainkey.domain.com.example.com", "value": "185.13.227.159", "ttl": 14400 }, { "type": "MX", "name": "example.com", "value": "spamfilter2.axc.eu", "ttl": 3600 }, { "type": "A", "name": "redirect.example.com", "value": "localhost", "prio": 10, "ttl": 14400 } ] } ================================================ FILE: providers/dns/versio/internal/fixtures/update-domain.json ================================================ { "domainInfo": { "domain": "example.com", "status": "OK", "expire-date": "2020-10-01", "registrant_id": "4334", "reseller_id": "3253", "category_id": "674", "dnstemplate_id": "674", "lock": false, "auto_renew": false, "epp_code": "3fFerggEg", "ns": [], "dns_management": true, "dns_records": [ { "type": "MX", "name": "example.com", "value": "fallback.axc.eu", "prio": 20, "ttl": 3600 }, { "type": "TXT", "name": "example.com", "value": "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", "prio": 0, "ttl": 3600 }, { "type": "A", "name": "example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "ftp.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "localhost.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "pop.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "smtp.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "www.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "dev.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "A", "name": "_domainkey.domain.com.example.com", "value": "185.13.227.159", "prio": 0, "ttl": 14400 }, { "type": "MX", "name": "example.com", "value": "spamfilter2.axc.eu", "prio": 0, "ttl": 3600 }, { "type": "A", "name": "redirect.example.com", "value": "localhost", "prio": 10, "ttl": 14400 } ], "dns_redirections": [ { "from": "redirect.example.com", "destination": "http:\/\/www.google.nl" } ], "dnssec_keys": [ { "flags": 256, "algorithm": 3, "public_key": "AwEAAZKsuPDwO1+Usao2X1rgdFhdT3LAxy5cbRNFNEy1qsauwSIYov5SU4GlG6ylXIVQwHF5AWfbD7lcZzw1IlNegvaLnoirJjcYZhz4ppQU5+M/1hfH7aNZIsyz7AhHwX7gpOeUdGBXTiXQ3m7ksGccVQ79h7yl2fiBDCryBSf49vOTqo3dI7KZM48vmeqOxPth3ANMXzt6osHENGIchdGgIOVy5Y7AsVecL4V+lbn2t47fFfJ2O9PwuuDBzO0HCCT/mmYVsvZ33kgc7QPFKB3LojoXdHFHl1jCsC98phIVGzJR54H2xRohQvfC2WAXFEx+YNDW1yv7zQFrUVVMFwCe/E8=" }, { "flags": 257, "algorithm": 8, "public_key": "AwEAAZKsuPDwO1+Usao2X1rgdFhdT3LAxy5cbRNFNEy1qsauwSIYov5SU4GlG6ylXIVQwHF5AWfbD7lcZzw1IlNegvaLnoirJjcYZhz4ppQU5+M/1hfH7aNZIsyz7AhHwX7gpOeUdGBXTiXQ3m7ksGccVQ79h7yl2fiBDCryBSf49vOTqo3dI7KZM48vmeqOxPth3ANMXzt6osHENGIchdGgIOVy5Y7AsVecL4V+lbn2t47fFfJ2O9PwuuDBzO0HCCT/mmYVsvZ33kgc7QPFKB3LojoXdHFHl1jCsC98phIVGzJR54H2xRohQvfC2WAXFEx+YNDW1yv7zQFrUVVMFwCe/E8=" } ] } } ================================================ FILE: providers/dns/versio/internal/types.go ================================================ package internal import "fmt" type DomainInfoResponse struct { DomainInfo DomainInfo `json:"domainInfo"` } type DomainInfo struct { DNSRecords []Record `json:"dns_records"` } type Record struct { Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` Priority int `json:"prio,omitempty"` TTL int `json:"ttl,omitempty"` } type ErrorResponse struct { Message ErrorMessage `json:"error"` } type ErrorMessage struct { Code int `json:"code,omitempty"` Message string `json:"message,omitempty"` } func (e ErrorMessage) Error() string { return fmt.Sprintf("%d: %s", e.Code, e.Message) } ================================================ FILE: providers/dns/versio/versio.go ================================================ // Package versio implements a DNS provider for solving the DNS-01 challenge using versio DNS. package versio import ( "context" "errors" "fmt" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/versio/internal" ) // Environment variables names. const ( envNamespace = "VERSIO_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvEndpoint = envNamespace + "ENDPOINT" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL *url.URL TTL int Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { baseURL, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, internal.DefaultBaseURL)) if err != nil { baseURL, _ = url.Parse(internal.DefaultBaseURL) } return &Config{ BaseURL: baseURL, TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client dnsEntriesMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("versio: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Versio. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("versio: the configuration of the DNS provider is nil") } if config.Username == "" { return nil, errors.New("versio: the versio username is missing") } if config.Password == "" { return nil, errors.New("versio: the versio password is missing") } client := internal.NewClient(config.Username, config.Password) if config.BaseURL != nil { client.BaseURL = config.BaseURL } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("versio: could not find zone for domain %q: %w", domain, err) } // use mutex to prevent race condition from getDNSRecords until postDNSRecords d.dnsEntriesMu.Lock() defer d.dnsEntriesMu.Unlock() ctx := context.Background() zoneName := dns01.UnFqdn(authZone) domains, err := d.client.GetDomain(ctx, zoneName) if err != nil { return fmt.Errorf("versio: %w", err) } txtRecord := internal.Record{ Type: "TXT", Name: info.EffectiveFQDN, Value: `"` + info.Value + `"`, TTL: d.config.TTL, } // Add new txtRecord to existing array of DNSRecords. // We'll need all the dns_records to add a new TXT record. msg := &domains.DomainInfo msg.DNSRecords = append(msg.DNSRecords, txtRecord) _, err = d.client.UpdateDomain(ctx, zoneName, msg) if err != nil { return fmt.Errorf("versio: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("versio: could not find zone for domain %q: %w", domain, err) } // use mutex to prevent race condition from getDNSRecords until postDNSRecords d.dnsEntriesMu.Lock() defer d.dnsEntriesMu.Unlock() ctx := context.Background() zoneName := dns01.UnFqdn(authZone) domains, err := d.client.GetDomain(ctx, zoneName) if err != nil { return fmt.Errorf("versio: %w", err) } // loop through the existing entries and remove the specific record msg := &internal.DomainInfo{} for _, e := range domains.DomainInfo.DNSRecords { if e.Name != info.EffectiveFQDN { msg.DNSRecords = append(msg.DNSRecords, e) } } _, err = d.client.UpdateDomain(ctx, zoneName, msg) if err != nil { return fmt.Errorf("versio: %w", err) } return nil } ================================================ FILE: providers/dns/versio/versio.toml ================================================ Name = "Versio.[nl|eu|uk]" Description = '''''' URL = "https://www.versio.nl/domeinnamen" Code = "versio" Since = "v2.7.0" Example = ''' VERSIO_USERNAME= \ VERSIO_PASSWORD= \ lego --dns versio -d '*.example.com' -d example.com run ''' Additional = ''' To test with the sandbox environment set ```VERSIO_ENDPOINT=https://www.versio.nl/testapi/v1/``` ''' [Configuration] [Configuration.Credentials] VERSIO_USERNAME = "Basic authentication username" VERSIO_PASSWORD = "Basic authentication password" [Configuration.Additional] VERSIO_ENDPOINT = "The endpoint URL of the API Server" VERSIO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" VERSIO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" VERSIO_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" VERSIO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" VERSIO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.versio.nl/RESTapidoc/" ================================================ FILE: providers/dns/versio/versio_test.go ================================================ package versio import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testDomain = "example.com" const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvEndpoint).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "me@example.com", EnvPassword: "SECRET", }, }, { desc: "missing token", envVars: map[string]string{ EnvPassword: "me@example.com", }, expected: "versio: some credentials information are missing: VERSIO_USERNAME", }, { desc: "missing key", envVars: map[string]string{ EnvUsername: "TOKEN", }, expected: "versio: some credentials information are missing: VERSIO_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "versio: some credentials information are missing: VERSIO_USERNAME,VERSIO_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string config *Config expected string }{ { desc: "success", config: &Config{ Username: "me@example.com", Password: "PW", }, }, { desc: "nil config", config: nil, expected: "versio: the configuration of the DNS provider is nil", }, { desc: "missing username", config: &Config{ Password: "PW", }, expected: "versio: the versio username is missing", }, { desc: "missing password", config: &Config{ Username: "UN", }, expected: "versio: the versio password is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p, err := NewDNSProviderConfig(test.config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "Success", builder: mockBuilder(). Route("GET /domains/example.com", servermock.ResponseFromFixture("token.json"), servermock.CheckQueryParameter().Strict(). With("show_dns_records", "true")). Route("POST /domains/example.com/update", servermock.ResponseFromFixture("token.json")), }, { desc: "FailToFindZone", builder: mockBuilder(). Route("GET /domains/example.com", servermock.ResponseFromFixture("error_failToFindZone.json"). WithStatusCode(http.StatusUnauthorized)), expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`, }, { desc: "FailToCreateTXT", builder: mockBuilder(). Route("GET /domains/example.com", servermock.ResponseFromFixture("token.json"), servermock.CheckQueryParameter().Strict(). With("show_dns_records", "true")). Route("POST /domains/example.com/update", servermock.ResponseFromFixture("error_failToCreateTXT.json"). WithStatusCode(http.StatusBadRequest)), expectedError: `versio: [status code: 400] 400: ProcessError|DNS record invalid type _acme-challenge.example.eu. TST`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() provider := test.builder.Build(t) err := provider.Present(testDomain, "token", "keyAuth") if test.expectedError == "" { require.NoError(t, err) } else { assert.EqualError(t, err, test.expectedError) } }) } } func TestDNSProvider_CleanUp(t *testing.T) { testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "Success", builder: mockBuilder(). Route("GET /domains/example.com", servermock.ResponseFromFixture("token.json"), servermock.CheckQueryParameter().Strict(). With("show_dns_records", "true")). Route("POST /domains/example.com/update", servermock.ResponseFromFixture("token.json")), }, { desc: "FailToFindZone", builder: mockBuilder(). Route("GET /domains/example.com", servermock.ResponseFromFixture("error_failToFindZone.json"). WithStatusCode(http.StatusUnauthorized)), expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() provider := test.builder.Build(t) err := provider.CleanUp(testDomain, "token", "keyAuth") if test.expectedError == "" { require.NoError(t, err) } else { require.EqualError(t, err, test.expectedError) } }) } } 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) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { envTest.Apply(map[string]string{ EnvUsername: "me@example.com", EnvPassword: "secret", EnvEndpoint: server.URL, }) provider, err := NewDNSProvider() if err != nil { return nil, err } provider.client.HTTPClient = server.Client() return provider, nil }) } ================================================ FILE: providers/dns/vinyldns/fixtures/recordSetChange-create.json ================================================ { "changeType": "Create", "created": "2021-03-04T00:49:00Z", "id": "27ba5c17-a217-4e8d-b662-b1dc8bee588f", "recordSet": { "account": "", "created": "2021-03-04T00:49:00Z", "id": "10000000-0000-0000-0000-000000000000", "name": "_acme-challenge.host", "records": [ { "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" } ], "status": "Active", "ttl": 30, "type": "TXT", "updated": "2021-03-04T00:49:00Z", "zoneId": "00000000-0000-0000-0000-000000000000" }, "singleBatchChangeIds": [], "status": "Complete", "userId": "50000000-0000-0000-0000-000000000000", "zone": { "account": "system", "acl": { "rules": [] }, "adminGroupId": "40000000-0000-0000-0000-000000000000", "created": "2020-07-15T21:15:36Z", "email": "Ops@company.invalid", "id": "00000000-0000-0000-0000-000000000000", "isTest": false, "latestSync": "2020-07-15T21:15:36Z", "name": "example.com.", "shared": false, "status": "Active", "updated": "2021-03-03T18:02:47Z" } } ================================================ FILE: providers/dns/vinyldns/fixtures/recordSetChange-delete.json ================================================ { "changeType": "Delete", "created": "2021-03-04T00:49:00Z", "id": "27ba5c17-a217-4e8d-b662-b1dc8bee588f", "recordSet": { "account": "", "created": "2021-03-04T00:49:00Z", "id": "10000000-0000-0000-0000-000000000000", "name": "_acme-challenge.host", "records": [ { "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" } ], "status": "Active", "ttl": 30, "type": "TXT", "updated": "2021-03-04T00:49:00Z", "zoneId": "00000000-0000-0000-0000-000000000000" }, "singleBatchChangeIds": [], "status": "Complete", "userId": "50000000-0000-0000-0000-000000000000", "zone": { "account": "system", "acl": { "rules": [] }, "adminGroupId": "40000000-0000-0000-0000-000000000000", "created": "2020-07-15T21:15:36Z", "email": "Ops@company.invalid", "id": "00000000-0000-0000-0000-000000000000", "isTest": false, "latestSync": "2020-07-15T21:15:36Z", "name": "example.com.", "shared": false, "status": "Active", "updated": "2021-03-03T18:02:47Z" } } ================================================ FILE: providers/dns/vinyldns/fixtures/recordSetDelete.json ================================================ { "changeType": "Delete", "created": "2021-03-04T16:21:54Z", "id": "20000000-0000-0000-0000-000000000000", "recordSet": { "account": "", "created": "2021-03-04T16:21:54Z", "id": "11000000-0000-0000-0000-000000000000", "name": "_acme-challenge.host", "records": [ { "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" } ], "status": "Pending", "ttl": 30, "type": "TXT", "zoneId": "00000000-0000-0000-0000-000000000000" }, "singleBatchChangeIds": [], "status": "Pending", "userId": "50000000-0000-0000-0000-000000000000", "zone": { "account": "system", "acl": { "rules": [] }, "adminGroupId": "40000000-0000-0000-0000-000000000000", "created": "2020-07-15T21:15:36Z", "email": "Ops@company.invalid", "id": "00000000-0000-0000-0000-000000000000", "isTest": false, "latestSync": "2020-07-15T21:15:36Z", "name": "example.com.", "shared": false, "status": "Active", "updated": "2021-03-03T18:02:47Z" } } ================================================ FILE: providers/dns/vinyldns/fixtures/recordSetUpdate-create.json ================================================ { "changeType": "Create", "created": "2021-03-04T16:21:54Z", "id": "20000000-0000-0000-0000-000000000000", "recordSet": { "account": "", "created": "2021-03-04T16:21:54Z", "id": "11000000-0000-0000-0000-000000000000", "name": "_acme-challenge.host", "records": [ { "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" } ], "status": "Pending", "ttl": 30, "type": "TXT", "zoneId": "00000000-0000-0000-0000-000000000000" }, "singleBatchChangeIds": [], "status": "Pending", "userId": "50000000-0000-0000-0000-000000000000", "zone": { "account": "system", "acl": { "rules": [] }, "adminGroupId": "40000000-0000-0000-0000-000000000000", "created": "2020-07-15T21:15:36Z", "email": "Ops@company.invalid", "id": "00000000-0000-0000-0000-000000000000", "isTest": false, "latestSync": "2020-07-15T21:15:36Z", "name": "example.com.", "shared": false, "status": "Active", "updated": "2021-03-03T18:02:47Z" } } ================================================ FILE: providers/dns/vinyldns/fixtures/recordSetsListAll-empty.json ================================================ { "maxItems": 100, "nameSort": "ASC", "recordNameFilter": "_acme-challenge.host", "recordSets": [] } ================================================ FILE: providers/dns/vinyldns/fixtures/recordSetsListAll.json ================================================ { "maxItems": 100, "nameSort": "ASC", "recordNameFilter": "_acme-challenge.host", "recordSets": [ { "accessLevel": "Delete", "account": "", "created": "2021-03-04T00:51:43Z", "fqdn": "_acme-challenge.host.example.com.", "id": "30000000-0000-0000-0000-000000000000", "name": "_acme-challenge.host", "records": [ { "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" } ], "status": "Active", "ttl": 30, "type": "TXT", "updated": "2021-03-04T00:51:43Z", "zoneId": "00000000-0000-0000-0000-000000000000" } ] } ================================================ FILE: providers/dns/vinyldns/fixtures/zoneByName.json ================================================ { "zone": { "accessLevel": "Delete", "account": "system", "acl": { "rules": [] }, "adminGroupId": "40000000-0000-0000-0000-000000000000", "adminGroupName": "OpsTeam", "created": "2020-07-15T21:15:36Z", "email": "Ops@company.invalid", "id": "00000000-0000-0000-0000-000000000000", "latestSync": "2020-07-15T21:15:36Z", "name": "example.com.", "shared": false, "status": "Active", "updated": "2021-03-03T18:02:47Z" } } ================================================ FILE: providers/dns/vinyldns/vinyldns.go ================================================ // Package vinyldns implements a DNS provider for solving the DNS-01 challenge using VinylDNS. package vinyldns import ( "context" "errors" "fmt" "net/http" "strconv" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" "github.com/vinyldns/go-vinyldns/vinyldns" ) // Environment variables names. const ( envNamespace = "VINYLDNS_" EnvAccessKey = envNamespace + "ACCESS_KEY" EnvSecretKey = envNamespace + "SECRET_KEY" EnvHost = envNamespace + "HOST" EnvQuoteValue = envNamespace + "QUOTE_VALUE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AccessKey string SecretKey string Host string QuoteValue bool 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, 30), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *vinyldns.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for VinylDNS. // Credentials must be passed in the environment variables: // VINYLDNS_ACCESS_KEY, VINYLDNS_SECRET_KEY, VINYLDNS_HOST. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccessKey, EnvSecretKey, EnvHost) if err != nil { return nil, fmt.Errorf("vinyldns: %w", err) } config := NewDefaultConfig() config.AccessKey = values[EnvAccessKey] config.SecretKey = values[EnvSecretKey] config.Host = values[EnvHost] config.QuoteValue = env.GetOrDefaultBool(EnvQuoteValue, false) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for VinylDNS. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("vinyldns: the configuration of the VinylDNS DNS provider is nil") } if config.AccessKey == "" || config.SecretKey == "" { return nil, errors.New("vinyldns: credentials are missing") } if config.Host == "" { return nil, errors.New("vinyldns: host is missing") } client := vinyldns.NewClient(vinyldns.ClientConfiguration{ AccessKey: config.AccessKey, SecretKey: config.SecretKey, Host: config.Host, UserAgent: useragent.Get(), }) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } else { // For compatibility, it should be removed in v5. client.HTTPClient.Timeout = 30 * time.Second } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) existingRecord, err := d.getRecordSet(info.EffectiveFQDN) if err != nil { return fmt.Errorf("vinyldns: %w", err) } value := d.formatValue(info.Value) record := vinyldns.Record{Text: value} if existingRecord == nil || existingRecord.ID == "" { err = d.createRecordSet(ctx, info.EffectiveFQDN, []vinyldns.Record{record}) if err != nil { return fmt.Errorf("vinyldns: %w", err) } return nil } for _, i := range existingRecord.Records { if i.Text == value { return nil } } records := existingRecord.Records records = append(records, record) err = d.updateRecordSet(ctx, existingRecord, records) if err != nil { return fmt.Errorf("vinyldns: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) existingRecord, err := d.getRecordSet(info.EffectiveFQDN) if err != nil { return fmt.Errorf("vinyldns: %w", err) } if existingRecord == nil || existingRecord.ID == "" || len(existingRecord.Records) == 0 { return nil } value := d.formatValue(info.Value) var records []vinyldns.Record for _, i := range existingRecord.Records { if i.Text != value { records = append(records, i) } } if len(records) == 0 { err = d.deleteRecordSet(ctx, existingRecord) if err != nil { return fmt.Errorf("vinyldns: %w", err) } return nil } err = d.updateRecordSet(ctx, existingRecord, records) if err != nil { return fmt.Errorf("vinyldns: %w", err) } return 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 } func (d *DNSProvider) formatValue(v string) string { if d.config.QuoteValue { return strconv.Quote(v) } return v } ================================================ FILE: providers/dns/vinyldns/vinyldns.toml ================================================ Name = "VinylDNS" Description = '''''' URL = "https://www.vinyldns.io" Code = "vinyldns" Since = "v4.4.0" Example = ''' VINYLDNS_ACCESS_KEY=xxxxxx \ VINYLDNS_SECRET_KEY=yyyyy \ VINYLDNS_HOST=https://api.vinyldns.example.org:9443 \ lego --dns vinyldns -d '*.example.com' -d example.com run ''' Additional = ''' The vinyldns integration makes use of dotted hostnames to ease permission management. Users are required to have DELETE ACL level or zone admin permissions on the VinylDNS zone containing the target host. ''' [Configuration] [Configuration.Credentials] VINYLDNS_ACCESS_KEY = "The VinylDNS API key" VINYLDNS_SECRET_KEY = "The VinylDNS API Secret key" VINYLDNS_HOST = "The VinylDNS API URL" [Configuration.Additional] VINYLDNS_QUOTE_VALUE = "Adds quotes around the TXT record value (Default: false)" VINYLDNS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" VINYLDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" VINYLDNS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" VINYLDNS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.vinyldns.io/api/" GoClient = "https://github.com/vinyldns/go-vinyldns" ================================================ FILE: providers/dns/vinyldns/vinyldns_test.go ================================================ package vinyldns import ( "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" const ( targetRootDomain = "example.com" targetDomain = "host." + targetRootDomain zoneID = "00000000-0000-0000-0000-000000000000" newRecordSetID = "11000000-0000-0000-0000-000000000000" newCreateChangeID = "20000000-0000-0000-0000-000000000000" recordID = "30000000-0000-0000-0000-000000000000" ) var envTest = tester.NewEnvTest( EnvAccessKey, EnvSecretKey, EnvHost). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessKey: "123", EnvSecretKey: "456", EnvHost: "https://example.org", }, }, { desc: "missing all credentials", envVars: map[string]string{ EnvHost: "https://example.org", }, expected: "vinyldns: some credentials information are missing: VINYLDNS_ACCESS_KEY,VINYLDNS_SECRET_KEY", }, { desc: "missing access key", envVars: map[string]string{ EnvSecretKey: "456", EnvHost: "https://example.org", }, expected: "vinyldns: some credentials information are missing: VINYLDNS_ACCESS_KEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAccessKey: "123", EnvHost: "https://example.org", }, expected: "vinyldns: some credentials information are missing: VINYLDNS_SECRET_KEY", }, { desc: "missing host", envVars: map[string]string{ EnvAccessKey: "123", EnvSecretKey: "456", }, expected: "vinyldns: some credentials information are missing: VINYLDNS_HOST", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string accessKey string secretKey string host string expected string }{ { desc: "success", accessKey: "123", secretKey: "456", host: "https://example.org", }, { desc: "missing all credentials", host: "https://example.org", expected: "vinyldns: credentials are missing", }, { desc: "missing access key", secretKey: "456", host: "https://example.org", expected: "vinyldns: credentials are missing", }, { desc: "missing secret key", accessKey: "123", host: "https://example.org", expected: "vinyldns: credentials are missing", }, { desc: "missing host", accessKey: "123", secretKey: "456", expected: "vinyldns: host is missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessKey = test.accessKey config.SecretKey = test.secretKey config.Host = test.host p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.AccessKey = "foo" config.SecretKey = "bar" config.Host = server.URL config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }) } func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string keyAuth string builder *servermock.Builder[*DNSProvider] }{ { desc: "new record", keyAuth: "123456d==", builder: mockBuilder(). Route("GET /zones/name/"+targetRootDomain+".", servermock.ResponseFromFixture("zoneByName.json")). Route("GET /zones/"+zoneID+"/recordsets", servermock.ResponseFromFixture("recordSetsListAll-empty.json")). Route("POST /zones/"+zoneID+"/recordsets", servermock.ResponseFromFixture("recordSetUpdate-create.json"). WithStatusCode(http.StatusAccepted)). Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, servermock.ResponseFromFixture("recordSetChange-create.json")), }, { desc: "existing record", keyAuth: "123456d==", builder: mockBuilder(). Route("GET /zones/name/"+targetRootDomain+".", servermock.ResponseFromFixture("zoneByName.json")). Route("GET /zones/"+zoneID+"/recordsets", servermock.ResponseFromFixture("recordSetsListAll.json")), }, { desc: "duplicate key", keyAuth: "abc123!!", builder: mockBuilder(). Route("GET /zones/name/"+targetRootDomain+".", servermock.ResponseFromFixture("zoneByName.json")). Route("GET /zones/"+zoneID+"/recordsets", servermock.ResponseFromFixture("recordSetsListAll.json")). Route("PUT /zones/"+zoneID+"/recordsets/"+recordID, servermock.ResponseFromFixture("recordSetUpdate-create.json"). WithStatusCode(http.StatusAccepted)). Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, servermock.ResponseFromFixture("recordSetChange-create.json")), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { provider := test.builder.Build(t) err := provider.Present(targetDomain, "token"+test.keyAuth, test.keyAuth) require.NoError(t, err) }) } } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("GET /zones/name/"+targetRootDomain+".", servermock.ResponseFromFixture("zoneByName.json")). Route("GET /zones/"+zoneID+"/recordsets", servermock.ResponseFromFixture("recordSetsListAll.json")). Route("DELETE /zones/"+zoneID+"/recordsets/"+recordID, servermock.ResponseFromFixture("recordSetDelete.json"). WithStatusCode(http.StatusAccepted)). Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, servermock.ResponseFromFixture("recordSetChange-delete.json")). Build(t) err := provider.CleanUp(targetDomain, "123456d==", "123456d==") require.NoError(t, err) } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/vinyldns/wrapper.go ================================================ package vinyldns import ( "context" "fmt" "github.com/cenkalti/backoff/v5" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/wait" "github.com/vinyldns/go-vinyldns/vinyldns" ) func (d *DNSProvider) getRecordSet(fqdn string) (*vinyldns.RecordSet, error) { zoneName, hostName, err := splitDomain(fqdn) if err != nil { return nil, err } zone, err := d.client.ZoneByName(zoneName) if err != nil { return nil, err } allRecordSets, err := d.client.RecordSetsListAll(zone.ID, vinyldns.ListFilter{NameFilter: hostName}) if err != nil { return nil, err } var recordSets []vinyldns.RecordSet for _, i := range allRecordSets { if i.Type == "TXT" { recordSets = append(recordSets, i) } } switch { case len(recordSets) > 1: return nil, fmt.Errorf("ambiguous recordset definition of %s", fqdn) case len(recordSets) == 1: return &recordSets[0], nil default: return nil, nil } } func (d *DNSProvider) createRecordSet(ctx context.Context, fqdn string, records []vinyldns.Record) error { zoneName, hostName, err := splitDomain(fqdn) if err != nil { return err } zone, err := d.client.ZoneByName(zoneName) if err != nil { return err } recordSet := vinyldns.RecordSet{ Name: hostName, ZoneID: zone.ID, Type: "TXT", TTL: d.config.TTL, Records: records, } resp, err := d.client.RecordSetCreate(&recordSet) if err != nil { return err } return d.waitForChanges(ctx, "CreateRS", resp) } func (d *DNSProvider) updateRecordSet(ctx context.Context, recordSet *vinyldns.RecordSet, newRecords []vinyldns.Record) error { operation := "delete" if len(recordSet.Records) < len(newRecords) { operation = "add" } recordSet.Records = newRecords recordSet.TTL = d.config.TTL resp, err := d.client.RecordSetUpdate(recordSet) if err != nil { return err } return d.waitForChanges(ctx, "UpdateRS - "+operation, resp) } func (d *DNSProvider) deleteRecordSet(ctx context.Context, existingRecord *vinyldns.RecordSet) error { resp, err := d.client.RecordSetDelete(existingRecord.ZoneID, existingRecord.ID) if err != nil { return err } return d.waitForChanges(ctx, "DeleteRS", resp) } func (d *DNSProvider) waitForChanges(ctx context.Context, operation string, resp *vinyldns.RecordSetUpdateResponse) error { return wait.Retry(ctx, func() error { change, err := d.client.RecordSetChange(resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID) if err != nil { return fmt.Errorf("failed to query change status: %w", err) } if change.Status != "Complete" { return fmt.Errorf("waiting operation: %s, zoneID: %s, recordsetID: %s, changeID: %s", operation, resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID) } return nil }, backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)), backoff.WithMaxElapsedTime(d.config.PropagationTimeout), ) } // splitDomain splits the hostname from the authoritative zone, and returns both parts. func splitDomain(fqdn string) (string, string, error) { zone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", "", fmt.Errorf("could not find zone: %w", err) } subDomain, err := dns01.ExtractSubDomain(fqdn, zone) if err != nil { return "", "", err } return zone, subDomain, nil } ================================================ FILE: providers/dns/virtualname/virtualname.go ================================================ // Package virtualname implements a DNS provider for solving the DNS-01 challenge using Virtualname DNS. package virtualname import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/tecnocratica" ) // Environment variables names. const ( envNamespace = "VIRTUALNAME_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://api.virtualname.net/v1" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = tecnocratica.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Virtualname. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("virtualname: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Virtualname. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("virtualname: the configuration of the DNS provider is nil") } provider, err := tecnocratica.NewDNSProviderConfig(config, defaultBaseURL) if err != nil { return nil, fmt.Errorf("virtualname: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("virtualname: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("virtualname: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/virtualname/virtualname.toml ================================================ Name = "Virtualname" Description = '''''' URL = "https://www.virtualname.es/" Code = "virtualname" Since = "v4.30.0" Example = ''' VIRTUALNAME_TOKEN=xxxxxx \ lego --dns virtualname -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] VIRTUALNAME_TOKEN = "API token" [Configuration.Additional] VIRTUALNAME_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" VIRTUALNAME_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" VIRTUALNAME_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" VIRTUALNAME_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.virtualname.net/#dns" ================================================ FILE: providers/dns/virtualname/virtualname_test.go ================================================ package virtualname import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "secret", }, }, { desc: "missing credentials: token", envVars: map[string]string{ EnvToken: "", }, expected: "virtualname: some credentials information are missing: VIRTUALNAME_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string expected string }{ { desc: "success", token: "secret", }, { desc: "missing token", expected: "virtualname: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/vkcloud/internal/client.go ================================================ package internal import ( "errors" "fmt" "net/http" "net/url" "github.com/gophercloud/gophercloud" "github.com/gophercloud/gophercloud/openstack" ) // Client VK client. type Client struct { openstack *gophercloud.ProviderClient authOpts gophercloud.AuthOptions authenticated bool baseURL *url.URL } // NewClient creates a Client. func NewClient(endpoint string, authOpts gophercloud.AuthOptions) (*Client, error) { err := validateAuthOptions(authOpts) if err != nil { return nil, err } openstackClient, err := openstack.NewClient(authOpts.IdentityEndpoint) if err != nil { return nil, fmt.Errorf("new client: %w", err) } baseURL, err := url.Parse(endpoint) if err != nil { return nil, fmt.Errorf("parse URL: %w", err) } return &Client{ openstack: openstackClient, authOpts: authOpts, baseURL: baseURL, }, nil } func (c *Client) ListZones() ([]DNSZone, error) { endpoint := c.baseURL.JoinPath("/") var zones []DNSZone opts := &gophercloud.RequestOpts{JSONResponse: &zones} err := c.request(http.MethodGet, endpoint, opts) if err != nil { return nil, err } return zones, nil } func (c *Client) ListTXTRecords(zoneUUID string) ([]DNSTXTRecord, error) { endpoint := c.baseURL.JoinPath(zoneUUID, "txt", "/") var records []DNSTXTRecord opts := &gophercloud.RequestOpts{JSONResponse: &records} err := c.request(http.MethodGet, endpoint, opts) if err != nil { return nil, err } return records, nil } func (c *Client) CreateTXTRecord(zoneUUID string, record *DNSTXTRecord) error { endpoint := c.baseURL.JoinPath(zoneUUID, "txt", "/") opts := &gophercloud.RequestOpts{ JSONBody: record, JSONResponse: record, } return c.request(http.MethodPost, endpoint, opts) } func (c *Client) DeleteTXTRecord(zoneUUID, recordUUID string) error { endpoint := c.baseURL.JoinPath(zoneUUID, "txt", recordUUID) return c.request(http.MethodDelete, endpoint, &gophercloud.RequestOpts{}) } func (c *Client) request(method string, endpoint *url.URL, options *gophercloud.RequestOpts) error { if err := c.lazyAuth(); err != nil { return fmt.Errorf("auth: %w", err) } _, err := c.openstack.Request(method, endpoint.String(), options) if err != nil { return fmt.Errorf("request: %w", err) } return nil } func (c *Client) lazyAuth() error { if c.authenticated { return nil } err := openstack.Authenticate(c.openstack, c.authOpts) if err != nil { return err } c.authenticated = true return nil } func validateAuthOptions(opts gophercloud.AuthOptions) error { if opts.TenantID == "" { return errors.New("project id is missing in credentials information") } if opts.Username == "" { return errors.New("username is missing in credentials information") } if opts.Password == "" { return errors.New("password is missing in credentials information") } if opts.IdentityEndpoint == "" { return errors.New("identity endpoint is missing in config") } if opts.DomainName == "" { return errors.New("domain name is missing in config") } return nil } ================================================ FILE: providers/dns/vkcloud/internal/types.go ================================================ package internal type DNSZone struct { UUID string `json:"uuid,omitempty"` Tenant string `json:"tenant,omitempty"` SoaPrimaryDNS string `json:"soa_primary_dns,omitempty"` SoaAdminEmail string `json:"soa_admin_email,omitempty"` SoaSerial int `json:"soa_serial,omitempty"` SoaRefresh int `json:"soa_refresh,omitempty"` SoaRetry int `json:"soa_retry,omitempty"` SoaExpire int `json:"soa_expire,omitempty"` SoaTTL int `json:"soa_ttl,omitempty"` Zone string `json:"zone,omitempty"` Status string `json:"status,omitempty"` } type DNSTXTRecord struct { UUID string `json:"uuid,omitempty"` Name string `json:"name,omitempty"` DNS string `json:"dns,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` } ================================================ FILE: providers/dns/vkcloud/vkcloud.go ================================================ // Package vkcloud implements a DNS provider for solving the DNS-01 challenge using VK Cloud. package vkcloud import ( "errors" "fmt" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/vkcloud/internal" "github.com/gophercloud/gophercloud" ) // Environment variables names. const ( envNamespace = "VK_CLOUD_" EnvDNSEndpoint = envNamespace + "DNS_ENDPOINT" EnvIdentityEndpoint = envNamespace + "IDENTITY_ENDPOINT" EnvDomainName = envNamespace + "DOMAIN_NAME" EnvProjectID = envNamespace + "PROJECT_ID" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) const ( defaultIdentityEndpoint = "https://infra.mail.ru/identity/v3/" defaultDNSEndpoint = "https://mcs.mail.ru/public-dns/v2/dns" ) const defaultDomainName = "users" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ProjectID string Username string Password string DNSEndpoint string IdentityEndpoint string DomainName string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *internal.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for VK Cloud. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvProjectID, EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("vkcloud: %w", err) } config := NewDefaultConfig() config.ProjectID = values[EnvProjectID] config.Username = values[EnvUsername] config.Password = values[EnvPassword] config.IdentityEndpoint = env.GetOrDefaultString(EnvIdentityEndpoint, defaultIdentityEndpoint) config.DomainName = env.GetOrDefaultString(EnvDomainName, defaultDomainName) config.DNSEndpoint = env.GetOrDefaultString(EnvDNSEndpoint, defaultDNSEndpoint) return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for VK Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("vkcloud: the configuration of the DNS provider is nil") } if config.DNSEndpoint == "" { return nil, errors.New("vkcloud: DNS endpoint is missing in config") } authOpts := gophercloud.AuthOptions{ IdentityEndpoint: config.IdentityEndpoint, Username: config.Username, Password: config.Password, DomainName: config.DomainName, TenantID: config.ProjectID, } client, err := internal.NewClient(config.DNSEndpoint, authOpts) if err != nil { return nil, fmt.Errorf("vkcloud: unable to build VK Cloud client: %w", err) } return &DNSProvider{ client: client, config: config, }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("vkcloud: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) zones, err := d.client.ListZones() if err != nil { return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err) } var zoneUUID string for _, zone := range zones { if zone.Zone == authZone { zoneUUID = zone.UUID } } if zoneUUID == "" { return fmt.Errorf("vkcloud: cant find dns zone %s in VK Cloud", authZone) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("vkcloud: %w", err) } err = d.upsertTXTRecord(zoneUUID, subDomain, info.Value) if err != nil { return fmt.Errorf("vkcloud: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("vkcloud: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) zones, err := d.client.ListZones() if err != nil { return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err) } var zoneUUID string for _, zone := range zones { if zone.Zone == authZone { zoneUUID = zone.UUID } } if zoneUUID == "" { return nil } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("vkcloud: %w", err) } err = d.removeTXTRecord(zoneUUID, subDomain, info.Value) if err != nil { return fmt.Errorf("vkcloud: %w", err) } return 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 } func (d *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error { records, err := d.client.ListTXTRecords(zoneUUID) if err != nil { return err } for _, record := range records { if record.Name == name && record.Content == value { // The DNSRecord is already present, nothing to do return nil } } return d.client.CreateTXTRecord(zoneUUID, &internal.DNSTXTRecord{ Name: name, Content: value, TTL: d.config.TTL, }) } func (d *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error { records, err := d.client.ListTXTRecords(zoneUUID) if err != nil { return err } name = dns01.UnFqdn(name) for _, record := range records { if record.Name == name && record.Content == value { return d.client.DeleteTXTRecord(zoneUUID, record.UUID) } } // The DNSRecord is not present, nothing to do return nil } ================================================ FILE: providers/dns/vkcloud/vkcloud.toml ================================================ Name = "VK Cloud" Description = '''''' URL = "https://mcs.mail.ru/" Code = "vkcloud" Since = "v4.9.0" Example = ''' VK_CLOUD_PROJECT_ID="" \ VK_CLOUD_USERNAME="" \ VK_CLOUD_PASSWORD="" \ lego --dns vkcloud -d '*.example.com' -d example.com run ''' Additional = ''' ## Credential information You can find all required and additional information on ["Project/Keys" page](https://mcs.mail.ru/app/en/project/keys) of your cloud. | ENV Variable | Parameter from page | |----------------------------|---------------------| | VK_CLOUD_PROJECT_ID | Project ID | | VK_CLOUD_USERNAME | Username | | VK_CLOUD_DOMAIN_NAME | User Domain Name | | VK_CLOUD_IDENTITY_ENDPOINT | Identity endpoint | ''' [Configuration] [Configuration.Credentials] VK_CLOUD_PROJECT_ID = "String ID of project in VK Cloud" VK_CLOUD_USERNAME = "Email of VK Cloud account" VK_CLOUD_PASSWORD = "Password for VK Cloud account" [Configuration.Additional] VK_CLOUD_DNS_ENDPOINT="URL of DNS API. Defaults to https://mcs.mail.ru/public-dns but can be changed for usage with private clouds" VK_CLOUD_IDENTITY_ENDPOINT="URL of OpenStack Auth API, Defaults to https://infra.mail.ru:35357/v3/ but can be changed for usage with private clouds" VK_CLOUD_DOMAIN_NAME="Openstack users domain name. Defaults to `users` but can be changed for usage with private clouds" VK_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" VK_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" VK_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" [Links] API = "https://mcs.mail.ru/docs/networks/vnet/networks/publicdns/api" ================================================ FILE: providers/dns/vkcloud/vkcloud_test.go ================================================ package vkcloud import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" const ( fakeProjectID = "an_project_id_from_vk_cloud_ui" fakeUsername = "vkclouduser@email.address" fakePasswd = "vkcloudpasswd" ) var envTest = tester.NewEnvTest(EnvProjectID, EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvProjectID: fakeProjectID, EnvUsername: fakeUsername, EnvPassword: fakePasswd, }, }, { desc: "missing project id", envVars: map[string]string{ EnvUsername: fakeUsername, EnvPassword: fakePasswd, }, expected: "vkcloud: some credentials information are missing: VK_CLOUD_PROJECT_ID", }, { desc: "missing username", envVars: map[string]string{ EnvProjectID: fakeProjectID, EnvPassword: fakePasswd, }, expected: "vkcloud: some credentials information are missing: VK_CLOUD_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvProjectID: fakeProjectID, EnvUsername: fakeUsername, }, expected: "vkcloud: some credentials information are missing: VK_CLOUD_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string config *Config expected string }{ { desc: "success", config: &Config{ ProjectID: fakeProjectID, Username: fakeUsername, Password: fakePasswd, DNSEndpoint: defaultDNSEndpoint, IdentityEndpoint: defaultIdentityEndpoint, DomainName: defaultDomainName, }, }, { desc: "nil config", config: nil, expected: "vkcloud: the configuration of the DNS provider is nil", }, { desc: "missing project id", config: &Config{ Username: fakeUsername, Password: fakePasswd, DNSEndpoint: defaultDNSEndpoint, IdentityEndpoint: defaultIdentityEndpoint, DomainName: defaultDomainName, }, expected: "vkcloud: unable to build VK Cloud client: project id is missing in credentials information", }, { desc: "missing username", config: &Config{ ProjectID: fakeProjectID, Password: fakePasswd, DNSEndpoint: defaultDNSEndpoint, IdentityEndpoint: defaultIdentityEndpoint, DomainName: defaultDomainName, }, expected: "vkcloud: unable to build VK Cloud client: username is missing in credentials information", }, { desc: "missing password", config: &Config{ ProjectID: fakeProjectID, Username: fakeUsername, DNSEndpoint: defaultDNSEndpoint, IdentityEndpoint: defaultIdentityEndpoint, DomainName: defaultDomainName, }, expected: "vkcloud: unable to build VK Cloud client: password is missing in credentials information", }, { desc: "missing dns endpoint", config: &Config{ ProjectID: fakeProjectID, Username: fakeUsername, Password: fakePasswd, IdentityEndpoint: defaultIdentityEndpoint, DomainName: defaultDomainName, }, expected: "vkcloud: DNS endpoint is missing in config", }, { desc: "missing identity endpoint", config: &Config{ ProjectID: fakeProjectID, Username: fakeUsername, Password: fakePasswd, DNSEndpoint: defaultDNSEndpoint, DomainName: defaultDomainName, }, expected: "vkcloud: unable to build VK Cloud client: identity endpoint is missing in config", }, { desc: "missing domain name", config: &Config{ ProjectID: fakeProjectID, Username: fakeUsername, Password: fakePasswd, DNSEndpoint: defaultDNSEndpoint, IdentityEndpoint: defaultIdentityEndpoint, }, expected: "vkcloud: unable to build VK Cloud client: domain name is missing in config", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p, err := NewDNSProviderConfig(test.config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/volcengine/volcengine.go ================================================ // Package volcengine implements a DNS provider for solving the DNS-01 challenge using Volcano Engine. package volcengine import ( "context" "errors" "fmt" "net/http" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/ptr" "github.com/volcengine/volc-sdk-golang/base" volc "github.com/volcengine/volc-sdk-golang/service/dns" ) // Environment variables names. const ( envNamespace = "VOLC_" EnvAccessKey = envNamespace + "ACCESSKEY" EnvSecretKey = envNamespace + "SECRETKEY" EnvRegion = envNamespace + "REGION" EnvHost = envNamespace + "HOST" EnvScheme = envNamespace + "SCHEME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // https://www.volcengine.com/docs/6758/170354 const defaultTTL = 600 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { AccessKey string SecretKey string Region string Host string Scheme string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPTimeout time.Duration } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ Scheme: env.GetOrDefaultString(EnvScheme, "https"), Host: env.GetOrDefaultString(EnvHost, "open.volcengineapi.com"), Region: env.GetOrDefaultString(EnvRegion, volc.DefaultRegion), TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 4*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, volc.Timeout*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *volc.Client config *Config recordIDs map[string]*string recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Volcano Engine. // Credentials must be passed in the environment variable: VOLC_ACCESSKEY, VOLC_SECRETKEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAccessKey, EnvSecretKey) if err != nil { return nil, fmt.Errorf("volcengine: %w", err) } config := NewDefaultConfig() config.AccessKey = values[EnvAccessKey] config.SecretKey = values[EnvSecretKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Volcano Engine. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("volcengine: the configuration of the DNS provider is nil") } if config.AccessKey == "" || config.SecretKey == "" { return nil, errors.New("volcengine: missing credentials") } return &DNSProvider{ config: config, client: newClient(config), recordIDs: make(map[string]*string), }, 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 { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getZone(ctx, info.EffectiveFQDN) if err != nil { return fmt.Errorf("volcengine: get zone ID: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, ptr.Deref(zone.ZoneName)) if err != nil { return fmt.Errorf("volcengine: %w", err) } crr := &volc.CreateRecordRequest{ Host: ptr.Pointer(subDomain), TTL: ptr.Pointer(int64(d.config.TTL)), Type: ptr.Pointer("TXT"), Value: ptr.Pointer(info.Value), ZID: zone.ZID, } record, err := d.client.CreateRecord(ctx, crr) if err != nil { return fmt.Errorf("volcengine: create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = record.RecordID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) // gets the record's unique ID d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("volcengine: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } drr := &volc.DeleteRecordRequest{RecordID: recordID} err := d.client.DeleteRecord(context.Background(), drr) if err != nil { return fmt.Errorf("volcengine: delete record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) getZone(ctx context.Context, fqdn string) (volc.TopZoneResponse, error) { for domain := range dns01.UnFqdnDomainsSeq(fqdn) { lzr := &volc.ListZonesRequest{ Key: ptr.Pointer(dns01.UnFqdn(domain)), SearchMode: ptr.Pointer("exact"), } zones, err := d.client.ListZones(ctx, lzr) if err != nil { return volc.TopZoneResponse{}, fmt.Errorf("list zones: %w", err) } total := ptr.Deref(zones.Total) if total == 0 || len(zones.Zones) == 0 { continue } if total > 1 { return volc.TopZoneResponse{}, fmt.Errorf("too many zone for %s", domain) } return zones.Zones[0], nil } return volc.TopZoneResponse{}, fmt.Errorf("zone no found for fqdn: %s", fqdn) } // https://github.com/volcengine/volc-sdk-golang/tree/main/service/dns // https://github.com/volcengine/volc-sdk-golang/blob/main/example/dns/demo_dns_test.go func newClient(config *Config) *volc.Client { // https://github.com/volcengine/volc-sdk-golang/blob/fae992a31d02754e271c322095413d374ea4ea1b/service/dns/config.go#L20-L35 serviceInfo := &base.ServiceInfo{ Timeout: config.HTTPTimeout, Host: config.Host, Header: http.Header{"Accept": []string{"application/json"}}, Scheme: config.Scheme, Credentials: base.Credentials{ Service: volc.ServiceName, Region: config.Region, AccessKeyID: config.AccessKey, SecretAccessKey: config.SecretKey, }, } // https://github.com/volcengine/volc-sdk-golang/blob/fae992a31d02754e271c322095413d374ea4ea1b/service/dns/caller.go#L17-L19 client := base.NewClient(serviceInfo, nil) // https://github.com/volcengine/volc-sdk-golang/blob/fae992a31d02754e271c322095413d374ea4ea1b/service/dns/caller.go#L25-L34 caller := &volc.VolcCaller{Volc: client} caller.Volc.SetAccessKey(serviceInfo.Credentials.AccessKeyID) caller.Volc.SetSecretKey(serviceInfo.Credentials.SecretAccessKey) caller.Volc.SetHost(serviceInfo.Host) caller.Volc.SetScheme(serviceInfo.Scheme) caller.Volc.SetTimeout(serviceInfo.Timeout) return volc.NewClient(caller) } ================================================ FILE: providers/dns/volcengine/volcengine.toml ================================================ Name = "Volcano Engine/火山引擎" Description = '''''' URL = "https://www.volcengine.com/" Code = "volcengine" Since = "v4.19.0" Example = ''' VOLC_ACCESSKEY=xxx \ VOLC_SECRETKEY=yyy \ lego --dns volcengine -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] VOLC_ACCESSKEY = "Access Key ID (AK)" VOLC_SECRETKEY = "Secret Access Key (SK)" [Configuration.Additional] VOLC_REGION = "Region" VOLC_HOST = "API host" VOLC_SCHEME = "API scheme" VOLC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" VOLC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 240)" VOLC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" VOLC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 15)" [Links] API = "https://www.volcengine.com/docs/6758/155086" GoClient = "https://github.com/volcengine/volc-sdk-golang" ================================================ FILE: providers/dns/volcengine/volcengine_test.go ================================================ package volcengine import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( EnvAccessKey, EnvSecretKey, EnvRegion, EnvHost, EnvScheme). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAccessKey: "access", EnvSecretKey: "secret", }, }, { desc: "missing access key", envVars: map[string]string{ EnvSecretKey: "secret", }, expected: "volcengine: some credentials information are missing: VOLC_ACCESSKEY", }, { desc: "missing secret key", envVars: map[string]string{ EnvAccessKey: "access", }, expected: "volcengine: some credentials information are missing: VOLC_SECRETKEY", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "volcengine: some credentials information are missing: VOLC_ACCESSKEY,VOLC_SECRETKEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string accessKey string secretKey string }{ { desc: "success", accessKey: "access", secretKey: "secret", }, { desc: "missing access key", secretKey: "secret", expected: "volcengine: missing credentials", }, { desc: "missing secret key", accessKey: "access", expected: "volcengine: missing credentials", }, { desc: "missing credentials", expected: "volcengine: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AccessKey = test.accessKey config.SecretKey = test.secretKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/vscale/vscale.go ================================================ // Package vscale implements a DNS provider for solving the DNS-01 challenge using Vscale Domains API. // Vscale Domain API reference: https://developers.vscale.io/documentation/api/v1/#api-Domains // Token: https://vscale.io/panel/settings/tokens/ package vscale import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/selectel" ) // Environment variables names. const ( envNamespace = "VSCALE_" EnvBaseURL = envNamespace + "BASE_URL" EnvAPIToken = envNamespace + "API_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://api.vscale.io/v1/domains" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = selectel.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL), TTL: env.GetOrDefaultInt(EnvTTL, selectel.MinTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), 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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Vscale Domains API. // API token must be passed in the environment variable VSCALE_API_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIToken) if err != nil { return nil, fmt.Errorf("vscale: %w", err) } config := NewDefaultConfig() config.Token = values[EnvAPIToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Vscale. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("vscale: the configuration of the DNS provider is nil") } if config.BaseURL == "" { config.BaseURL = defaultBaseURL } provider, err := selectel.NewDNSProviderConfig(config) if err != nil { return nil, fmt.Errorf("vscale: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("vscale: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("vscale: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/vscale/vscale.toml ================================================ Name = "Vscale" Description = '''''' URL = "https://vscale.io/" Code = "vscale" Since = "v2.0.0" Example = ''' VSCALE_API_TOKEN=xxxxx \ lego --dns vscale -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] VSCALE_API_TOKEN = "API token" [Configuration.Additional] VSCALE_BASE_URL = "API endpoint URL" VSCALE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" VSCALE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" VSCALE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" VSCALE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.vscale.io/documentation/api/v1/#api-Domains_Records" ================================================ FILE: providers/dns/vscale/vscale_test.go ================================================ package vscale import ( "fmt" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/internal/selectel" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAPIToken, EnvTTL) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIToken: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIToken: "", }, expected: fmt.Sprintf("vscale: some credentials information are missing: %s", EnvAPIToken), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string token string ttl int expected string }{ { desc: "success", token: "123", ttl: 60, }, { desc: "missing api key", token: "", ttl: 60, expected: "vscale: credentials missing", }, { desc: "bad TTL value", token: "123", ttl: 59, expected: fmt.Sprintf("vscale: invalid TTL, TTL (59) must be greater than %d", selectel.MinTTL), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.TTL = test.ttl config.Token = test.token p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) assert.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/vultr/vultr.go ================================================ // Package vultr implements a DNS provider for solving the DNS-01 challenge using the Vultr DNS. // See https://www.vultr.com/api/#dns package vultr import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/vultr/govultr/v3" "golang.org/x/oauth2" ) // Environment variables names. const ( envNamespace = "VULTR_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client HTTPTimeout time.Duration // TODO(ldez): remove in v5 } // 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), HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *govultr.Client } // NewDNSProvider returns a DNSProvider instance with a configured Vultr client. // Authentication uses the VULTR_API_KEY environment variable. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("vultr: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Vultr. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("vultr: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("vultr: credentials missing") } authClient := OAuthStaticAccessToken(config.HTTPClient, config.APIKey) authClient.Timeout = config.HTTPTimeout client := govultr.NewClient(clientdebug.Wrap(authClient)) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the DNS-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) // TODO(ldez) replace domain by FQDN to follow CNAME. zoneDomain, err := d.getHostedZone(ctx, domain) if err != nil { return fmt.Errorf("vultr: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zoneDomain) if err != nil { return fmt.Errorf("vultr: %w", err) } req := govultr.DomainRecordCreateReq{ Name: subDomain, Type: "TXT", Data: `"` + info.Value + `"`, TTL: d.config.TTL, Priority: func(v int) *int { return &v }(0), } _, resp, err := d.client.DomainRecord.Create(ctx, zoneDomain, &req) if err != nil { return fmt.Errorf("vultr: %w", extendError(resp, err)) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) // TODO(ldez) replace domain by FQDN to follow CNAME. zoneDomain, records, err := d.findTxtRecords(ctx, domain, info.EffectiveFQDN) if err != nil { return fmt.Errorf("vultr: %w", err) } var allErr []string for _, rec := range records { err := d.client.DomainRecord.Delete(ctx, zoneDomain, rec.ID) if err != nil { allErr = append(allErr, err.Error()) } } if len(allErr) > 0 { return errors.New(strings.Join(allErr, ": ")) } return 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 } func (d *DNSProvider) getHostedZone(ctx context.Context, domain string) (string, error) { listOptions := &govultr.ListOptions{PerPage: 25} var hostedDomain govultr.Domain for { domains, meta, resp, err := d.client.Domain.List(ctx, listOptions) if err != nil { return "", extendError(resp, err) } for _, dom := range domains { if strings.HasSuffix(domain, dom.Domain) && len(dom.Domain) > len(hostedDomain.Domain) { hostedDomain = dom } } if domain == hostedDomain.Domain { break } if meta.Links.Next == "" { break } listOptions.Cursor = meta.Links.Next } if hostedDomain.Domain == "" { return "", fmt.Errorf("no matching domain found for domain %s", domain) } return hostedDomain.Domain, nil } func (d *DNSProvider) findTxtRecords(ctx context.Context, domain, fqdn string) (string, []govultr.DomainRecord, error) { zoneDomain, err := d.getHostedZone(ctx, domain) if err != nil { return "", nil, err } subDomain, err := dns01.ExtractSubDomain(fqdn, zoneDomain) if err != nil { return "", nil, err } listOptions := &govultr.ListOptions{PerPage: 25} var records []govultr.DomainRecord for { result, meta, resp, err := d.client.DomainRecord.List(ctx, zoneDomain, listOptions) if err != nil { return "", records, extendError(resp, err) } for _, record := range result { if record.Type == "TXT" && record.Name == subDomain { records = append(records, record) } } if meta.Links.Next == "" { break } listOptions.Cursor = meta.Links.Next } return zoneDomain, records, nil } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } func extendError(resp *http.Response, err error) error { msg := "API call failed" if resp != nil { msg += fmt.Sprintf(" (%d)", resp.StatusCode) } return fmt.Errorf("%s: %w", msg, err) } ================================================ FILE: providers/dns/vultr/vultr.toml ================================================ Name = "Vultr" Description = '''''' URL = "https://www.vultr.com/" Code = "vultr" Since = "v0.3.1" Example = ''' VULTR_API_KEY=xxxxx \ lego --dns vultr -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] VULTR_API_KEY = "API key" [Configuration.Additional] VULTR_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" VULTR_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" VULTR_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" VULTR_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.vultr.com/api/#dns" GoClient = "https://github.com/vultr/govultr" ================================================ FILE: providers/dns/vultr/vultr_test.go ================================================ package vultr import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vultr/govultr/v3" ) const envDomain = envNamespace + "TEST_DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "vultr: some credentials information are missing: VULTR_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "123", }, { desc: "missing credentials", expected: "vultr: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_getHostedZone(t *testing.T) { testCases := []struct { desc string domain string expected string expectedPageCount int }{ { desc: "exact match, in latest page", domain: "test.my.example.com", expected: "test.my.example.com", expectedPageCount: 5, }, { desc: "exact match, in the middle", domain: "my.example.com", expected: "my.example.com", expectedPageCount: 3, }, { desc: "exact match, first page", domain: "example.com", expected: "example.com", expectedPageCount: 1, }, { desc: "match on apex", domain: "test.example.org", expected: "example.org", expectedPageCount: 5, }, { desc: "match on parent", domain: "test.my.example.net", expected: "my.example.net", expectedPageCount: 5, }, } domains := []govultr.Domain{{Domain: "example.com"}, {Domain: "example.org"}, {Domain: "example.net"}} for i := range 50 { domains = append(domains, govultr.Domain{Domain: fmt.Sprintf("my%02d.example.com", i)}) } domains = append(domains, govultr.Domain{Domain: "my.example.com"}, govultr.Domain{Domain: "my.example.net"}) for i := 50; i < 100; i++ { domains = append(domains, govultr.Domain{Domain: fmt.Sprintf("my%02d.example.com", i)}) } domains = append(domains, govultr.Domain{Domain: "test.my.example.com"}) type domainsBase struct { Domains []govultr.Domain `json:"domains"` Meta *govultr.Meta `json:"meta"` } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() var pageCount int provider := servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { client := govultr.NewClient(server.Client()) err := client.SetBaseURL(server.URL) require.NoError(t, err) return &DNSProvider{client: client}, nil }, ). Route("GET /v2/domains", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { pageCount++ query := req.URL.Query() cursor, _ := strconv.Atoi(query.Get("cursor")) perPage, _ := strconv.Atoi(query.Get("per_page")) var next string if len(domains)/perPage > cursor { next = strconv.Itoa(cursor + 1) } start := cursor * perPage if len(domains) < start { start = cursor * len(domains) } end := min(len(domains), (cursor+1)*perPage) db := domainsBase{ Domains: domains[start:end], Meta: &govultr.Meta{ Total: len(domains), Links: &govultr.Links{Next: next}, }, } err := json.NewEncoder(rw).Encode(db) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } })). Build(t) zone, err := provider.getHostedZone(t.Context(), test.domain) require.NoError(t, err) assert.Equal(t, test.expected, zone) assert.Equal(t, test.expectedPageCount, pageCount) }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/webnames/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://www.webnames.ru/scripts/json_domain_zone_manager.pl" // Client the Webnames API client. type Client struct { apiKey string baseURL string HTTPClient *http.Client } // NewClient Creates a new Client. func NewClient(apiKey string) *Client { return &Client{ apiKey: apiKey, baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // AddTXTRecord adds a TXT record. // Inspired by https://github.com/regtime-ltd/certbot-dns-webnames/blob/master/authenticator.sh func (c *Client) AddTXTRecord(ctx context.Context, domain, subDomain, value string) error { data := url.Values{} data.Set("domain", domain) data.Set("type", "TXT") data.Set("record", subDomain+":"+value) data.Set("action", "add") return c.doRequest(ctx, data) } // RemoveTXTRecord removes a TXT record. // Inspired by https://github.com/regtime-ltd/certbot-dns-webnames/blob/master/cleanup.sh func (c *Client) RemoveTXTRecord(ctx context.Context, domain, subDomain, value string) error { data := url.Values{} data.Set("domain", domain) data.Set("type", "TXT") data.Set("record", subDomain+":"+value) data.Set("action", "delete") return c.doRequest(ctx, data) } func (c *Client) doRequest(ctx context.Context, data url.Values) error { data.Set("apikey", c.apiKey) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(data.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } var r APIResponse err = json.Unmarshal(raw, &r) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if r.Result == "OK" { return nil } return fmt.Errorf("%s: %s", r.Result, r.Details) } ================================================ FILE: providers/dns/webnames/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.baseURL = server.URL client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded(), ) } func TestClient_AddTXTRecord(t *testing.T) { testCases := []struct { desc string filename string require require.ErrorAssertionFunc }{ { desc: "ok", filename: "ok.json", require: require.NoError, }, { desc: "error", filename: "error.json", require: require.Error, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture(test.filename), servermock.CheckForm().Strict(). With("domain", "example.com"). With("type", "TXT"). With("record", "foo:txtTXTtxt"). With("action", "add"). With("apikey", "secret"), ). Build(t) domain := "example.com" subDomain := "foo" content := "txtTXTtxt" err := client.AddTXTRecord(t.Context(), domain, subDomain, content) test.require(t, err) }) } } func TestClient_RemoveTxtRecord(t *testing.T) { testCases := []struct { desc string filename string require require.ErrorAssertionFunc }{ { desc: "ok", filename: "ok.json", require: require.NoError, }, { desc: "error", filename: "error.json", require: require.Error, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture(test.filename), servermock.CheckForm().Strict(). With("domain", "example.com"). With("type", "TXT"). With("record", "foo:txtTXTtxt"). With("action", "delete"). With("apikey", "secret"), ). Build(t) domain := "example.com" subDomain := "foo" content := "txtTXTtxt" err := client.RemoveTXTRecord(t.Context(), domain, subDomain, content) test.require(t, err) }) } } ================================================ FILE: providers/dns/webnames/internal/fixtures/error.json ================================================ { "result": "ERROR", "details": "zone_manager_unavailable" } ================================================ FILE: providers/dns/webnames/internal/fixtures/ok.json ================================================ { "result": "OK", "details": 1 } ================================================ FILE: providers/dns/webnames/internal/types.go ================================================ package internal import "encoding/json" type APIResponse struct { Result string `json:"result"` Details json.RawMessage `json:"details"` } ================================================ FILE: providers/dns/webnames/webnames.go ================================================ // Package webnames implements a DNS provider for solving the DNS-01 challenge using webnames.ru DNS. package webnames import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/webnames/internal" ) // Environment variables names. const ( envNamespace = "WEBNAMESRU_" altEnvNamespace = "WEBNAMES_" EnvAPIKey = envNamespace + "API_KEY" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, dns01.DefaultPropagationTimeout, env.ParseSecond, altEnvName(EnvPropagationTimeout)), PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)), HTTPClient: &http.Client{ Timeout: env.GetOneWithFallback(EnvHTTPTimeout, 20*time.Second, env.ParseSecond, altEnvName(EnvHTTPTimeout)), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a new DNS provider using // environment variable WEBNAMESRU_API_KEY for adding and removing the DNS record. func NewDNSProvider() (*DNSProvider, error) { values, err := env.GetWithFallback([]string{EnvAPIKey, altEnvName(EnvAPIKey)}) if err != nil { return nil, fmt.Errorf("webnamesru: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Webnames. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("webnamesru: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("webnamesru: credentials missing") } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("webnamesru: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("webnamesru: %w", err) } err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value) if err != nil { return fmt.Errorf("webnamesru: failed to create TXT records [domain: %s, sub domain: %s]: %w", dns01.UnFqdn(authZone), subDomain, err) } return nil } // CleanUp clears Webnames TXT record. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("webnamesru: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("webnamesru: %w", err) } err = d.client.RemoveTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value) if err != nil { return fmt.Errorf("webnamesru: failed to remove TXT records [domain: %s, sub domain: %s]: %w", dns01.UnFqdn(authZone), subDomain, err) } return 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 } func altEnvName(v string) string { return strings.ReplaceAll(v, envNamespace, altEnvNamespace) } ================================================ FILE: providers/dns/webnames/webnames.toml ================================================ Name = "webnames.ru" Description = '''''' URL = "https://www.webnames.ru/" Code = "webnames" Aliases = ["webnamesru"] Since = "v4.15.0" Example = ''' WEBNAMESRU_API_KEY=xxxxxx \ lego --dns webnamesru -d '*.example.com' -d example.com run ''' Additional = ''' ## API Key To obtain the key, you need to change the DNS server to `*.nameself.com`: Personal account / My domains and services / Select the required domain / DNS servers The API key can be found: Personal account / My domains and services / Select the required domain / Zone management / acme.sh or certbot settings ''' [Configuration] [Configuration.Credentials] WEBNAMESRU_API_KEY = "Domain API key" [Configuration.Additional] WEBNAMESRU_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" WEBNAMESRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" WEBNAMESRU_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://github.com/regtime-ltd/certbot-dns-webnames" ================================================ FILE: providers/dns/webnames/webnames_test.go ================================================ package webnames import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "webnamesru: some credentials information are missing: WEBNAMESRU_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "123", }, { desc: "missing credentials", expected: "webnamesru: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/webnamesca/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://www.webnames.ca/_/APICore" // Client the webnames.ca API client. type Client struct { user string key string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(user, key string) (*Client, error) { if user == "" || key == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ user: user, key: key, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddTXTRecord(ctx context.Context, domainName, hostName, value string) ([]DNSRecordSet, error) { endpoint := c.BaseURL.JoinPath("domains", domainName, "add-txt-record") query := endpoint.Query() query.Set("hostName", hostName) query.Set("txt", value) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil) if err != nil { return nil, err } var result APIResponse[*DNSInfo] err = c.do(req, &result) if err != nil { return nil, err } return result.Result.DNSRecordSets, nil } func (c *Client) DeleteTXTRecord(ctx context.Context, domainName, hostName, value string) ([]DNSRecordSet, error) { endpoint := c.BaseURL.JoinPath("domains", domainName, "delete-txt-record") query := endpoint.Query() query.Set("hostName", hostName) query.Set("txt", value) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return nil, err } var result APIResponse[*DNSInfo] err = c.do(req, &result) if err != nil { return nil, err } return result.Result.DNSRecordSets, nil } func (c *Client) do(req *http.Request, result any) error { useragent.SetHeader(req.Header) req.Header.Set("API-User", c.user) req.Header.Set("API-Key", c.key) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIError err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/webnamesca/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") if err != nil { return nil, err } client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). With("API-User", "user"). With("API-Key", "secret"). WithJSONHeaders(), ) } func TestClient_AddTXTRecord(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/add-txt-record", servermock.ResponseFromFixture("add_txt_record.json"), servermock.CheckQueryParameter().Strict(). With("hostName", "foo.example.com"). With("txt", "value")). Build(t) result, err := client.AddTXTRecord(t.Context(), "example.com", "foo.example.com", "value") require.NoError(t, err) expected := []DNSRecordSet{{ Hostname: "_acme-challenge.example.com", Type: "TXT", Records: []string{"value"}, }} assert.Equal(t, expected, result) } func TestClient_AddTXTRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /domains/example.com/add-txt-record", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.AddTXTRecord(t.Context(), "example.com", "foo.example.com", "value") require.EqualError(t, err, "message: User does not exist., details: string, logiD: 35579, result: {}") } func TestClient_DeleteTXTRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/delete-txt-record", servermock.ResponseFromFixture("delete_txt_record.json"), servermock.CheckQueryParameter().Strict(). With("hostName", "foo.example.com"). With("txt", "value")). Build(t) result, err := client.DeleteTXTRecord(t.Context(), "example.com", "foo.example.com", "value") require.NoError(t, err) expected := []DNSRecordSet{{ Hostname: "_acme-challenge.example.com", Type: "TXT", Records: []string{"value"}, }} assert.Equal(t, expected, result) } func TestClient_DeleteTXTRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /domains/example.com/delete-txt-record", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest)). Build(t) _, err := client.DeleteTXTRecord(t.Context(), "example.com", "foo.example.com", "value") require.EqualError(t, err, "message: User does not exist., details: string, logiD: 35579, result: {}") } ================================================ FILE: providers/dns/webnamesca/internal/fixtures/add_txt_record.json ================================================ { "result": { "domainAdvancedDNSConfigID": 3258480, "domainID": 1333334, "dtCreated": "2025-10-30T11:55:23.243", "dtModified": "2025-10-30T11:55:23.177", "timeToLive": 21600, "soAorigin": "hosting.webnames.ca", "soArefresh": 21600, "soAretry": 180, "soAexpire": 1209600, "soAnegcache": 3600, "forwardingURL": null, "gripping": false, "name": null, "dtSubmitted": "2025-10-30T11:55:24.927", "dtRequestedDNSChange": null, "type": "REAL_DOMAIN", "userManaged": false, "effectiveMgmtOption": "AD", "urlForwardRootOnly": false, "enableDNSSEC": false, "dnsRecordSets": [ { "hostname": "_acme-challenge.example.com", "type": "TXT", "records": [ "value" ] } ] }, "logID": 36014 } ================================================ FILE: providers/dns/webnamesca/internal/fixtures/delete_txt_record.json ================================================ { "errorMessage": "string", "errorDetails": "string", "logID": 0, "result": { "domainAdvancedDNSConfigID": 0, "domainID": 0, "dtCreated": "2025-10-29T21:22:31.478", "dtModified": "2025-10-29T21:22:31.478", "timeToLive": 0, "soAorigin": "string", "soArefresh": 0, "soAretry": 0, "soAexpire": 0, "soAnegcache": 0, "forwardingURL": "string", "gripping": true, "name": "string", "dtSubmitted": "2025-10-29T21:22:31.478", "dtRequestedDNSChange": "2025-10-29T21:22:31.478", "type": "string", "userManaged": true, "effectiveMgmtOption": "string", "urlForwardRootOnly": true, "enableDNSSEC": true, "dnsRecordSets": [ { "hostname": "_acme-challenge.example.com", "type": "TXT", "records": [ "value" ] } ] } } ================================================ FILE: providers/dns/webnamesca/internal/fixtures/error.json ================================================ { "errorMessage": "User does not exist.", "errorDetails": "string", "logID": 35579, "result": {} } ================================================ FILE: providers/dns/webnamesca/internal/types.go ================================================ package internal import ( "encoding/json" "fmt" ) type APIError struct { ErrorMessage string `json:"errorMessage,omitempty"` ErrorDetails string `json:"errorDetails,omitempty"` LogID int `json:"logID,omitempty"` Result json.RawMessage `json:"result,omitempty"` } func (a *APIError) Error() string { return fmt.Sprintf("message: %s, details: %s, logiD: %d, result: %s", a.ErrorMessage, a.ErrorDetails, a.LogID, a.Result) } type APIResponse[T any] struct { Result T `json:"result,omitempty"` LogID int `json:"logID,omitempty"` } type DNSInfo struct { DomainID int `json:"domainID,omitempty"` DNSRecordSets []DNSRecordSet `json:"dnsRecordSets,omitempty"` } type DNSRecordSet struct { Hostname string `json:"hostname"` Type string `json:"type"` Records []string `json:"records"` } ================================================ FILE: providers/dns/webnamesca/webnamesca.go ================================================ // Package webnamesca implements a DNS provider for solving the DNS-01 challenge using webnames.ca. package webnamesca import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/webnamesca/internal" ) // Environment variables names. const ( envNamespace = "WEBNAMESCA_" EnvAPIUser = envNamespace + "API_USER" EnvAPIKey = envNamespace + "API_KEY" 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 { APIUser string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 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 { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for webnames.ca. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIUser, EnvAPIKey) if err != nil { return nil, fmt.Errorf("webnamesca: %w", err) } config := NewDefaultConfig() config.APIUser = values[EnvAPIUser] config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for webnames.ca. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("webnamesca: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIUser, config.APIKey) if err != nil { return nil, fmt.Errorf("webnamesca: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("webnamesca: could not find zone for domain %q: %w", domain, err) } _, err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("webnamesca: add TXT record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("webnamesca: could not find zone for domain %q: %w", domain, err) } _, err = d.client.DeleteTXTRecord(context.Background(), dns01.UnFqdn(authZone), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("webnamesca: delete TXT record: %w", err) } return 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 } ================================================ FILE: providers/dns/webnamesca/webnamesca.toml ================================================ Name = "webnames.ca" Description = '''''' URL = "https://www.webnames.ca/" Code = "webnamesca" Since = "v4.28.0" Example = ''' WEBNAMESCA_API_USER="xxx" \ WEBNAMESCA_API_KEY="yyy" \ lego --dns webnamesca -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] WEBNAMESCA_API_USER = "API username" WEBNAMESCA_API_KEY = "API key" [Configuration.Additional] WEBNAMESCA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" WEBNAMESCA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" WEBNAMESCA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" WEBNAMESCA_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.webnames.ca/_/swagger/index.html" ================================================ FILE: providers/dns/webnamesca/webnamesca_test.go ================================================ package webnamesca import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIUser, EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIUser: "user", EnvAPIKey: "secret", }, }, { desc: "missing EnvAPIUser", envVars: map[string]string{ EnvAPIUser: "", EnvAPIKey: "secret", }, expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_USER", }, { desc: "missing EnvAPIKey", envVars: map[string]string{ EnvAPIUser: "user", EnvAPIKey: "", }, expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_KEY", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "webnamesca: some credentials information are missing: WEBNAMESCA_API_USER,WEBNAMESCA_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiUser string apiKey string expected string }{ { desc: "success", apiUser: "user", apiKey: "secret", }, { desc: "missing apiUser", apiKey: "secret", expected: "webnamesca: credentials missing", }, { desc: "missing apiKey", apiUser: "user", expected: "webnamesca: credentials missing", }, { desc: "missing credentials", expected: "webnamesca: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIUser = test.apiUser config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIUser = "user" config.APIKey = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("API-User", "user"). With("API-Key", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /domains/example.com/add-txt-record", servermock.ResponseFromInternal("add_txt_record.json"), servermock.CheckQueryParameter().Strict(). With("hostName", "_acme-challenge.example.com"). With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("DELETE /domains/example.com/delete-txt-record", servermock.ResponseFromInternal("delete_txt_record.json"), servermock.CheckQueryParameter().Strict(). With("hostName", "_acme-challenge.example.com"). With("txt", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY")). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/websupport/websupport.go ================================================ // Package websupport implements a DNS provider for solving the DNS-01 challenge using Websupport. package websupport import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/active24" ) const baseAPIDomain = "websupport.sk" // Environment variables names. const ( envNamespace = "WEBSUPPORT_" EnvAPIKey = envNamespace + "API_KEY" EnvSecret = envNamespace + "SECRET" 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 = active24.Config // 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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Websupport. // Credentials must be passed in the environment variables: WEBSUPPORT_API_KEY, WEBSUPPORT_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey, EnvSecret) if err != nil { return nil, fmt.Errorf("websupport: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.Secret = values[EnvSecret] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Websupport. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("websupport: the configuration of the DNS provider is nil") } provider, err := active24.NewDNSProviderConfig(config, baseAPIDomain) if err != nil { return nil, fmt.Errorf("websupport: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("websupport: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("websupport: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/websupport/websupport.toml ================================================ Name = "Websupport" Description = '''''' URL = "https://websupport.sk" Code = "websupport" Since = "v4.10.0" Example = ''' WEBSUPPORT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ WEBSUPPORT_SECRET="yyyyyyyyyyyyyyyyyyyyy" \ lego --dns websupport -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] WEBSUPPORT_API_KEY = "API key" WEBSUPPORT_SECRET = "API secret" [Configuration.Additional] WEBSUPPORT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" WEBSUPPORT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" WEBSUPPORT_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 60)" WEBSUPPORT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" WEBSUPPORT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://rest.websupport.sk/v2/docs" APIv1 = "https://rest.websupport.sk/docs/v1.service#services" ================================================ FILE: providers/dns/websupport/websupport_test.go ================================================ package websupport import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "user", EnvSecret: "secret", }, }, { desc: "missing API key", envVars: map[string]string{ EnvAPIKey: "", EnvSecret: "secret", }, expected: "websupport: some credentials information are missing: WEBSUPPORT_API_KEY", }, { desc: "missing secret", envVars: map[string]string{ EnvAPIKey: "user", EnvSecret: "", }, expected: "websupport: some credentials information are missing: WEBSUPPORT_SECRET", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "websupport: some credentials information are missing: WEBSUPPORT_API_KEY,WEBSUPPORT_SECRET", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string secret string expected string }{ { desc: "success", apiKey: "user", secret: "secret", }, { desc: "missing API key", apiKey: "", secret: "secret", expected: "websupport: credentials missing", }, { desc: "missing secret", apiKey: "user", secret: "", expected: "websupport: credentials missing", }, { desc: "missing credentials", expected: "websupport: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Secret = test.secret p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/wedos/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const baseURL = "https://api.wedos.com/wapi/json" // Client the API client for Webos. type Client struct { username string password string baseURL string HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(username, password string) *Client { return &Client{ username: username, password: password, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } // GetRecords lists all the records in the zone. // https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-rows-list/ func (c *Client) GetRecords(ctx context.Context, zone string) ([]DNSRow, error) { payload := map[string]any{ "domain": dns01.UnFqdn(zone), } req, err := c.newRequest(ctx, commandDNSRowsList, payload) if err != nil { return nil, err } result := APIResponse[Rows]{} err = c.do(req, &result) if err != nil { return nil, err } return result.Response.Data.Rows, err } // AddRecord adds a record in the zone, either by updating existing records or creating new ones. // https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-add-row/ // https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-row-update/ func (c *Client) AddRecord(ctx context.Context, zone string, record DNSRow) error { payload := DNSRowRequest{ Domain: dns01.UnFqdn(zone), TTL: record.TTL, Type: record.Type, Data: record.Data, } cmd := commandDNSRowAdd if record.ID == "" { payload.Name = record.Name } else { cmd = commandDNSRowUpdate payload.ID = record.ID } req, err := c.newRequest(ctx, cmd, payload) if err != nil { return err } return c.do(req, &APIResponse[json.RawMessage]{}) } // DeleteRecord deletes a record from the zone. // If a record does not have an ID, it will be looked up. // https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-row-delete/ func (c *Client) DeleteRecord(ctx context.Context, zone, recordID string) error { payload := DNSRowRequest{ Domain: dns01.UnFqdn(zone), ID: recordID, } req, err := c.newRequest(ctx, commandDNSRowDelete, payload) if err != nil { return err } return c.do(req, &APIResponse[json.RawMessage]{}) } // Commit not really required, all changes will be auto-committed after 5 minutes. // https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-domain-commit/ func (c *Client) Commit(ctx context.Context, zone string) error { payload := map[string]any{ "name": dns01.UnFqdn(zone), } req, err := c.newRequest(ctx, commandDNSDomainCommit, payload) if err != nil { return err } return c.do(req, &APIResponse[json.RawMessage]{}) } func (c *Client) Ping(ctx context.Context) error { req, err := c.newRequest(ctx, commandPing, nil) if err != nil { return err } return c.do(req, &APIResponse[json.RawMessage]{}) } func (c *Client) do(req *http.Request, result Response) error { resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if result.GetCode() != codeOk { return fmt.Errorf("error %d: %s", result.GetCode(), result.GetResult()) } return err } func (c *Client) newRequest(ctx context.Context, command string, payload any) (*http.Request, error) { requestObject := map[string]any{ "request": APIRequest{ User: c.username, Auth: authToken(c.username, c.password), Command: command, Data: payload, }, } object, err := json.Marshal(requestObject) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } form := url.Values{} form.Add("request", string(object)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") return req, nil } ================================================ FILE: providers/dns/wedos/internal/client_test.go ================================================ package internal import ( "fmt" "net/http" "net/http/httptest" "regexp" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.baseURL = server.URL client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func TestClient_GetRecords(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture(commandDNSRowsList+".json"), checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-rows-list","data":{"domain":"example.com"}}}`)). Build(t) records, err := client.GetRecords(t.Context(), "example.com.") require.NoError(t, err) assert.Len(t, records, 4) expected := []DNSRow{ { ID: "911", TTL: "1800", Type: "A", Data: "1.2.3.4", }, { ID: "913", TTL: "1800", Type: "MX", Data: "1 mail1.wedos.net", }, { ID: "914", TTL: "1800", Type: "MX", Data: "10 mailbackup.wedos.net", }, { ID: "912", Name: "*", TTL: "1800", Type: "A", Data: "1.2.3.4", }, } assert.Equal(t, expected, records) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture(commandDNSRowAdd+".json"), checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-add","data":{"domain":"example.com","name":"foo","ttl":1800,"type":"TXT","rdata":"foobar"}}}`)). Build(t) record := DNSRow{ ID: "", Name: "foo", TTL: "1800", Type: "TXT", Data: "foobar", } err := client.AddRecord(t.Context(), "example.com.", record) require.NoError(t, err) } func TestClient_AddRecord_update(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture(commandDNSRowUpdate+".json"), checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-update","data":{"row_id":"1","domain":"example.com","ttl":1800,"type":"TXT","rdata":"foobar"}}}`)). Build(t) record := DNSRow{ ID: "1", Name: "foo", TTL: "1800", Type: "TXT", Data: "foobar", } err := client.AddRecord(t.Context(), "example.com.", record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture(commandDNSRowDelete+".json"), checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-delete","data":{"row_id":"1","domain":"example.com","rdata":""}}}`)). Build(t) err := client.DeleteRecord(t.Context(), "example.com.", "1") require.NoError(t, err) } func TestClient_Commit(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture(commandDNSDomainCommit+".json"), checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-domain-commit","data":{"name":"example.com"}}}`)). Build(t) err := client.Commit(t.Context(), "example.com.") require.NoError(t, err) } func checkFormRequest(data string) servermock.LinkFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { err := req.ParseForm() if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } form := regexp.MustCompile(`"auth":"\w+",`). ReplaceAllString(req.PostForm.Get("request"), `"auth":"xxx",`) if form != data { http.Error(rw, fmt.Sprintf("invalid form data: %s", req.PostForm.Get("request")), http.StatusBadRequest) return } next.ServeHTTP(rw, req) }) } } ================================================ FILE: providers/dns/wedos/internal/fixtures/dns-domain-commit.json ================================================ { "response": { "code": 1000, "result": "OK", "timestamp": 1291192534, "svTRID": "1291192534.6326.32542.1", "command": "dns-domain-commit" } } ================================================ FILE: providers/dns/wedos/internal/fixtures/dns-row-add.json ================================================ { "response": { "code": 1000, "result": "OK", "timestamp": 1291210501, "svTRID": "1291210501.7672.19698.1", "command": "dns-row-add" } } ================================================ FILE: providers/dns/wedos/internal/fixtures/dns-row-delete.json ================================================ { "response": { "code": 1000, "result": "OK", "timestamp": 1291370821, "svTRID": "1291370821.1702.7371.1", "command": "dns-row-delete" } } ================================================ FILE: providers/dns/wedos/internal/fixtures/dns-row-update.json ================================================ { "response": { "code": 1000, "result": "OK", "timestamp": 1291370821, "svTRID": "1291370821.1702.7371.1", "command": "dns-row-update" } } ================================================ FILE: providers/dns/wedos/internal/fixtures/dns-rows-list.json ================================================ { "response": { "code": 1000, "result": "OK", "timestamp": 1291194425, "svTRID": "1291194425.9562.9881.1", "command": "dns-rows-list", "data": { "row": [ { "ID": "911", "name": "", "ttl": "1800", "rdtype": "A", "rdata": "1.2.3.4", "changed_date": "2010-12-01 09:54:41", "author_comment": "" }, { "ID": "913", "name": "", "ttl": "1800", "rdtype": "MX", "rdata": "1 mail1.wedos.net", "changed_date": "2010-12-01 09:54:54", "author_comment": "" }, { "ID": "914", "name": "", "ttl": "1800", "rdtype": "MX", "rdata": "10 mailbackup.wedos.net", "changed_date": "2010-12-01 09:55:07", "author_comment": "" }, { "ID": "912", "name": "*", "ttl": "1800", "rdtype": "A", "rdata": "1.2.3.4", "changed_date": "2010-12-01 09:54:46", "author_comment": "" } ] } } } ================================================ FILE: providers/dns/wedos/internal/token.go ================================================ package internal import ( "crypto/sha1" "encoding/hex" "fmt" "io" "time" ) func authToken(userName, wapiPass string) string { return sha1string(userName + sha1string(wapiPass) + czechHourString()) } func sha1string(txt string) string { h := sha1.New() _, _ = io.WriteString(h, txt) return hex.EncodeToString(h.Sum(nil)) } func czechHourString() string { return formatHour(czechHour()) } func czechHour() int { tryZones := []string{"Europe/Prague", "Europe/Paris", "CET"} for _, zoneName := range tryZones { loc, err := time.LoadLocation(zoneName) if err == nil { return time.Now().In(loc).Hour() } } // hopefully this will never be used // this is fallback for containers without tzdata installed return utcToCet(time.Now().UTC()).Hour() } func utcToCet(utc time.Time) time.Time { // https://en.wikipedia.org/wiki/Central_European_Time // As of 2011, all member states of the European Union observe Summer Time (daylight saving time), // from the last Sunday in March to the last Sunday in October. // States within the CET area switch to Central European Summer Time (CEST -- UTC+02:00) for the summer.[1] utcMonth := utc.Month() if utcMonth < time.March || utcMonth > time.October { return utc.Add(time.Hour) } if utcMonth > time.March && utcMonth < time.October { return utc.Add(time.Hour * 2) } dayOff := 0 breaking := time.Date(utc.Year(), utcMonth+1, dayOff, 1, 0, 0, 0, time.UTC) for breaking.Weekday() != time.Sunday { dayOff-- breaking = time.Date(utc.Year(), utcMonth+1, dayOff, 1, 0, 0, 0, time.UTC) if dayOff < -7 { panic("safety exit to avoid infinite loop") } } if (utcMonth == time.March && utc.Before(breaking)) || (utcMonth == time.October && utc.After(breaking)) { return utc.Add(time.Hour) } return utc.Add(time.Hour * 2) } func formatHour(hour int) string { return fmt.Sprintf("%02d", hour) } ================================================ FILE: providers/dns/wedos/internal/types.go ================================================ package internal import "encoding/json" const codeOk = 1000 const ( commandPing = "ping" commandDNSDomainCommit = "dns-domain-commit" commandDNSRowsList = "dns-rows-list" commandDNSRowDelete = "dns-row-delete" commandDNSRowAdd = "dns-row-add" commandDNSRowUpdate = "dns-row-update" ) type Response interface { GetCode() int GetResult() string } type APIResponse[D any] struct { Response ResponsePayload[D] `json:"response"` } func (a APIResponse[D]) GetCode() int { return a.Response.Code } func (a APIResponse[D]) GetResult() string { return a.Response.Result } type ResponsePayload[D any] struct { Code int `json:"code,omitempty"` Result string `json:"result,omitempty"` Timestamp int `json:"timestamp,omitempty"` SvTRID string `json:"svTRID,omitempty"` Command string `json:"command,omitempty"` Data D `json:"data"` } type Rows struct { Rows []DNSRow `json:"row"` } type DNSRow struct { ID string `json:"ID,omitempty"` Name string `json:"name,omitempty"` TTL json.Number `json:"ttl,omitempty"` Type string `json:"rdtype,omitempty"` Data string `json:"rdata"` } type DNSRowRequest struct { ID string `json:"row_id,omitempty"` Domain string `json:"domain,omitempty"` Name string `json:"name,omitempty"` TTL json.Number `json:"ttl,omitempty"` Type string `json:"type,omitempty"` Data string `json:"rdata"` } type APIRequest struct { User string `json:"user,omitempty"` Auth string `json:"auth,omitempty"` Command string `json:"command,omitempty"` Data any `json:"data,omitempty"` } ================================================ FILE: providers/dns/wedos/wedos.go ================================================ package wedos import ( "context" "encoding/json" "errors" "fmt" "net/http" "strconv" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/wedos/internal" ) // Environment variables names. const ( envNamespace = "WEDOS_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "WAPI_PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const minTTL = 5 * 60 // 5 minutes var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Username string Password string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), TTL: env.GetOrDefaultInt(EnvTTL, minTTL), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("wedos: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("wedos: the configuration of the DNS provider is nil") } if config.Username == "" || config.Password == "" { return nil, errors.New("wedos: some credentials information are missing") } if config.TTL < minTTL { return nil, fmt.Errorf("wedos: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } client := internal.NewClient(config.Username, config.Password) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("wedos: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("wedos: %w", err) } record := internal.DNSRow{ Name: subDomain, TTL: json.Number(strconv.Itoa(d.config.TTL)), Type: "TXT", Data: info.Value, } records, err := d.client.GetRecords(ctx, authZone) if err != nil { return fmt.Errorf("wedos: could not get records for domain %q: %w", domain, err) } for _, candidate := range records { if candidate.Type == "TXT" && candidate.Name == subDomain && candidate.Data == info.Value { record.ID = candidate.ID break } } err = d.client.AddRecord(ctx, authZone, record) if err != nil { return fmt.Errorf("wedos: could not add TXT record for domain %q: %w", domain, err) } err = d.client.Commit(ctx, authZone) if err != nil { return fmt.Errorf("wedos: could not commit TXT record for domain %q: %w", domain, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("wedos: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("wedos: %w", err) } records, err := d.client.GetRecords(ctx, authZone) if err != nil { return fmt.Errorf("wedos: could not get records for domain %q: %w", domain, err) } for _, candidate := range records { if candidate.Type != "TXT" || candidate.Name != subDomain || candidate.Data != info.Value { continue } err = d.client.DeleteRecord(ctx, authZone, candidate.ID) if err != nil { return fmt.Errorf("wedos: could not remove TXT record for domain %q: %w", domain, err) } err = d.client.Commit(ctx, authZone) if err != nil { return fmt.Errorf("wedos: could not commit TXT record for domain %q: %w", domain, err) } return nil } return nil } ================================================ FILE: providers/dns/wedos/wedos.toml ================================================ Name = "WEDOS" Description = '''''' URL = "https://www.wedos.com" Code = "wedos" Since = "v4.4.0" Example = ''' WEDOS_USERNAME=xxxxxxxx \ WEDOS_WAPI_PASSWORD=xxxxxxxx \ lego --dns wedos -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] WEDOS_USERNAME = "Username is the same as for the admin account" WEDOS_WAPI_PASSWORD = "Password needs to be generated and IP allowed in the admin interface" [Configuration.Additional] WEDOS_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" WEDOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" WEDOS_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" WEDOS_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://kb.wedos.com/en/kategorie/wapi-api-interface/wdns-en/" ================================================ FILE: providers/dns/wedos/wedos_test.go ================================================ package wedos import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "admin@example.com", EnvPassword: "secret", }, }, { desc: "missing credentials: username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "secret", }, expected: "wedos: some credentials information are missing: WEDOS_USERNAME", }, { desc: "missing credentials: password", envVars: map[string]string{ EnvUsername: "admin@example.com", EnvPassword: "", }, expected: "wedos: some credentials information are missing: WEDOS_WAPI_PASSWORD", }, { desc: "missing credentials: all", envVars: map[string]string{ EnvUsername: "", EnvPassword: "", }, expected: "wedos: some credentials information are missing: WEDOS_USERNAME,WEDOS_WAPI_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "admin@example.com", password: "secret", }, { desc: "missing username", password: "secret", expected: "wedos: some credentials information are missing", }, { desc: "missing WAPI password", username: "admin@example.com", expected: "wedos: some credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/westcn/westcn.go ================================================ // Package westcn implements a DNS provider for solving the DNS-01 challenge using West.cn/西部数码. package westcn import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/westcn" ) // Environment variables names. const ( envNamespace = "WESTCN_" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://api.west.cn/api/v2" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = westcn.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for West.cn/西部数码. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("westcn: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for West.cn/西部数码. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("westcn: the configuration of the DNS provider is nil") } provider, err := westcn.NewDNSProviderConfig(config, defaultBaseURL) if err != nil { return nil, fmt.Errorf("westcn: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("westcn: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("westcn: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/westcn/westcn.toml ================================================ Name = "West.cn/西部数码" Description = '''''' URL = "https://www.west.cn" Code = "westcn" Since = "v4.21.0" Example = ''' WESTCN_USERNAME="xxx" \ WESTCN_PASSWORD="yyy" \ lego --dns westcn -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] WESTCN_USERNAME = "Username" WESTCN_PASSWORD = "API password" [Configuration.Additional] WESTCN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" WESTCN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" WESTCN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" WESTCN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.west.cn/CustomerCenter/doc/domain_v2.html" ================================================ FILE: providers/dns/westcn/westcn_test.go ================================================ package westcn import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "secret", }, }, { desc: "missing username", envVars: map[string]string{ EnvUsername: "", EnvPassword: "secret", }, expected: "westcn: some credentials information are missing: WESTCN_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvUsername: "user", EnvPassword: "", }, expected: "westcn: some credentials information are missing: WESTCN_PASSWORD", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "westcn: some credentials information are missing: WESTCN_USERNAME,WESTCN_PASSWORD", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string username string password string expected string }{ { desc: "success", username: "user", password: "secret", }, { desc: "missing username", password: "secret", expected: "westcn: credentials missing", }, { desc: "missing password", username: "user", expected: "westcn: credentials missing", }, { desc: "missing credentials", expected: "westcn: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/yandex/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://pddimp.yandex.ru/api2/admin/dns" const successCode = "ok" const pddTokenHeader = "PddToken" type Client struct { pddToken string baseURL *url.URL HTTPClient *http.Client } func NewClient(pddToken string) (*Client, error) { if pddToken == "" { return nil, errors.New("PDD token is required") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ pddToken: pddToken, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) AddRecord(ctx context.Context, payload Record) (*Record, error) { endpoint := c.baseURL.JoinPath("add") req, err := newRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return nil, err } r := AddResponse{} err = c.do(req, &r) if err != nil { return nil, err } return r.Record, nil } func (c *Client) RemoveRecord(ctx context.Context, payload Record) (int, error) { endpoint := c.baseURL.JoinPath("del") req, err := newRequest(ctx, http.MethodPost, endpoint, payload) if err != nil { return 0, err } r := RemoveResponse{} err = c.do(req, &r) if err != nil { return 0, err } return r.RecordID, nil } func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) { endpoint := c.baseURL.JoinPath("list") payload := struct { Domain string `url:"domain"` }{Domain: domain} req, err := newRequest(ctx, http.MethodGet, endpoint, payload) if err != nil { return nil, err } r := ListResponse{} err = c.do(req, &r) if err != nil { return nil, err } return r.Records, nil } func (c *Client) do(req *http.Request, result Response) error { req.Header.Set(pddTokenHeader, c.pddToken) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if result.GetSuccess() != successCode { return fmt.Errorf("error during operation: %s %s", result.GetSuccess(), result.GetError()) } return nil } func newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { switch method { case http.MethodPost: values, err := querystring.Values(payload) if err != nil { return nil, err } buf.WriteString(values.Encode()) case http.MethodGet: values, err := querystring.Values(payload) if err != nil { return nil, err } endpoint.RawQuery = values.Encode() } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } if method == http.MethodPost { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } return req, nil } ================================================ FILE: providers/dns/yandex/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("lego") if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil } func TestAddRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /add", servermock.ResponseFromFixture("add_record.json"), servermock.CheckHeader(). WithContentTypeFromURLEncoded(), servermock.CheckForm().Strict(). With("domain", "example.com"). With("subdomain", "foo"). With("ttl", "300"). With("content", "txtTXTtxtTXTtxtTXT"). With("type", "TXT")). Build(t) data := Record{ Domain: "example.com", Type: "TXT", Content: "txtTXTtxtTXTtxtTXT", SubDomain: "foo", TTL: 300, } record, err := client.AddRecord(t.Context(), data) require.NoError(t, err) require.NotNil(t, record) } func TestAddRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /add", servermock.ResponseFromFixture("add_record_error.json"), servermock.CheckHeader(). WithContentTypeFromURLEncoded()). Build(t) data := Record{ Domain: "example.com", Type: "TXT", Content: "txtTXTtxtTXTtxtTXT", SubDomain: "foo", TTL: 300, } _, err := client.AddRecord(t.Context(), data) require.EqualError(t, err, "error during operation: error bad things") } func TestRemoveRecord(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /del", servermock.ResponseFromFixture("remove_record.json"), servermock.CheckHeader(). WithContentTypeFromURLEncoded(), servermock.CheckForm().Strict(). With("domain", "example.com"). With("record_id", "6")). Build(t) data := Record{ ID: 6, Domain: "example.com", } id, err := client.RemoveRecord(t.Context(), data) require.NoError(t, err) assert.Equal(t, 6, id) } func TestRemoveRecord_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("POST /del", servermock.ResponseFromFixture("remove_record_error.json"), servermock.CheckHeader(). WithContentTypeFromURLEncoded()). Build(t) data := Record{ ID: 6, Domain: "example.com", } _, err := client.RemoveRecord(t.Context(), data) require.EqualError(t, err, "error during operation: error bad things") } func TestGetRecords(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /list", servermock.ResponseFromFixture("get_records.json"), servermock.CheckForm().Strict(). With("domain", "example.com")). Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) require.Len(t, records, 2) } func TestGetRecords_error(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). Route("GET /list", servermock.ResponseFromFixture("get_records_error.json")). Build(t) _, err := client.GetRecords(t.Context(), "example.com") require.EqualError(t, err, "error during operation: error bad things") } ================================================ FILE: providers/dns/yandex/internal/fixtures/add_record.json ================================================ { "success": "ok", "domain": "example.com", "record": { "record_id": 1, "domain": "example.com", "subdomain": "foo", "fqdn": "foo.example.com.", "ttl": 300, "type": "TXT", "content": "txtTXTtxtTXTtxtTXT" } } ================================================ FILE: providers/dns/yandex/internal/fixtures/add_record_error.json ================================================ { "success": "error", "error": "bad things", "domain": "example.com" } ================================================ FILE: providers/dns/yandex/internal/fixtures/get_records.json ================================================ { "success": "ok", "domain": "example.com", "records": [ { "record_id": 1, "domain": "example.com", "subdomain": "foo", "fqdn": "foo.example.com.", "ttl": 300, "type": "TXT", "content": "txtTXTtxtTXTtxtTXT" }, { "record_id": 2, "domain": "example.com", "subdomain": "foo", "fqdn": "foo.example.com.", "ttl": 300, "type": "NS", "content": "bar" } ] } ================================================ FILE: providers/dns/yandex/internal/fixtures/get_records_error.json ================================================ { "success": "error", "error": "bad things", "domain": "example.com" } ================================================ FILE: providers/dns/yandex/internal/fixtures/remove_record.json ================================================ { "success": "ok", "domain": "example.com", "record_id": 6 } ================================================ FILE: providers/dns/yandex/internal/fixtures/remove_record_error.json ================================================ { "success": "error", "error": "bad things", "domain": "example.com", "record_id": 6 } ================================================ FILE: providers/dns/yandex/internal/types.go ================================================ package internal type Record struct { ID int `json:"record_id,omitempty" url:"record_id,omitempty"` Domain string `json:"domain,omitempty" url:"domain,omitempty"` SubDomain string `json:"subdomain,omitempty" url:"subdomain,omitempty"` FQDN string `json:"fqdn,omitempty" url:"fqdn,omitempty"` TTL int `json:"ttl,omitempty" url:"ttl,omitempty"` Type string `json:"type,omitempty" url:"type,omitempty"` Content string `json:"content,omitempty" url:"content,omitempty"` } type Response interface { GetSuccess() string GetError() string } type BaseResponse struct { Success string `json:"success"` Error string `json:"error,omitempty"` } func (r BaseResponse) GetSuccess() string { return r.Success } func (r BaseResponse) GetError() string { return r.Error } type AddResponse struct { BaseResponse Domain string `json:"domain,omitempty"` Record *Record `json:"record,omitempty"` } type RemoveResponse struct { BaseResponse Domain string `json:"domain,omitempty"` RecordID int `json:"record_id,omitempty"` } type ListResponse struct { BaseResponse Domain string `json:"domain,omitempty"` Records []Record `json:"records,omitempty"` } ================================================ FILE: providers/dns/yandex/yandex.go ================================================ // Package yandex implements a DNS provider for solving the DNS-01 challenge using Yandex PDD. package yandex import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/yandex/internal" "github.com/miekg/dns" ) // Environment variables names. const ( envNamespace = "YANDEX_" EnvPddToken = envNamespace + "PDD_TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { PddToken string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 21600), 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 Yandex. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvPddToken) if err != nil { return nil, fmt.Errorf("yandex: %w", err) } config := NewDefaultConfig() config.PddToken = values[EnvPddToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Yandex. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("yandex: the configuration of the DNS provider is nil") } if config.PddToken == "" { return nil, errors.New("yandex: credentials missing") } client, err := internal.NewClient(config.PddToken) if err != nil { return nil, fmt.Errorf("yandex: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) rootDomain, subDomain, err := splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("yandex: %w", err) } data := internal.Record{ Domain: rootDomain, SubDomain: subDomain, Type: "TXT", TTL: d.config.TTL, Content: info.Value, } _, err = d.client.AddRecord(context.Background(), data) if err != nil { return fmt.Errorf("yandex: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) rootDomain, subDomain, err := splitDomain(info.EffectiveFQDN) if err != nil { return fmt.Errorf("yandex: %w", err) } ctx := context.Background() records, err := d.client.GetRecords(ctx, rootDomain) if err != nil { return fmt.Errorf("yandex: %w", err) } var record *internal.Record for _, rcd := range records { if rcd.Type == "TXT" && rcd.SubDomain == subDomain && rcd.Content == info.Value { record = &rcd break } } if record == nil { return fmt.Errorf("yandex: TXT record not found for domain: %s", domain) } data := internal.Record{ ID: record.ID, Domain: rootDomain, } _, err = d.client.RemoveRecord(ctx, data) if err != nil { return fmt.Errorf("yandex: %w", err) } return 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 } func splitDomain(full string) (string, string, error) { split := dns.Split(full) if len(split) < 2 { return "", "", fmt.Errorf("unsupported domain: %s", full) } if len(split) == 2 { return full, "", nil } domain := full[split[len(split)-2]:] subDomain := full[:split[len(split)-2]-1] return domain, subDomain, nil } ================================================ FILE: providers/dns/yandex/yandex.toml ================================================ Name = "Yandex PDD" Description = ''' ''' URL = "https://pdd.yandex.com" Code = "yandex" Since = "v3.7.0" Example = ''' YANDEX_PDD_TOKEN= \ lego --dns yandex -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] YANDEX_PDD_TOKEN = "Basic authentication username" [Configuration.Additional] YANDEX_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" YANDEX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" YANDEX_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)" YANDEX_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://yandex.com/dev/domain/doc/concepts/api-dns.html" ================================================ FILE: providers/dns/yandex/yandex_test.go ================================================ package yandex import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvPddToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvPddToken: "SECRET", }, }, { desc: "missing token", envVars: map[string]string{}, expected: "yandex: some credentials information are missing: YANDEX_PDD_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string config *Config expected string }{ { desc: "success", config: &Config{ PddToken: "secret", }, }, { desc: "nil config", config: nil, expected: "yandex: the configuration of the DNS provider is nil", }, { desc: "missing token", config: &Config{}, expected: "yandex: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p, err := NewDNSProviderConfig(test.config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/yandex360/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://api360.yandex.net/" type Client struct { oauthToken string orgID int64 baseURL *url.URL HTTPClient *http.Client } func NewClient(oauthToken string, orgID int64) (*Client, error) { if oauthToken == "" { return nil, errors.New("OAuth token is required") } if orgID == 0 { return nil, errors.New("orgID is required") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ oauthToken: oauthToken, orgID: orgID, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // AddRecord Adds a DNS record. // POST https://api30.yandex.net/directory/v1/org/{orgId}/domains/{domain}/dns // https://yandex.ru/dev/api360/doc/ref/DomainDNSService/DomainDNSService_Create.html func (c *Client) AddRecord(ctx context.Context, domain string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("directory", "v1", "org", strconv.FormatInt(c.orgID, 10), "domains", domain, "dns") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } var newRecord Record err = c.do(req, &newRecord) if err != nil { return nil, err } return &newRecord, nil } // DeleteRecord Deletes a DNS record. // DELETE https://api360.yandex.net/directory/v1/org/{orgId}/domains/{domain}/dns/{recordId} // https://yandex.ru/dev/api360/doc/ref/DomainDNSService/DomainDNSService_Delete.html func (c *Client) DeleteRecord(ctx context.Context, domain string, recordID int64) error { endpoint := c.baseURL.JoinPath("directory", "v1", "org", strconv.FormatInt(c.orgID, 10), "domains", domain, "dns", strconv.FormatInt(recordID, 10)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { req.Header.Set("Authorization", "OAuth "+c.oauthToken) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var apiErr APIError err := json.Unmarshal(raw, &apiErr) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, apiErr) } ================================================ FILE: providers/dns/yandex360/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret", 123456) if err != nil { return nil, err } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("OAuth secret")) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /directory/v1/org/123456/domains/example.com/dns", servermock.ResponseFromFixture("add-record.json"), servermock.CheckRequestJSONBody(`{"name":"_acme-challenge","text":"txtxtxt","ttl":60,"type":"TXT"}`)). Build(t) record := Record{ Name: "_acme-challenge", Text: "txtxtxt", TTL: 60, Type: "TXT", } newRecord, err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &Record{ ID: 789465, Name: "foo", Text: "_acme-challenge", TTL: 60, Type: "txtxtxt", } assert.Equal(t, expected, newRecord) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /directory/v1/org/123456/domains/example.com/dns", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) record := Record{ Name: "_acme-challenge", Text: "txtxtxt", TTL: 60, Type: "TXT", } newRecord, err := client.AddRecord(t.Context(), "example.com", record) require.Error(t, err) assert.Nil(t, newRecord) } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /directory/v1/org/123456/domains/example.com/dns/789456", servermock.ResponseFromFixture("delete-record.json")). Build(t) err := client.DeleteRecord(t.Context(), "example.com", 789456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /directory/v1/org/123456/domains/example.com/dns/789456", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(t.Context(), "example.com", 789456) require.Error(t, err) } ================================================ FILE: providers/dns/yandex360/internal/fixtures/add-record.json ================================================ { "recordID": 789465, "name": "foo", "text": "_acme-challenge", "ttl": 60, "type": "txtxtxt" } ================================================ FILE: providers/dns/yandex360/internal/fixtures/delete-record.json ================================================ {} ================================================ FILE: providers/dns/yandex360/internal/fixtures/error.json ================================================ { "code": 123, "details": [ { "@type": "foo" } ], "message": "bar" } ================================================ FILE: providers/dns/yandex360/internal/types.go ================================================ package internal import "fmt" type Record struct { ID int64 `json:"recordId,omitempty"` Address string `json:"address,omitempty"` Exchange string `json:"exchange,omitempty"` Flag int64 `json:"flag,omitempty"` Name string `json:"name,omitempty"` Port int64 `json:"port,omitempty"` Preference int64 `json:"preference,omitempty"` Priority int64 `json:"priority,omitempty"` Tag string `json:"tag,omitempty"` Target string `json:"target,omitempty"` Text string `json:"text,omitempty"` TTL int `json:"ttl,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value,omitempty"` Weight int64 `json:"weight,omitempty"` } type APIError struct { Code int32 `json:"code"` Details []Detail `json:"details"` Message string `json:"message"` } func (a APIError) Error() string { return fmt.Sprintf("%d: %s: %v", a.Code, a.Message, a.Details) } type Detail struct { Type string `json:"@type"` } func (d Detail) String() string { return d.Type } ================================================ FILE: providers/dns/yandex360/yandex360.go ================================================ // Package yandex360 implements a DNS provider for solving the DNS-01 challenge using Yandex 360. package yandex360 import ( "context" "errors" "fmt" "net/http" "strconv" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/yandex360/internal" "github.com/miekg/dns" ) // Environment variables names. const ( envNamespace = "YANDEX360_" EnvOAuthToken = envNamespace + "OAUTH_TOKEN" EnvOrgID = envNamespace + "ORG_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { OAuthToken string OrgID int64 PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 21600), 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 recordIDs map[string]int64 recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Yandex 360. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvOAuthToken, EnvOrgID) if err != nil { return nil, fmt.Errorf("yandex360: %w", err) } config := NewDefaultConfig() config.OAuthToken = values[EnvOAuthToken] orgID, err := strconv.ParseInt(values[EnvOrgID], 10, 64) if err != nil { return nil, fmt.Errorf("yandex360: %w", err) } config.OrgID = orgID return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Yandex 360. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("yandex360: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.OAuthToken, config.OrgID) if err != nil { return nil, fmt.Errorf("yandex360: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ client: client, config: config, recordIDs: make(map[string]int64), }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("yandex360: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("yandex360: %w", err) } authZone = dns01.UnFqdn(authZone) record := internal.Record{ Name: subDomain, TTL: d.config.TTL, Text: info.Value, Type: "TXT", } newRecord, err := d.client.AddRecord(context.Background(), authZone, record) if err != nil { return fmt.Errorf("yandex360: add DNS record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = newRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(info.EffectiveFQDN)) if err != nil { return fmt.Errorf("yandex360: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("yandex360: unknown recordID for %q", info.EffectiveFQDN) } err = d.client.DeleteRecord(context.Background(), authZone, recordID) if err != nil { return fmt.Errorf("yandex360: delete DNS record: %w", err) } d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return 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 } ================================================ FILE: providers/dns/yandex360/yandex360.toml ================================================ Name = "Yandex 360" Description = ''' ''' URL = "https://360.yandex.ru" Code = "yandex360" Since = "v4.14.0" Example = ''' YANDEX360_OAUTH_TOKEN= \ YANDEX360_ORG_ID= \ lego --dns yandex360 -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] YANDEX360_OAUTH_TOKEN = "The OAuth Token" YANDEX360_ORG_ID = "The organization ID" [Configuration.Additional] YANDEX360_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" YANDEX360_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" YANDEX360_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 21600)" YANDEX360_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://yandex.ru/dev/api360/doc/ref/DomainDNSService.html" ================================================ FILE: providers/dns/yandex360/yandex360_test.go ================================================ package yandex360 import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvOAuthToken, EnvOrgID).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvOAuthToken: "secret", EnvOrgID: "123456", }, }, { desc: "missing org ID", envVars: map[string]string{ EnvOAuthToken: "secret", }, expected: "yandex360: some credentials information are missing: YANDEX360_ORG_ID", }, { desc: "missing token", envVars: map[string]string{ EnvOrgID: "123456", }, expected: "yandex360: some credentials information are missing: YANDEX360_OAUTH_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string oauthToken string orgID int64 expected string }{ { desc: "success", oauthToken: "secret", orgID: 123456, }, { desc: "missing org ID", oauthToken: "secret", expected: "yandex360: orgID is required", }, { desc: "missing token", orgID: 123456, expected: "yandex360: OAuth token is required", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.OAuthToken = test.oauthToken config.OrgID = test.orgID p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/yandexcloud/yandexcloud.go ================================================ // Package yandexcloud implements a DNS provider for solving the DNS-01 challenge using Yandex Cloud. package yandexcloud import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "slices" "strings" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" ycdnsproto "github.com/yandex-cloud/go-genproto/yandex/cloud/dns/v1" ycdns "github.com/yandex-cloud/go-sdk/services/dns/v1" ycsdk "github.com/yandex-cloud/go-sdk/v2" "github.com/yandex-cloud/go-sdk/v2/credentials" "github.com/yandex-cloud/go-sdk/v2/pkg/iamkey" "github.com/yandex-cloud/go-sdk/v2/pkg/options" ) // Environment variables names. const ( envNamespace = "YANDEX_CLOUD_" EnvIamToken = envNamespace + "IAM_TOKEN" EnvFolderID = envNamespace + "FOLDER_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { IamToken string FolderID string PropagationTimeout time.Duration PollingInterval time.Duration TTL int } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client ycdns.DnsZoneClient config *Config } // NewDNSProvider returns a DNSProvider instance configured for Yandex Cloud. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvIamToken, EnvFolderID) if err != nil { return nil, fmt.Errorf("yandexcloud: %w", err) } config := NewDefaultConfig() config.IamToken = values[EnvIamToken] config.FolderID = values[EnvFolderID] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Yandex Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("yandexcloud: the configuration of the DNS provider is nil") } if config.IamToken == "" { return nil, errors.New("yandexcloud: some credentials information are missing IAM token") } if config.FolderID == "" { return nil, errors.New("yandexcloud: some credentials information are missing folder id") } creds, err := decodeCredentials(config.IamToken) if err != nil { return nil, fmt.Errorf("yandexcloud: iam token is malformed: %w", err) } sdk, err := ycsdk.Build(context.Background(), options.WithCredentials(creds)) if err != nil { return nil, errors.New("yandexcloud: unable to build yandex cloud sdk") } return &DNSProvider{ client: ycdns.NewDnsZoneClient(sdk), config: config, }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("yandexcloud: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() zones, err := d.getZones(ctx) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } var zoneID string for _, zone := range zones { if zone.GetZone() == authZone { zoneID = zone.GetId() } } if zoneID == "" { return fmt.Errorf("yandexcloud: cant find dns zone %s in yandex cloud", authZone) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } err = d.upsertRecordSetData(ctx, zoneID, subDomain, info.Value) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("yandexcloud: could not find zone for domain %q: %w", domain, err) } ctx := context.Background() zones, err := d.getZones(ctx) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } var zoneID string for _, zone := range zones { if zone.GetZone() == authZone { zoneID = zone.GetId() } } if zoneID == "" { return nil } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } err = d.removeRecordSetData(ctx, zoneID, subDomain, info.Value) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } return 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 } // getZones retrieves available zones from yandex cloud. func (d *DNSProvider) getZones(ctx context.Context) ([]*ycdnsproto.DnsZone, error) { list := &ycdnsproto.ListDnsZonesRequest{ FolderId: d.config.FolderID, } response, err := d.client.List(ctx, list) if err != nil { return nil, errors.New("unable to fetch dns zones") } return response.GetDnsZones(), nil } func (d *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error { get := &ycdnsproto.GetDnsZoneRecordSetRequest{ DnsZoneId: zoneID, Name: name, Type: "TXT", } exist, err := d.client.GetRecordSet(ctx, get) if err != nil { if !strings.Contains(err.Error(), "RecordSet not found") { return err } } record := &ycdnsproto.RecordSet{ Name: name, Type: "TXT", Ttl: int64(d.config.TTL), Data: []string{}, } var deletions []*ycdnsproto.RecordSet if exist != nil { record.SetData(append(record.GetData(), exist.GetData()...)) deletions = append(deletions, exist) } appended := appendRecordSetData(record, value) if !appended { // The value already present in RecordSet, nothing to do return nil } update := &ycdnsproto.UpdateRecordSetsRequest{ DnsZoneId: zoneID, Deletions: deletions, Additions: []*ycdnsproto.RecordSet{record}, } _, err = d.client.UpdateRecordSets(ctx, update) return err } func (d *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error { get := &ycdnsproto.GetDnsZoneRecordSetRequest{ DnsZoneId: zoneID, Name: name, Type: "TXT", } previousRecord, err := d.client.GetRecordSet(ctx, get) if err != nil { if strings.Contains(err.Error(), "RecordSet not found") { // RecordSet is not present, nothing to do return nil } return err } var additions []*ycdnsproto.RecordSet if len(previousRecord.GetData()) > 1 { // RecordSet is not empty we should update it record := &ycdnsproto.RecordSet{ Name: name, Type: "TXT", Ttl: int64(d.config.TTL), Data: []string{}, } for _, data := range previousRecord.GetData() { if data != value { record.SetData(append(record.GetData(), data)) } } additions = append(additions, record) } update := &ycdnsproto.UpdateRecordSetsRequest{ DnsZoneId: zoneID, Deletions: []*ycdnsproto.RecordSet{previousRecord}, Additions: additions, } _, err = d.client.UpdateRecordSets(ctx, update) return err } // decodeCredentials converts base64 encoded json of iam token to struct. func decodeCredentials(accountB64 string) (credentials.Credentials, error) { account, err := base64.StdEncoding.DecodeString(accountB64) if err != nil { return nil, err } key := &iamkey.Key{} err = json.Unmarshal(account, key) if err != nil { return nil, err } return credentials.ServiceAccountKey(key) } func appendRecordSetData(record *ycdnsproto.RecordSet, value string) bool { if slices.Contains(record.GetData(), value) { return false } record.SetData(append(record.GetData(), value)) return true } ================================================ FILE: providers/dns/yandexcloud/yandexcloud.toml ================================================ Name = "Yandex Cloud" Description = '''''' URL = "https://cloud.yandex.com" Code = "yandexcloud" Since = "v4.9.0" Example = ''' YANDEX_CLOUD_IAM_TOKEN= \ YANDEX_CLOUD_FOLDER_ID= \ lego --dns yandexcloud -d '*.example.com' -d example.com run # --- YANDEX_CLOUD_IAM_TOKEN=$(echo '{ \ "id": "", \ "service_account_id": "", \ "created_at": "", \ "key_algorithm": "RSA_2048", \ "public_key": "-----BEGIN PUBLIC KEY----------END PUBLIC KEY-----", \ "private_key": "-----BEGIN PRIVATE KEY----------END PRIVATE KEY-----" \ }' | base64) \ YANDEX_CLOUD_FOLDER_ID= \ lego --dns yandexcloud -d '*.example.com' -d example.com run ''' Additional = ''' ## IAM Token The simplest way to retrieve IAM access token is usage of yc-cli, follow [docs](https://cloud.yandex.ru/docs/iam/operations/iam-token/create-for-sa) to get it ```bash yc iam key create --service-account-name my-robot --output key.json cat key.json | base64 ``` ''' [Configuration] [Configuration.Credentials] YANDEX_CLOUD_IAM_TOKEN = "The base64 encoded json which contains information about iam token of service account with `dns.admin` permissions" YANDEX_CLOUD_FOLDER_ID = "The string id of folder (aka project) in Yandex Cloud" [Configuration.Additional] YANDEX_CLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" YANDEX_CLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" YANDEX_CLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 60)" [Links] API = "https://cloud.yandex.com/en/docs/dns/quickstart" ================================================ FILE: providers/dns/yandexcloud/yandexcloud_test.go ================================================ package yandexcloud import ( "encoding/base64" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" const fakeIAMToken = ` { "id": "abcdefghijklmnopqrst", "service_account_id": "abcdefghijklmnopqrst", "created_at": "2000-01-01T00:00:00.000000000Z", "key_algorithm": "RSA_2048", "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkVF2HjTx4v9rGof5OHGO\nGka+5XJc+px2KkzG0kG2H0ftal8n1LaY2rARmGp1T1/px80rR3amJ9mhnmB+jH5+\ntwxWr+qVwVnJrklBozgEtl6wXzB7zNqC3kV5rXZ4Omvn6daKuiczfgLL7N/yYQzk\nSKRYOCygBbPoxVGS50ZLVdCWWtz1iFbNmElnsM4KQjnxWBVRDwR2H5OIU84NonUz\nNcHDkVBX/d8pkSg7iB4NyD1AqvJtF1pS03NQm32n69bsfRsJxrqR6LK/aql379rk\nhgA7SyzMLJcLckKug+KfTCpktrwzi2AppUPD7keKJilOfhSrCGQglMr6Q3ao03SZ\ncQIDAQAB\n-----END PUBLIC KEY-----", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCRUXYeNPHi/2sa\nh/k4cY4aRr7lclz6nHYqTMbSQbYfR+1qXyfUtpjasBGYanVPX+nHzStHdqYn2aGe\nYH6Mfn63DFav6pXBWcmuSUGjOAS2XrBfMHvM2oLeRXmtdng6a+fp1oq6JzN+Asvs\n3/JhDORIpFg4LKAFs+jFUZLnRktV0JZa3PWIVs2YSWewzgpCOfFYFVEPBHYfk4hT\nzg2idTM1wcORUFf93ymRKDuIHg3IPUCq8m0XWlLTc1Cbfafr1ux9GwnGupHosr9q\nqXfv2uSGADtLLMwslwtyQq6D4p9MKmS2vDOLYCmlQ8PuR4omKU5+FKsIZCCUyvpD\ndqjTdJlxAgMBAAECggEAOzG7s8JNZfI1ZrFMy7k18W4wBLb5OPzTBZgQxUUPMt7R\nzyrDxto6mZpvEG8NKjAfwsvIfWvPcxwrwZ/87K36YAYeqbodFo3EocIlgp8nDEK2\nBZByXZgFBxW14vsHLoUWCyLhj8K4LvRkrTDsQqxFsXGAniFPbgNDJl18QclYlrOr\nnn9ZF7W0t2d0jnuzwB9k8L18RqRYWovCAjnFCS0tX5uQKtjSYD0JRG7CiKqd4ruv\ntJ1Go4bo+rRcaEbFgDyf8BEVa6t9VJX1MVjL2xm0toQUjtA+ZTuAAg4hCibEoru8\nYo55+R65HHI9B8nZxfp0kEVyzAhQWov91JbHzhRiAQKBgQDM8yuJ4tDAQ53RDmDF\nX5er2F9TeJo2ARiFB2C+4h9I88jC1LJ3Kgd161MO1mY3SVfNMHXZc0tpRDr+5xdn\nUNKuV8AS+O80Fan5eJX245bJiXr7Q73tV1PjVwJmXkMT+GaITqKsGyOZp1ms61Ed\nP/YaDfS7az1KeIGKWmkO5xDc2QKBgQC1g9G4wTrAaaZ8uXBkm982Oy47iMDy4IgW\na4mLyedhvBhOFNSGwNKfw6zBX+PPT1FKM9xJX1g1kbNNhH+W/y/Qx/uNz7QcsSvQ\nsUVRwPRmUarPsIuDGvqIj7kn7HjQgqJ/hTlmOXR3fTrvGZq8OYyhgF6BqowPFS/2\nxVYOLXsiWQKBgQCpmxdNzZlJcut4ZTiqPfiLas1Ai4664F9FP5zNet2/Bpf+u/xQ\n50QzTqJ2pfEDEbwKf28Xm/UtURytc9qHUnh3dQDr8nwqEz+Nxz/7h85yTEatBxt2\n/Yzbl1bSFnHWZfucE89FNFRaxQZONpLy7MqiNyhvrUiUh3NUZouInKn0yQKBgEAv\nGougGCxNr4dO80VAMM+2YYS/uKqpZrW21O5POLhAkL+bcgMsT84anQ3L4Hw/6di5\nOd3gDwryOFrizVMRbVEARh1BIsk6hOnIpWBhQIqluiayoMJ9WbXMTIangZkJeHhr\nHX7eNibCa4J8pVCFcQryn3huXBRBQ7KY2PMudeoRAoGBAJ1vdBQSuai3RIfyj8Yr\n4ArtCU1T5bicp13+mJODSeRhHMnlKkmI64vwrW5POFXWyJKPYLkuDk9bEYOyNBOA\nBTsUyaJp3jx/942oEwURc4Tb9az7CqEHaCrWHVHCj1CjCEX/FsRfd+wYyuGLwwly\nwdpqBWBl5iH74tRD6c+rguma\n-----END PRIVATE KEY-----" } ` var envTest = tester.NewEnvTest(EnvIamToken, EnvFolderID).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvIamToken: base64.StdEncoding.EncodeToString([]byte(fakeIAMToken)), EnvFolderID: "folder_id", }, }, { desc: "missing iam token", envVars: map[string]string{ EnvFolderID: "folder_id", }, expected: "yandexcloud: some credentials information are missing: YANDEX_CLOUD_IAM_TOKEN", }, { desc: "missing folder_id", envVars: map[string]string{ EnvIamToken: base64.StdEncoding.EncodeToString([]byte(fakeIAMToken)), }, expected: "yandexcloud: some credentials information are missing: YANDEX_CLOUD_FOLDER_ID", }, { desc: "malformed token (not base64)", envVars: map[string]string{ EnvIamToken: fakeIAMToken, EnvFolderID: "folder_id", }, expected: "yandexcloud: iam token is malformed: illegal base64 data at input byte 1", }, { desc: "malformed token (invalid json in bas64)", envVars: map[string]string{ EnvIamToken: "aW52YWxpZCBqc29u", EnvFolderID: "folder_id", }, expected: "yandexcloud: iam token is malformed: invalid character 'i' looking for beginning of value", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string config *Config expected string }{ { desc: "success", config: &Config{ IamToken: base64.StdEncoding.EncodeToString([]byte(fakeIAMToken)), FolderID: "folder_id", }, }, { desc: "nil config", config: nil, expected: "yandexcloud: the configuration of the DNS provider is nil", }, { desc: "missing token", config: &Config{ FolderID: "folder_id", }, expected: "yandexcloud: some credentials information are missing IAM token", }, { desc: "missing folder id", config: &Config{ IamToken: base64.StdEncoding.EncodeToString([]byte(fakeIAMToken)), }, expected: "yandexcloud: some credentials information are missing folder id", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { p, err := NewDNSProviderConfig(test.config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/zoneedit/internal/client.go ================================================ package internal import ( "bytes" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "slices" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const defaultBaseURL = "https://dynamic.zoneedit.com" // Client the ZoneEdit API client. type Client struct { user string authToken string baseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(user, authToken string) (*Client, error) { if user == "" || authToken == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ user: user, authToken: authToken, baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } func (c *Client) CreateTXTRecord(domain, rdata string) error { return c.perform("txt-create.php", domain, rdata) } func (c *Client) DeleteTXTRecord(domain, rdata string) error { return c.perform("txt-delete.php", domain, rdata) } func (c *Client) perform(actionPath, domain, rdata string) error { endpoint := c.baseURL.JoinPath(actionPath) query := endpoint.Query() query.Set("host", domain) query.Set("rdata", rdata) endpoint.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodGet, endpoint.String(), http.NoBody) if err != nil { return err } return c.do(req) } func (c *Client) do(req *http.Request) error { req.SetBasicAuth(c.user, c.authToken) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } if bytes.Contains(raw, []byte("SUCCESS CODE")) { return nil } raw = bytes.TrimSpace(raw) // The answer is not an XML valid (missing closing), so I fix it to parse it. if bytes.HasSuffix(raw, []byte(">")) { raw = slices.Concat(raw[:len(raw)-1], []byte("/>")) } var apiErr APIError err = xml.Unmarshal(raw, &apiErr) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code: %d] %w", resp.StatusCode, apiErr) } ================================================ FILE: providers/dns/zoneedit/internal/client_test.go ================================================ package internal import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder(func(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") if err != nil { return nil, err } client.baseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil }) } func TestClient_CreateTXTRecord(t *testing.T) { client := mockBuilder(). Route("GET /txt-create.php", servermock.ResponseFromFixture("success.xml")). Build(t) err := client.CreateTXTRecord("_acme-challenge.example.com", "value") require.NoError(t, err) } func TestClient_CreateTXTRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /txt-create.php", servermock.ResponseFromFixture("error.xml")). Build(t) err := client.CreateTXTRecord("_acme-challenge.example.com", "value") require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)") } func TestClient_DeleteTXTRecord(t *testing.T) { client := mockBuilder(). Route("GET /txt-delete.php", servermock.ResponseFromFixture("success.xml")). Build(t) err := client.DeleteTXTRecord("_acme-challenge.example.com", "value") require.NoError(t, err) } func TestClient_DeleteTXTRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /txt-delete.php", servermock.ResponseFromFixture("error.xml")). Build(t) err := client.DeleteTXTRecord("_acme-challenge.example.com", "value") require.EqualError(t, err, "[status code: 200] 708: Failed Login: user (_acme-challenge.example.com)") } ================================================ FILE: providers/dns/zoneedit/internal/fixtures/error.xml ================================================ ================================================ FILE: providers/dns/zoneedit/internal/fixtures/success.xml ================================================ ================================================ FILE: providers/dns/zoneedit/internal/types.go ================================================ package internal import ( "encoding/xml" "fmt" ) type APIError struct { XMLName xml.Name `xml:"ERROR"` Text string `xml:",chardata"` Code string `xml:"CODE,attr"` Message string `xml:"TEXT,attr"` Zone string `xml:"ZONE,attr"` } func (a APIError) Error() string { return fmt.Sprintf("%s: %s (%s)", a.Code, a.Message, a.Zone) } ================================================ FILE: providers/dns/zoneedit/zoneedit.go ================================================ // Package zoneedit implements a DNS provider for solving the DNS-01 challenge using ZoneEdit. package zoneedit import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/zoneedit/internal" ) // Environment variables names. const ( envNamespace = "ZONEEDIT_" EnvUser = envNamespace + "USER" EnAuthToken = envNamespace + "AUTH_TOKEN" 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 { User string AuthToken string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for ZoneEdit. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUser, EnAuthToken) if err != nil { return nil, fmt.Errorf("zoneedit: %w", err) } config := NewDefaultConfig() config.User = values[EnvUser] config.AuthToken = values[EnAuthToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for ZoneEdit. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("zoneedit: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.User, config.AuthToken) if err != nil { return nil, fmt.Errorf("zoneedit: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.CreateTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("zoneedit: create TXT record: %w", err) } // ERROR CODE="702" TEXT="Minimum 10 seconds between requests" time.Sleep(11 * time.Second) return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.DeleteTXTRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("zoneedit: delete TXT record: %w", err) } // ERROR CODE="702" TEXT="Minimum 10 seconds between requests" time.Sleep(11 * time.Second) return 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 } ================================================ FILE: providers/dns/zoneedit/zoneedit.toml ================================================ Name = "ZoneEdit" Description = '''''' URL = "https://www.zoneedit.com" Code = "zoneedit" Since = "v4.25.0" Example = ''' ZONEEDIT_USER="xxxxxxxxxxxxxxxxxxxxx" \ ZONEEDIT_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns zoneedit -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ZONEEDIT_USER = "User ID" ZONEEDIT_AUTH_TOKEN = "Authentication token" [Configuration.Additional] ZONEEDIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ZONEEDIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ZONEEDIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://support.zoneedit.com/en/knowledgebase/article/changes-to-dynamic-dns" ================================================ FILE: providers/dns/zoneedit/zoneedit_test.go ================================================ package zoneedit import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvUser, EnAuthToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvUser: "user", EnAuthToken: "secret", }, }, { desc: "missing user ID", envVars: map[string]string{ EnvUser: "", EnAuthToken: "secret", }, expected: "zoneedit: some credentials information are missing: ZONEEDIT_USER", }, { desc: "missing auth token", envVars: map[string]string{ EnvUser: "user", EnAuthToken: "", }, expected: "zoneedit: some credentials information are missing: ZONEEDIT_AUTH_TOKEN", }, { desc: "missing credentials", envVars: map[string]string{}, expected: "zoneedit: some credentials information are missing: ZONEEDIT_USER,ZONEEDIT_AUTH_TOKEN", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string user string authToken string expected string }{ { desc: "success", user: "user", authToken: "secret", }, { desc: "missing user ID", authToken: "secret", expected: "zoneedit: credentials missing", }, { desc: "missing auth token", user: "user", expected: "zoneedit: credentials missing", }, { desc: "missing credentials", expected: "zoneedit: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.User = test.user config.AuthToken = test.authToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/zoneee/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) // DefaultEndpoint the default API endpoint. const DefaultEndpoint = "https://api.zone.eu/v2/" // Client the API client for Zoneee. type Client struct { username string apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(username, apiKey string) *Client { baseURL, _ := url.Parse(DefaultEndpoint) return &Client{ username: username, apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } // GetTxtRecords get TXT records. // https://api.zone.eu/v2#operation/getdnstxtrecords func (c *Client) GetTxtRecords(ctx context.Context, domain string) ([]TXTRecord, error) { endpoint := c.BaseURL.JoinPath("dns", domain, "txt") req, err := newJSONRequest(ctx, http.MethodGet, endpoint, http.NoBody) if err != nil { return nil, err } var records []TXTRecord if err := c.do(req, &records); err != nil { return nil, err } return records, nil } // AddTxtRecord creates a TXT records. // https://api.zone.eu/v2#operation/creatednstxtrecord func (c *Client) AddTxtRecord(ctx context.Context, domain string, record TXTRecord) ([]TXTRecord, error) { endpoint := c.BaseURL.JoinPath("dns", domain, "txt") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } var records []TXTRecord if err := c.do(req, &records); err != nil { return nil, err } return records, nil } // RemoveTxtRecord deletes a TXT record. // https://api.zone.eu/v2#operation/deletednstxtrecord func (c *Client) RemoveTxtRecord(ctx context.Context, domain, id string) error { endpoint := c.BaseURL.JoinPath("dns", domain, "txt", id) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth(c.username, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } ================================================ FILE: providers/dns/zoneee/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("user", "secret"), ) } func TestClient_GetTxtRecords(t *testing.T) { client := mockBuilder(). Route("GET /dns/example.com/txt", servermock.ResponseFromFixture("get-txt-records.json")). Build(t) records, err := client.GetTxtRecords(t.Context(), "example.com") require.NoError(t, err) expected := []TXTRecord{ {ID: "123", Name: "prefix.example.com", Destination: "server.example.com", Delete: true, Modify: true, ResourceURL: "string"}, } assert.Equal(t, expected, records) } func TestClient_AddTxtRecord(t *testing.T) { client := mockBuilder(). Route("POST /dns/example.com/txt", servermock.ResponseFromFixture("create-txt-record.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"name":"prefix.example.com","destination":"server.example.com"}`)). Build(t) records, err := client.AddTxtRecord(t.Context(), "example.com", TXTRecord{Name: "prefix.example.com", Destination: "server.example.com"}) require.NoError(t, err) expected := []TXTRecord{ {ID: "123", Name: "prefix.example.com", Destination: "server.example.com", Delete: true, Modify: true, ResourceURL: "string"}, } assert.Equal(t, expected, records) } func TestClient_RemoveTxtRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /dns/example.com/txt/123", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) err := client.RemoveTxtRecord(t.Context(), "example.com", "123") require.NoError(t, err) } ================================================ FILE: providers/dns/zoneee/internal/fixtures/create-txt-record.json ================================================ [ { "resource_url": "string", "destination": "server.example.com", "id": "123", "name": "prefix.example.com", "delete": true, "modify": true } ] ================================================ FILE: providers/dns/zoneee/internal/fixtures/get-txt-records.json ================================================ [ { "resource_url": "string", "destination": "server.example.com", "id": "123", "name": "prefix.example.com", "delete": true, "modify": true } ] ================================================ FILE: providers/dns/zoneee/internal/types.go ================================================ package internal type TXTRecord struct { // Identifier (identificator) ID string `json:"id,omitempty"` // Hostname Name string `json:"name"` // TXT content value Destination string `json:"destination"` // Can this record be deleted Delete bool `json:"delete,omitempty"` // Can this record be modified Modify bool `json:"modify,omitempty"` // API url to get this entity ResourceURL string `json:"resource_url,omitempty"` } ================================================ FILE: providers/dns/zoneee/zoneee.go ================================================ // Package zoneee implements a DNS provider for solving the DNS-01 challenge through zone.ee. package zoneee import ( "context" "errors" "fmt" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/go-acme/lego/v4/providers/dns/zoneee/internal" ) // Environment variables names. const ( envNamespace = "ZONEEE_" EnvEndpoint = envNamespace + "ENDPOINT" EnvAPIUser = envNamespace + "API_USER" EnvAPIKey = envNamespace + "API_KEY" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Endpoint *url.URL Username string APIKey string PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { endpoint, _ := url.Parse(internal.DefaultEndpoint) return &Config{ Endpoint: endpoint, // zone.ee can take up to 5min to propagate according to the support PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIUser, EnvAPIKey) if err != nil { return nil, fmt.Errorf("zoneee: %w", err) } rawEndpoint := env.GetOrDefaultString(EnvEndpoint, internal.DefaultEndpoint) endpoint, err := url.Parse(rawEndpoint) if err != nil { return nil, fmt.Errorf("zoneee: %w", err) } config := NewDefaultConfig() config.Username = values[EnvAPIUser] config.APIKey = values[EnvAPIKey] config.Endpoint = endpoint return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Zone.ee. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("zoneee: the configuration of the DNS provider is nil") } if config.Username == "" { return nil, errors.New("zoneee: credentials missing: username") } if config.APIKey == "" { return nil, errors.New("zoneee: credentials missing: API key") } if config.Endpoint == nil { return nil, errors.New("zoneee: the endpoint is missing") } client := internal.NewClient(config.Username, config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) if config.Endpoint != nil { client.BaseURL = config.Endpoint } return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("zoneee: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) record := internal.TXTRecord{ Name: dns01.UnFqdn(info.EffectiveFQDN), Destination: info.Value, } _, err = d.client.AddTxtRecord(context.Background(), authZone, record) if err != nil { return fmt.Errorf("zoneee: %w", err) } return nil } // CleanUp removes the TXT record previously created. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("zoneee: could not find zone for domain %q: %w", domain, err) } authZone = dns01.UnFqdn(authZone) ctx := context.Background() records, err := d.client.GetTxtRecords(ctx, authZone) if err != nil { return fmt.Errorf("zoneee: %w", err) } var id string for _, record := range records { if record.Destination == info.Value { id = record.ID } } if id == "" { return fmt.Errorf("zoneee: txt record does not exist for %s", info.Value) } if err = d.client.RemoveTxtRecord(ctx, authZone, id); err != nil { return fmt.Errorf("zoneee: %w", err) } return nil } ================================================ FILE: providers/dns/zoneee/zoneee.toml ================================================ Name = "Zone.ee" Description = '''''' URL = "https://www.zone.ee/" Code = "zoneee" Since = "v2.1.0" Example = ''' ZONEEE_API_USER=xxxxx \ ZONEEE_API_KEY=yyyyy \ lego --dns zoneee -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ZONEEE_API_USER = "API user" ZONEEE_API_KEY = "API key" [Configuration.Additional] ZONEEE_ENDPOINT = "API endpoint URL" ZONEEE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" ZONEEE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" ZONEEE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://api.zone.eu/v2" ================================================ FILE: providers/dns/zoneee/zoneee_test.go ================================================ package zoneee import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/zoneee/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" const ( fakeUsername = "user" fakeAPIKey = "secret" ) var envTest = tester.NewEnvTest(EnvEndpoint, EnvAPIUser, EnvAPIKey). WithLiveTestRequirements(EnvAPIUser, EnvAPIKey). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIUser: "123", EnvAPIKey: "456", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAPIUser: "", EnvAPIKey: "", }, expected: "zoneee: some credentials information are missing: ZONEEE_API_USER,ZONEEE_API_KEY", }, { desc: "missing username", envVars: map[string]string{ EnvAPIUser: "", EnvAPIKey: "456", }, expected: "zoneee: some credentials information are missing: ZONEEE_API_USER", }, { desc: "missing API key", envVars: map[string]string{ EnvAPIUser: "123", EnvAPIKey: "", }, expected: "zoneee: some credentials information are missing: ZONEEE_API_KEY", }, { desc: "invalid URL", envVars: map[string]string{ EnvAPIUser: "123", EnvAPIKey: "456", EnvEndpoint: ":", }, expected: `zoneee: parse ":": missing protocol scheme`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiUser string apiKey string expected string }{ { desc: "success", apiKey: "123", apiUser: "456", }, { desc: "missing credentials", expected: "zoneee: credentials missing: username", }, { desc: "missing api key", apiUser: "456", expected: "zoneee: credentials missing: API key", }, { desc: "missing username", apiKey: "123", expected: "zoneee: credentials missing: username", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey config.Username = test.apiUser p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { hostedZone := "example.com" domain := "prefix." + hostedZone testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "success", builder: mockBuilder(fakeUsername, fakeAPIKey). Route("POST /dns/"+hostedZone+"/txt", mockHandlerCreateRecord()), }, { desc: "invalid auth", builder: mockBuilder("nope", "nope"). Route("POST /dns/"+hostedZone+"/txt", nil), expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized", }, { desc: "error", builder: mockBuilder(fakeUsername, fakeAPIKey), expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { provider := test.builder.Build(t) err := provider.Present(domain, "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { require.EqualError(t, err, test.expectedError) } }) } } func TestDNSProvider_Cleanup(t *testing.T) { hostedZone := "example.com" domain := "prefix." + hostedZone testCases := []struct { desc string builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "success", builder: mockBuilder(fakeUsername, fakeAPIKey). Route("GET /dns/"+hostedZone+"/txt", mockHandlerGetRecords([]internal.TXTRecord{{ ID: "1234", Name: domain, Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", Delete: true, Modify: true, }})). Route("DELETE /dns/"+hostedZone+"/txt/1234", servermock.Noop(). WithStatusCode(http.StatusNoContent)), }, { desc: "no txt records", builder: mockBuilder(fakeUsername, fakeAPIKey). Route("GET /dns/"+hostedZone+"/txt", mockHandlerGetRecords([]internal.TXTRecord{})), expectedError: "zoneee: txt record does not exist for LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", }, { desc: "invalid auth", builder: mockBuilder("nope", "nope"). Route("GET /dns/"+hostedZone+"/txt", nil), expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized", }, { desc: "error", builder: mockBuilder(fakeUsername, fakeAPIKey), expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { provider := test.builder.Build(t) err := provider.CleanUp(domain, "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { require.EqualError(t, err, test.expectedError) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(2 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder(username, apiKey string) *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.HTTPClient = server.Client() config.Endpoint, _ = url.Parse(server.URL) config.Username = username config.APIKey = apiKey return NewDNSProviderConfig(config) }, checkBasicAuth()) } func mockHandlerCreateRecord() http.HandlerFunc { return encodeJSONHandler(func(req *http.Request, rw http.ResponseWriter) (any, error) { record := internal.TXTRecord{} err := json.NewDecoder(req.Body).Decode(&record) if err != nil { return nil, err } record.ID = "1234" record.Delete = true record.Modify = true record.ResourceURL = req.URL.String() + "/1234" return []internal.TXTRecord{record}, nil }) } func mockHandlerGetRecords(records []internal.TXTRecord) http.HandlerFunc { return encodeJSONHandler(func(req *http.Request, rw http.ResponseWriter) (any, error) { for _, record := range records { if record.ResourceURL == "" { record.ResourceURL = req.URL.String() + "/" + record.ID } } return records, nil }) } func encodeJSONHandler(build func(req *http.Request, rw http.ResponseWriter) (any, error)) http.HandlerFunc { return func(rw http.ResponseWriter, req *http.Request) { data, err := build(req, rw) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } bytes, err := json.Marshal(data) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } if _, err = rw.Write(bytes); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } } } func checkBasicAuth() servermock.LinkFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { username, apiKey, ok := req.BasicAuth() if username != fakeUsername || apiKey != fakeAPIKey || !ok { rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } next.ServeHTTP(rw, req) }) } } ================================================ FILE: providers/dns/zonomi/zonomi.go ================================================ // Package zonomi implements a DNS provider for solving the DNS-01 challenge using Zonomi DNS. package zonomi import ( "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/rimuhosting" ) // Environment variables names. const ( envNamespace = "ZONOMI_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const defaultBaseURL = "https://zonomi.com/app/dns/dyndns.jsp" var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config = rimuhosting.Config // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, rimuhosting.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 { prv challenge.ProviderTimeout } // NewDNSProvider returns a DNSProvider instance configured for Zonomi. // Credentials must be passed in the environment variables. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("zonomi: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Zonomi. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("zonomi: the configuration of the DNS provider is nil") } provider, err := rimuhosting.NewDNSProviderConfig(config, defaultBaseURL) if err != nil { return nil, fmt.Errorf("zonomi: %w", err) } return &DNSProvider{prv: provider}, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.prv.Present(domain, token, keyAuth) if err != nil { return fmt.Errorf("zonomi: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.prv.CleanUp(domain, token, keyAuth) if err != nil { return fmt.Errorf("zonomi: %w", err) } return 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.prv.Timeout() } ================================================ FILE: providers/dns/zonomi/zonomi.toml ================================================ Name = "Zonomi" Description = '''''' URL = "https://zonomi.com" Code = "zonomi" Since = "v3.5.0" Example = ''' ZONOMI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns zonomi -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ZONOMI_API_KEY = "User API key" [Configuration.Additional] ZONOMI_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ZONOMI_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ZONOMI_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" ZONOMI_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://zonomi.com/app/dns/dyndns.jsp" ================================================ FILE: providers/dns/zonomi/zonomi_test.go ================================================ package zonomi import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "123", }, }, { desc: "missing api key", envVars: map[string]string{ EnvAPIKey: "", }, expected: "zonomi: some credentials information are missing: ZONOMI_API_KEY", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(test.envVars) p, err := NewDNSProvider() if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string expected string apiKey string secretKey string }{ { desc: "success", apiKey: "api_key", secretKey: "api_secret", }, { desc: "missing api key", apiKey: "", secretKey: "api_secret", expected: "zonomi: incomplete credentials, missing API key", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.prv) } else { require.EqualError(t, err, test.expected) } }) } } func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) err = provider.Present(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func TestLiveCleanUp(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() provider, err := NewDNSProvider() require.NoError(t, err) time.Sleep(1 * time.Second) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/zz_gen_dns_providers.go ================================================ // Code generated by 'make generate-dns'; DO NOT EDIT. package dns import ( "fmt" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/providers/dns/acmedns" "github.com/go-acme/lego/v4/providers/dns/active24" "github.com/go-acme/lego/v4/providers/dns/alidns" "github.com/go-acme/lego/v4/providers/dns/aliesa" "github.com/go-acme/lego/v4/providers/dns/allinkl" "github.com/go-acme/lego/v4/providers/dns/alwaysdata" "github.com/go-acme/lego/v4/providers/dns/anexia" "github.com/go-acme/lego/v4/providers/dns/artfiles" "github.com/go-acme/lego/v4/providers/dns/arvancloud" "github.com/go-acme/lego/v4/providers/dns/auroradns" "github.com/go-acme/lego/v4/providers/dns/autodns" "github.com/go-acme/lego/v4/providers/dns/axelname" "github.com/go-acme/lego/v4/providers/dns/azion" "github.com/go-acme/lego/v4/providers/dns/azure" "github.com/go-acme/lego/v4/providers/dns/azuredns" "github.com/go-acme/lego/v4/providers/dns/baiducloud" "github.com/go-acme/lego/v4/providers/dns/beget" "github.com/go-acme/lego/v4/providers/dns/binarylane" "github.com/go-acme/lego/v4/providers/dns/bindman" "github.com/go-acme/lego/v4/providers/dns/bluecat" "github.com/go-acme/lego/v4/providers/dns/bluecatv2" "github.com/go-acme/lego/v4/providers/dns/bookmyname" "github.com/go-acme/lego/v4/providers/dns/brandit" "github.com/go-acme/lego/v4/providers/dns/bunny" "github.com/go-acme/lego/v4/providers/dns/checkdomain" "github.com/go-acme/lego/v4/providers/dns/civo" "github.com/go-acme/lego/v4/providers/dns/clouddns" "github.com/go-acme/lego/v4/providers/dns/cloudflare" "github.com/go-acme/lego/v4/providers/dns/cloudns" "github.com/go-acme/lego/v4/providers/dns/cloudru" "github.com/go-acme/lego/v4/providers/dns/cloudxns" "github.com/go-acme/lego/v4/providers/dns/com35" "github.com/go-acme/lego/v4/providers/dns/conoha" "github.com/go-acme/lego/v4/providers/dns/conohav3" "github.com/go-acme/lego/v4/providers/dns/constellix" "github.com/go-acme/lego/v4/providers/dns/corenetworks" "github.com/go-acme/lego/v4/providers/dns/cpanel" "github.com/go-acme/lego/v4/providers/dns/czechia" "github.com/go-acme/lego/v4/providers/dns/ddnss" "github.com/go-acme/lego/v4/providers/dns/derak" "github.com/go-acme/lego/v4/providers/dns/desec" "github.com/go-acme/lego/v4/providers/dns/designate" "github.com/go-acme/lego/v4/providers/dns/digitalocean" "github.com/go-acme/lego/v4/providers/dns/directadmin" "github.com/go-acme/lego/v4/providers/dns/dnsexit" "github.com/go-acme/lego/v4/providers/dns/dnshomede" "github.com/go-acme/lego/v4/providers/dns/dnsimple" "github.com/go-acme/lego/v4/providers/dns/dnsmadeeasy" "github.com/go-acme/lego/v4/providers/dns/dnspod" "github.com/go-acme/lego/v4/providers/dns/dode" "github.com/go-acme/lego/v4/providers/dns/domeneshop" "github.com/go-acme/lego/v4/providers/dns/dreamhost" "github.com/go-acme/lego/v4/providers/dns/duckdns" "github.com/go-acme/lego/v4/providers/dns/dyn" "github.com/go-acme/lego/v4/providers/dns/dyndnsfree" "github.com/go-acme/lego/v4/providers/dns/dynu" "github.com/go-acme/lego/v4/providers/dns/easydns" "github.com/go-acme/lego/v4/providers/dns/edgecenter" "github.com/go-acme/lego/v4/providers/dns/edgedns" "github.com/go-acme/lego/v4/providers/dns/edgeone" "github.com/go-acme/lego/v4/providers/dns/efficientip" "github.com/go-acme/lego/v4/providers/dns/epik" "github.com/go-acme/lego/v4/providers/dns/eurodns" "github.com/go-acme/lego/v4/providers/dns/excedo" "github.com/go-acme/lego/v4/providers/dns/exec" "github.com/go-acme/lego/v4/providers/dns/exoscale" "github.com/go-acme/lego/v4/providers/dns/f5xc" "github.com/go-acme/lego/v4/providers/dns/freemyip" "github.com/go-acme/lego/v4/providers/dns/gandi" "github.com/go-acme/lego/v4/providers/dns/gandiv5" "github.com/go-acme/lego/v4/providers/dns/gcloud" "github.com/go-acme/lego/v4/providers/dns/gcore" "github.com/go-acme/lego/v4/providers/dns/gigahostno" "github.com/go-acme/lego/v4/providers/dns/glesys" "github.com/go-acme/lego/v4/providers/dns/godaddy" "github.com/go-acme/lego/v4/providers/dns/googledomains" "github.com/go-acme/lego/v4/providers/dns/gravity" "github.com/go-acme/lego/v4/providers/dns/hetzner" "github.com/go-acme/lego/v4/providers/dns/hostingde" "github.com/go-acme/lego/v4/providers/dns/hostinger" "github.com/go-acme/lego/v4/providers/dns/hostingnl" "github.com/go-acme/lego/v4/providers/dns/hosttech" "github.com/go-acme/lego/v4/providers/dns/httpnet" "github.com/go-acme/lego/v4/providers/dns/httpreq" "github.com/go-acme/lego/v4/providers/dns/huaweicloud" "github.com/go-acme/lego/v4/providers/dns/hurricane" "github.com/go-acme/lego/v4/providers/dns/hyperone" "github.com/go-acme/lego/v4/providers/dns/ibmcloud" "github.com/go-acme/lego/v4/providers/dns/iij" "github.com/go-acme/lego/v4/providers/dns/iijdpf" "github.com/go-acme/lego/v4/providers/dns/infoblox" "github.com/go-acme/lego/v4/providers/dns/infomaniak" "github.com/go-acme/lego/v4/providers/dns/internetbs" "github.com/go-acme/lego/v4/providers/dns/inwx" "github.com/go-acme/lego/v4/providers/dns/ionos" "github.com/go-acme/lego/v4/providers/dns/ionoscloud" "github.com/go-acme/lego/v4/providers/dns/ipv64" "github.com/go-acme/lego/v4/providers/dns/ispconfig" "github.com/go-acme/lego/v4/providers/dns/ispconfigddns" "github.com/go-acme/lego/v4/providers/dns/iwantmyname" "github.com/go-acme/lego/v4/providers/dns/jdcloud" "github.com/go-acme/lego/v4/providers/dns/joker" "github.com/go-acme/lego/v4/providers/dns/keyhelp" "github.com/go-acme/lego/v4/providers/dns/leaseweb" "github.com/go-acme/lego/v4/providers/dns/liara" "github.com/go-acme/lego/v4/providers/dns/lightsail" "github.com/go-acme/lego/v4/providers/dns/limacity" "github.com/go-acme/lego/v4/providers/dns/linode" "github.com/go-acme/lego/v4/providers/dns/liquidweb" "github.com/go-acme/lego/v4/providers/dns/loopia" "github.com/go-acme/lego/v4/providers/dns/luadns" "github.com/go-acme/lego/v4/providers/dns/mailinabox" "github.com/go-acme/lego/v4/providers/dns/manageengine" "github.com/go-acme/lego/v4/providers/dns/manual" "github.com/go-acme/lego/v4/providers/dns/metaname" "github.com/go-acme/lego/v4/providers/dns/metaregistrar" "github.com/go-acme/lego/v4/providers/dns/mijnhost" "github.com/go-acme/lego/v4/providers/dns/mittwald" "github.com/go-acme/lego/v4/providers/dns/myaddr" "github.com/go-acme/lego/v4/providers/dns/mydnsjp" "github.com/go-acme/lego/v4/providers/dns/mythicbeasts" "github.com/go-acme/lego/v4/providers/dns/namecheap" "github.com/go-acme/lego/v4/providers/dns/namedotcom" "github.com/go-acme/lego/v4/providers/dns/namesilo" "github.com/go-acme/lego/v4/providers/dns/namesurfer" "github.com/go-acme/lego/v4/providers/dns/nearlyfreespeech" "github.com/go-acme/lego/v4/providers/dns/neodigit" "github.com/go-acme/lego/v4/providers/dns/netcup" "github.com/go-acme/lego/v4/providers/dns/netlify" "github.com/go-acme/lego/v4/providers/dns/nicmanager" "github.com/go-acme/lego/v4/providers/dns/nicru" "github.com/go-acme/lego/v4/providers/dns/nifcloud" "github.com/go-acme/lego/v4/providers/dns/njalla" "github.com/go-acme/lego/v4/providers/dns/nodion" "github.com/go-acme/lego/v4/providers/dns/ns1" "github.com/go-acme/lego/v4/providers/dns/octenium" "github.com/go-acme/lego/v4/providers/dns/oraclecloud" "github.com/go-acme/lego/v4/providers/dns/otc" "github.com/go-acme/lego/v4/providers/dns/ovh" "github.com/go-acme/lego/v4/providers/dns/pdns" "github.com/go-acme/lego/v4/providers/dns/plesk" "github.com/go-acme/lego/v4/providers/dns/porkbun" "github.com/go-acme/lego/v4/providers/dns/rackspace" "github.com/go-acme/lego/v4/providers/dns/rainyun" "github.com/go-acme/lego/v4/providers/dns/rcodezero" "github.com/go-acme/lego/v4/providers/dns/regfish" "github.com/go-acme/lego/v4/providers/dns/regru" "github.com/go-acme/lego/v4/providers/dns/rfc2136" "github.com/go-acme/lego/v4/providers/dns/rimuhosting" "github.com/go-acme/lego/v4/providers/dns/route53" "github.com/go-acme/lego/v4/providers/dns/safedns" "github.com/go-acme/lego/v4/providers/dns/sakuracloud" "github.com/go-acme/lego/v4/providers/dns/scaleway" "github.com/go-acme/lego/v4/providers/dns/selectel" "github.com/go-acme/lego/v4/providers/dns/selectelv2" "github.com/go-acme/lego/v4/providers/dns/selfhostde" "github.com/go-acme/lego/v4/providers/dns/servercow" "github.com/go-acme/lego/v4/providers/dns/shellrent" "github.com/go-acme/lego/v4/providers/dns/simply" "github.com/go-acme/lego/v4/providers/dns/sonic" "github.com/go-acme/lego/v4/providers/dns/spaceship" "github.com/go-acme/lego/v4/providers/dns/stackpath" "github.com/go-acme/lego/v4/providers/dns/syse" "github.com/go-acme/lego/v4/providers/dns/technitium" "github.com/go-acme/lego/v4/providers/dns/tencentcloud" "github.com/go-acme/lego/v4/providers/dns/timewebcloud" "github.com/go-acme/lego/v4/providers/dns/todaynic" "github.com/go-acme/lego/v4/providers/dns/transip" "github.com/go-acme/lego/v4/providers/dns/ultradns" "github.com/go-acme/lego/v4/providers/dns/uniteddomains" "github.com/go-acme/lego/v4/providers/dns/variomedia" "github.com/go-acme/lego/v4/providers/dns/vegadns" "github.com/go-acme/lego/v4/providers/dns/vercel" "github.com/go-acme/lego/v4/providers/dns/versio" "github.com/go-acme/lego/v4/providers/dns/vinyldns" "github.com/go-acme/lego/v4/providers/dns/virtualname" "github.com/go-acme/lego/v4/providers/dns/vkcloud" "github.com/go-acme/lego/v4/providers/dns/volcengine" "github.com/go-acme/lego/v4/providers/dns/vscale" "github.com/go-acme/lego/v4/providers/dns/vultr" "github.com/go-acme/lego/v4/providers/dns/webnames" "github.com/go-acme/lego/v4/providers/dns/webnamesca" "github.com/go-acme/lego/v4/providers/dns/websupport" "github.com/go-acme/lego/v4/providers/dns/wedos" "github.com/go-acme/lego/v4/providers/dns/westcn" "github.com/go-acme/lego/v4/providers/dns/yandex" "github.com/go-acme/lego/v4/providers/dns/yandex360" "github.com/go-acme/lego/v4/providers/dns/yandexcloud" "github.com/go-acme/lego/v4/providers/dns/zoneedit" "github.com/go-acme/lego/v4/providers/dns/zoneee" "github.com/go-acme/lego/v4/providers/dns/zonomi" ) // NewDNSChallengeProviderByName Factory for DNS providers. func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { switch name { case "acme-dns", "acmedns": return acmedns.NewDNSProvider() case "active24": return active24.NewDNSProvider() case "alidns": return alidns.NewDNSProvider() case "aliesa": return aliesa.NewDNSProvider() case "allinkl": return allinkl.NewDNSProvider() case "alwaysdata": return alwaysdata.NewDNSProvider() case "anexia": return anexia.NewDNSProvider() case "artfiles": return artfiles.NewDNSProvider() case "arvancloud": return arvancloud.NewDNSProvider() case "auroradns": return auroradns.NewDNSProvider() case "autodns": return autodns.NewDNSProvider() case "axelname": return axelname.NewDNSProvider() case "azion": return azion.NewDNSProvider() case "azure": return azure.NewDNSProvider() case "azuredns": return azuredns.NewDNSProvider() case "baiducloud": return baiducloud.NewDNSProvider() case "beget": return beget.NewDNSProvider() case "binarylane": return binarylane.NewDNSProvider() case "bindman": return bindman.NewDNSProvider() case "bluecat": return bluecat.NewDNSProvider() case "bluecatv2": return bluecatv2.NewDNSProvider() case "bookmyname": return bookmyname.NewDNSProvider() case "brandit": return brandit.NewDNSProvider() case "bunny": return bunny.NewDNSProvider() case "checkdomain": return checkdomain.NewDNSProvider() case "civo": return civo.NewDNSProvider() case "clouddns": return clouddns.NewDNSProvider() case "cloudflare": return cloudflare.NewDNSProvider() case "cloudns": return cloudns.NewDNSProvider() case "cloudru": return cloudru.NewDNSProvider() case "cloudxns": return cloudxns.NewDNSProvider() case "com35": return com35.NewDNSProvider() case "conoha": return conoha.NewDNSProvider() case "conohav3": return conohav3.NewDNSProvider() case "constellix": return constellix.NewDNSProvider() case "corenetworks": return corenetworks.NewDNSProvider() case "cpanel": return cpanel.NewDNSProvider() case "czechia": return czechia.NewDNSProvider() case "ddnss": return ddnss.NewDNSProvider() case "derak": return derak.NewDNSProvider() case "desec": return desec.NewDNSProvider() case "designate": return designate.NewDNSProvider() case "digitalocean": return digitalocean.NewDNSProvider() case "directadmin": return directadmin.NewDNSProvider() case "dnsexit": return dnsexit.NewDNSProvider() case "dnshomede": return dnshomede.NewDNSProvider() case "dnsimple": return dnsimple.NewDNSProvider() case "dnsmadeeasy": return dnsmadeeasy.NewDNSProvider() case "dnspod": return dnspod.NewDNSProvider() case "dode": return dode.NewDNSProvider() case "domeneshop", "domainnameshop": return domeneshop.NewDNSProvider() case "dreamhost": return dreamhost.NewDNSProvider() case "duckdns": return duckdns.NewDNSProvider() case "dyn": return dyn.NewDNSProvider() case "dyndnsfree": return dyndnsfree.NewDNSProvider() case "dynu": return dynu.NewDNSProvider() case "easydns": return easydns.NewDNSProvider() case "edgecenter": return edgecenter.NewDNSProvider() case "edgedns", "fastdns": return edgedns.NewDNSProvider() case "edgeone": return edgeone.NewDNSProvider() case "efficientip": return efficientip.NewDNSProvider() case "epik": return epik.NewDNSProvider() case "eurodns": return eurodns.NewDNSProvider() case "excedo": return excedo.NewDNSProvider() case "exec": return exec.NewDNSProvider() case "exoscale": return exoscale.NewDNSProvider() case "f5xc": return f5xc.NewDNSProvider() case "freemyip": return freemyip.NewDNSProvider() case "gandi": return gandi.NewDNSProvider() case "gandiv5": return gandiv5.NewDNSProvider() case "gcloud": return gcloud.NewDNSProvider() case "gcore": return gcore.NewDNSProvider() case "gigahostno": return gigahostno.NewDNSProvider() case "glesys": return glesys.NewDNSProvider() case "godaddy": return godaddy.NewDNSProvider() case "googledomains": return googledomains.NewDNSProvider() case "gravity": return gravity.NewDNSProvider() case "hetzner": return hetzner.NewDNSProvider() case "hostingde": return hostingde.NewDNSProvider() case "hostinger": return hostinger.NewDNSProvider() case "hostingnl": return hostingnl.NewDNSProvider() case "hosttech": return hosttech.NewDNSProvider() case "httpnet": return httpnet.NewDNSProvider() case "httpreq": return httpreq.NewDNSProvider() case "huaweicloud": return huaweicloud.NewDNSProvider() case "hurricane": return hurricane.NewDNSProvider() case "hyperone": return hyperone.NewDNSProvider() case "ibmcloud": return ibmcloud.NewDNSProvider() case "iij": return iij.NewDNSProvider() case "iijdpf": return iijdpf.NewDNSProvider() case "infoblox": return infoblox.NewDNSProvider() case "infomaniak": return infomaniak.NewDNSProvider() case "internetbs": return internetbs.NewDNSProvider() case "inwx": return inwx.NewDNSProvider() case "ionos": return ionos.NewDNSProvider() case "ionoscloud": return ionoscloud.NewDNSProvider() case "ipv64": return ipv64.NewDNSProvider() case "ispconfig": return ispconfig.NewDNSProvider() case "ispconfigddns": return ispconfigddns.NewDNSProvider() case "iwantmyname": return iwantmyname.NewDNSProvider() case "jdcloud": return jdcloud.NewDNSProvider() case "joker": return joker.NewDNSProvider() case "keyhelp": return keyhelp.NewDNSProvider() case "leaseweb": return leaseweb.NewDNSProvider() case "liara": return liara.NewDNSProvider() case "lightsail": return lightsail.NewDNSProvider() case "limacity": return limacity.NewDNSProvider() case "linode", "linodev4": return linode.NewDNSProvider() case "liquidweb": return liquidweb.NewDNSProvider() case "loopia": return loopia.NewDNSProvider() case "luadns": return luadns.NewDNSProvider() case "mailinabox": return mailinabox.NewDNSProvider() case "manageengine": return manageengine.NewDNSProvider() case "manual": return manual.NewDNSProvider() case "metaname": return metaname.NewDNSProvider() case "metaregistrar": return metaregistrar.NewDNSProvider() case "mijnhost": return mijnhost.NewDNSProvider() case "mittwald": return mittwald.NewDNSProvider() case "myaddr": return myaddr.NewDNSProvider() case "mydnsjp": return mydnsjp.NewDNSProvider() case "mythicbeasts": return mythicbeasts.NewDNSProvider() case "namecheap": return namecheap.NewDNSProvider() case "namedotcom": return namedotcom.NewDNSProvider() case "namesilo": return namesilo.NewDNSProvider() case "namesurfer": return namesurfer.NewDNSProvider() case "nearlyfreespeech": return nearlyfreespeech.NewDNSProvider() case "neodigit": return neodigit.NewDNSProvider() case "netcup": return netcup.NewDNSProvider() case "netlify": return netlify.NewDNSProvider() case "nicmanager": return nicmanager.NewDNSProvider() case "nicru": return nicru.NewDNSProvider() case "nifcloud": return nifcloud.NewDNSProvider() case "njalla": return njalla.NewDNSProvider() case "nodion": return nodion.NewDNSProvider() case "ns1": return ns1.NewDNSProvider() case "octenium": return octenium.NewDNSProvider() case "oraclecloud": return oraclecloud.NewDNSProvider() case "otc": return otc.NewDNSProvider() case "ovh": return ovh.NewDNSProvider() case "pdns": return pdns.NewDNSProvider() case "plesk": return plesk.NewDNSProvider() case "porkbun": return porkbun.NewDNSProvider() case "rackspace": return rackspace.NewDNSProvider() case "rainyun": return rainyun.NewDNSProvider() case "rcodezero": return rcodezero.NewDNSProvider() case "regfish": return regfish.NewDNSProvider() case "regru": return regru.NewDNSProvider() case "rfc2136": return rfc2136.NewDNSProvider() case "rimuhosting": return rimuhosting.NewDNSProvider() case "route53": return route53.NewDNSProvider() case "safedns": return safedns.NewDNSProvider() case "sakuracloud": return sakuracloud.NewDNSProvider() case "scaleway": return scaleway.NewDNSProvider() case "selectel": return selectel.NewDNSProvider() case "selectelv2": return selectelv2.NewDNSProvider() case "selfhostde": return selfhostde.NewDNSProvider() case "servercow": return servercow.NewDNSProvider() case "shellrent": return shellrent.NewDNSProvider() case "simply": return simply.NewDNSProvider() case "sonic": return sonic.NewDNSProvider() case "spaceship": return spaceship.NewDNSProvider() case "stackpath": return stackpath.NewDNSProvider() case "syse": return syse.NewDNSProvider() case "technitium": return technitium.NewDNSProvider() case "tencentcloud": return tencentcloud.NewDNSProvider() case "timewebcloud": return timewebcloud.NewDNSProvider() case "todaynic": return todaynic.NewDNSProvider() case "transip": return transip.NewDNSProvider() case "ultradns": return ultradns.NewDNSProvider() case "uniteddomains": return uniteddomains.NewDNSProvider() case "variomedia": return variomedia.NewDNSProvider() case "vegadns": return vegadns.NewDNSProvider() case "vercel": return vercel.NewDNSProvider() case "versio": return versio.NewDNSProvider() case "vinyldns": return vinyldns.NewDNSProvider() case "virtualname": return virtualname.NewDNSProvider() case "vkcloud": return vkcloud.NewDNSProvider() case "volcengine": return volcengine.NewDNSProvider() case "vscale": return vscale.NewDNSProvider() case "vultr": return vultr.NewDNSProvider() case "webnames", "webnamesru": return webnames.NewDNSProvider() case "webnamesca": return webnamesca.NewDNSProvider() case "websupport": return websupport.NewDNSProvider() case "wedos": return wedos.NewDNSProvider() case "westcn": return westcn.NewDNSProvider() case "yandex": return yandex.NewDNSProvider() case "yandex360": return yandex360.NewDNSProvider() case "yandexcloud": return yandexcloud.NewDNSProvider() case "zoneedit": return zoneedit.NewDNSProvider() case "zoneee": return zoneee.NewDNSProvider() case "zonomi": return zonomi.NewDNSProvider() default: return nil, fmt.Errorf("unrecognized DNS provider: %s", name) } } ================================================ FILE: providers/http/memcached/README.md ================================================ # Memcached http provider Publishes challenges into memcached where they can be retrieved by nginx. Allows specifying multiple memcached servers and the responses will be published to all of them, making it easier to verify when your domain is hosted on a cluster of servers. Example nginx config: ``` location /.well-known/acme-challenge/ { set $memcached_key "$uri"; memcached_pass 127.0.0.1:11211; } ``` ================================================ FILE: providers/http/memcached/memcached.go ================================================ // Package memcached implements an HTTP provider for solving the HTTP-01 challenge using memcached in combination with a webserver. package memcached import ( "errors" "fmt" "path" "github.com/go-acme/lego/v4/challenge/http01" "github.com/rainycape/memcache" ) // HTTPProvider implements HTTPProvider for `http-01` challenge. type HTTPProvider struct { hosts []string } // NewMemcachedProvider returns a HTTPProvider instance with a configured webroot path. func NewMemcachedProvider(hosts []string) (*HTTPProvider, error) { if len(hosts) == 0 { return nil, errors.New("no memcached hosts provided") } c := &HTTPProvider{ hosts: hosts, } return c, nil } // Present makes the token available at `HTTP01ChallengePath(token)` by creating a file in the given webroot path. func (w *HTTPProvider) Present(domain, token, keyAuth string) error { var errs []error challengePath := path.Join("/", http01.ChallengePath(token)) for _, host := range w.hosts { mc, err := memcache.New(host) if err != nil { errs = append(errs, err) continue } _ = mc.Add(&memcache.Item{ Key: challengePath, Value: []byte(keyAuth), Expiration: 60, }) } if len(errs) == len(w.hosts) { return fmt.Errorf("unable to store key in any of the memcache hosts: %v", errs) } return nil } // CleanUp removes the file created for the challenge. func (w *HTTPProvider) CleanUp(domain, token, keyAuth string) error { // Memcached will clean up itself, that's what expiration is for. return nil } ================================================ FILE: providers/http/memcached/memcached_test.go ================================================ package memcached import ( "os" "path" "strings" "testing" "github.com/go-acme/lego/v4/challenge/http01" "github.com/rainycape/memcache" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( domain = "lego.test" token = "foo" keyAuth = "bar" ) var memcachedHosts = loadMemcachedHosts() func loadMemcachedHosts() []string { memcachedHostsStr := os.Getenv("MEMCACHED_HOSTS") if memcachedHostsStr != "" { return strings.Split(memcachedHostsStr, ",") } return nil } func TestNewMemcachedProviderEmpty(t *testing.T) { emptyHosts := make([]string, 0) _, err := NewMemcachedProvider(emptyHosts) require.EqualError(t, err, "no memcached hosts provided") } func TestNewMemcachedProviderValid(t *testing.T) { if len(memcachedHosts) == 0 { t.Skip("Skipping memcached tests") } _, err := NewMemcachedProvider(memcachedHosts) require.NoError(t, err) } func TestMemcachedPresentSingleHost(t *testing.T) { if len(memcachedHosts) == 0 { t.Skip("Skipping memcached tests") } p, err := NewMemcachedProvider(memcachedHosts[0:1]) require.NoError(t, err) challengePath := path.Join("/", http01.ChallengePath(token)) err = p.Present(domain, token, keyAuth) require.NoError(t, err) mc, err := memcache.New(memcachedHosts[0]) require.NoError(t, err) i, err := mc.Get(challengePath) require.NoError(t, err) assert.Equal(t, i.Value, []byte(keyAuth)) } func TestMemcachedPresentMultiHost(t *testing.T) { if len(memcachedHosts) <= 1 { t.Skip("Skipping memcached multi-host tests") } p, err := NewMemcachedProvider(memcachedHosts) require.NoError(t, err) challengePath := path.Join("/", http01.ChallengePath(token)) err = p.Present(domain, token, keyAuth) require.NoError(t, err) for _, host := range memcachedHosts { mc, err := memcache.New(host) require.NoError(t, err) i, err := mc.Get(challengePath) require.NoError(t, err) assert.Equal(t, i.Value, []byte(keyAuth)) } } func TestMemcachedPresentPartialFailureMultiHost(t *testing.T) { if len(memcachedHosts) == 0 { t.Skip("Skipping memcached tests") } hosts := append(memcachedHosts, "5.5.5.5:11211") p, err := NewMemcachedProvider(hosts) require.NoError(t, err) challengePath := path.Join("/", http01.ChallengePath(token)) err = p.Present(domain, token, keyAuth) require.NoError(t, err) for _, host := range memcachedHosts { mc, err := memcache.New(host) require.NoError(t, err) i, err := mc.Get(challengePath) require.NoError(t, err) assert.Equal(t, i.Value, []byte(keyAuth)) } } func TestMemcachedCleanup(t *testing.T) { if len(memcachedHosts) == 0 { t.Skip("Skipping memcached tests") } p, err := NewMemcachedProvider(memcachedHosts) require.NoError(t, err) require.NoError(t, p.CleanUp(domain, token, keyAuth)) } ================================================ FILE: providers/http/s3/s3.go ================================================ // Package s3 implements an HTTP provider for solving the HTTP-01 challenge using AWS S3. package s3 import ( "bytes" "context" "errors" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/go-acme/lego/v4/challenge/http01" ) // HTTPProvider implements ChallengeProvider for `http-01` challenge. type HTTPProvider struct { bucket string client *s3.Client } // NewHTTPProvider returns a HTTPProvider instance with a configured s3 bucket and aws session. // Credentials must be passed in the environment variables. func NewHTTPProvider(bucket string) (*HTTPProvider, error) { if bucket == "" { return nil, errors.New("s3: bucket name missing") } ctx := context.Background() cfg, err := config.LoadDefaultConfig(ctx) if err != nil { return nil, fmt.Errorf("s3: unable to create AWS config: %w", err) } client := s3.NewFromConfig(cfg) return &HTTPProvider{ bucket: bucket, client: client, }, nil } // Present makes the token available at `HTTP01ChallengePath(token)` by creating a file in the given s3 bucket. func (s *HTTPProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() params := &s3.PutObjectInput{ ACL: "public-read", Bucket: aws.String(s.bucket), Key: aws.String(strings.Trim(http01.ChallengePath(token), "/")), Body: bytes.NewReader([]byte(keyAuth)), } _, err := s.client.PutObject(ctx, params) if err != nil { return fmt.Errorf("s3: failed to upload token to s3: %w", err) } return nil } // CleanUp removes the file created for the challenge. func (s *HTTPProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() params := &s3.DeleteObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(strings.Trim(http01.ChallengePath(token), "/")), } _, err := s.client.DeleteObject(ctx, params) if err != nil { return fmt.Errorf("s3: could not remove file in s3 bucket after HTTP challenge: %w", err) } return nil } ================================================ FILE: providers/http/s3/s3.toml ================================================ Name = "Amazon S3" Description = '''''' URL = "https://aws.amazon.com/s3/" Code = "s3" Since = "v4.14.0" Example = ''' AWS_ACCESS_KEY_ID=your_key_id \ AWS_SECRET_ACCESS_KEY=your_secret_access_key \ AWS_REGION=aws-region \ lego --domains example.com --email your_example@email.com --http --http.s3-bucket your_s3_bucket --accept-tos=true run ''' Additional = ''' ## Description AWS Credentials are automatically detected in the following locations and prioritized in the following order: 1. Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, [`AWS_SESSION_TOKEN`] 2. Shared credentials file (defaults to `~/.aws/credentials`, profiles can be specified using `AWS_PROFILE`) 3. Amazon EC2 IAM role The AWS Region is automatically detected in the following locations and prioritized in the following order: 1. Environment variables: `AWS_REGION` 2. Shared configuration file if `AWS_SDK_LOAD_CONFIG` is set (defaults to `~/.aws/config`, profiles can be specified using `AWS_PROFILE`) See also: https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/ ### Broad privileges for testing purposes Will need to create an S3 bucket which has read permissions set for Everyone (public access). The S3 bucket doesn't require static website hosting to be enabled. AWS_REGION must match the region where the s3 bucket is hosted. ''' [Configuration] [Configuration.Credentials] AWS_ACCESS_KEY_ID = "Managed by the AWS client. Access key ID (`AWS_ACCESS_KEY_ID_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead)" AWS_SECRET_ACCESS_KEY = "Managed by the AWS client. Secret access key (`AWS_SECRET_ACCESS_KEY_FILE` is not supported, use `AWS_SHARED_CREDENTIALS_FILE` instead)" AWS_REGION = "Managed by the AWS client (`AWS_REGION_FILE` is not supported)" S3_BUCKET = "Name of the s3 bucket" AWS_PROFILE = "Managed by the AWS client (`AWS_PROFILE_FILE` is not supported)" AWS_SDK_LOAD_CONFIG = "Managed by the AWS client. Retrieve the region from the CLI config file (`AWS_SDK_LOAD_CONFIG_FILE` is not supported)" AWS_ASSUME_ROLE_ARN = "Managed by the AWS Role ARN (`AWS_ASSUME_ROLE_ARN_FILE` is not supported)" AWS_EXTERNAL_ID = "Managed by STS AssumeRole API operation (`AWS_EXTERNAL_ID_FILE` is not supported)" [Configuration.Additional] AWS_SHARED_CREDENTIALS_FILE = "Managed by the AWS client. Shared credentials file." AWS_MAX_RETRIES = "The number of maximum returns the service will use to make an individual API request" [Links] API = "https://docs.aws.amazon.com/AmazonS3/latest/userguide//Welcome.html" GoClient = "https://docs.aws.amazon.com/sdk-for-go/" ================================================ FILE: providers/http/s3/s3_test.go ================================================ package s3 import ( "fmt" "io" "net/http" "os" "testing" "github.com/go-acme/lego/v4/challenge/http01" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( domain = "example.com" token = "foo" keyAuth = "bar" ) var envTest = tester.NewEnvTest( "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "S3_BUCKET") func TestLiveNewHTTPProvider_Valid(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() _, err := NewHTTPProvider(envTest.GetValue("S3_BUCKET")) require.NoError(t, err) } func TestLiveNewHTTPProvider(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") } envTest.RestoreEnv() s3Bucket := os.Getenv("S3_BUCKET") provider, err := NewHTTPProvider(s3Bucket) require.NoError(t, err) // Present err = provider.Present(domain, token, keyAuth) require.NoError(t, err) chlgPath := fmt.Sprintf("http://%s.s3.%s.amazonaws.com%s", s3Bucket, envTest.GetValue("AWS_REGION"), http01.ChallengePath(token)) resp, err := http.Get(chlgPath) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, []byte(keyAuth), data) // CleanUp err = provider.CleanUp(domain, token, keyAuth) require.NoError(t, err) cleanupResp, err := http.Get(chlgPath) require.NoError(t, err) assert.Equal(t, 403, cleanupResp.StatusCode) } ================================================ FILE: providers/http/webroot/webroot.go ================================================ // Package webroot implements an HTTP provider for solving the HTTP-01 challenge using web server's root path. package webroot import ( "errors" "fmt" "os" "path/filepath" "github.com/go-acme/lego/v4/challenge/http01" ) // HTTPProvider implements ChallengeProvider for `http-01` challenge. type HTTPProvider struct { path string } // NewHTTPProvider returns a HTTPProvider instance with a configured webroot path. func NewHTTPProvider(path string) (*HTTPProvider, error) { if _, err := os.Stat(path); os.IsNotExist(err) { return nil, errors.New("webroot path does not exist") } return &HTTPProvider{path: path}, nil } // Present makes the token available at `HTTP01ChallengePath(token)` by creating a file in the given webroot path. func (w *HTTPProvider) Present(domain, token, keyAuth string) error { var err error challengeFilePath := filepath.Join(w.path, http01.ChallengePath(token)) err = os.MkdirAll(filepath.Dir(challengeFilePath), 0o755) if err != nil { return fmt.Errorf("could not create required directories in webroot for HTTP challenge: %w", err) } err = os.WriteFile(challengeFilePath, []byte(keyAuth), 0o644) if err != nil { return fmt.Errorf("could not write file in webroot for HTTP challenge: %w", err) } return nil } // CleanUp removes the file created for the challenge. func (w *HTTPProvider) CleanUp(domain, token, keyAuth string) error { err := os.Remove(filepath.Join(w.path, http01.ChallengePath(token))) if err != nil { return fmt.Errorf("could not remove file in webroot after HTTP challenge: %w", err) } return nil } ================================================ FILE: providers/http/webroot/webroot_test.go ================================================ package webroot import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHTTPProvider(t *testing.T) { webroot := "webroot" domain := "domain" token := "token" keyAuth := "keyAuth" challengeFilePath := webroot + "/.well-known/acme-challenge/" + token require.NoError(t, os.MkdirAll(webroot+"/.well-known/acme-challenge", 0o777)) defer os.RemoveAll(webroot) provider, err := NewHTTPProvider(webroot) require.NoError(t, err) err = provider.Present(domain, token, keyAuth) require.NoError(t, err) if _, err = os.Stat(challengeFilePath); os.IsNotExist(err) { t.Error("Challenge file was not created in webroot") } var data []byte data, err = os.ReadFile(challengeFilePath) require.NoError(t, err) dataStr := string(data) assert.Equal(t, keyAuth, dataStr) err = provider.CleanUp(domain, token, keyAuth) require.NoError(t, err) } ================================================ FILE: registration/registar.go ================================================ package registration import ( "errors" "net/http" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/log" ) const mailTo = "mailto:" // Resource represents all important information about a registration // of which the client needs to keep track itself. // WARNING: will be removed in the future (acme.ExtendedAccount), https://github.com/go-acme/lego/issues/855. type Resource struct { Body acme.Account `json:"body"` URI string `json:"uri,omitempty"` } type RegisterOptions struct { TermsOfServiceAgreed bool } type RegisterEABOptions struct { TermsOfServiceAgreed bool Kid string HmacEncoded string } type Registrar struct { core *api.Core user User } func NewRegistrar(core *api.Core, user User) *Registrar { return &Registrar{ core: core, user: user, } } // Register the current account to the ACME server. func (r *Registrar) Register(options RegisterOptions) (*Resource, error) { if r == nil || r.user == nil { return nil, errors.New("acme: cannot register a nil client or user") } accMsg := acme.Account{ TermsOfServiceAgreed: options.TermsOfServiceAgreed, Contact: []string{}, } if r.user.GetEmail() != "" { log.Infof("acme: Registering account for %s", r.user.GetEmail()) accMsg.Contact = []string{mailTo + r.user.GetEmail()} } account, err := r.core.Accounts.New(accMsg) if err != nil { // seems impossible errorDetails := &acme.ProblemDetails{} if !errors.As(err, &errorDetails) || errorDetails.HTTPStatus != http.StatusConflict { return nil, err } } return &Resource{URI: account.Location, Body: account.Account}, nil } // RegisterWithExternalAccountBinding Register the current account to the ACME server. func (r *Registrar) RegisterWithExternalAccountBinding(options RegisterEABOptions) (*Resource, error) { accMsg := acme.Account{ TermsOfServiceAgreed: options.TermsOfServiceAgreed, Contact: []string{}, } if r.user.GetEmail() != "" { log.Infof("acme: Registering account for %s", r.user.GetEmail()) accMsg.Contact = []string{mailTo + r.user.GetEmail()} } account, err := r.core.Accounts.NewEAB(accMsg, options.Kid, options.HmacEncoded) if err != nil { // seems impossible errorDetails := &acme.ProblemDetails{} if !errors.As(err, &errorDetails) || errorDetails.HTTPStatus != http.StatusConflict { return nil, err } } return &Resource{URI: account.Location, Body: account.Account}, nil } // QueryRegistration runs a POST request on the client's registration and returns the result. // // This is similar to the Register function, // but acting on an existing registration link and resource. func (r *Registrar) QueryRegistration() (*Resource, error) { if r == nil || r.user == nil || r.user.GetRegistration() == nil { return nil, errors.New("acme: cannot query the registration of a nil client or user") } // Log the URL here instead of the email as the email may not be set log.Infof("acme: Querying account for %s", r.user.GetRegistration().URI) account, err := r.core.Accounts.Get(r.user.GetRegistration().URI) if err != nil { return nil, err } return &Resource{ Body: account, // Location: header is not returned so this needs to be populated off of existing URI URI: r.user.GetRegistration().URI, }, nil } // UpdateRegistration update the user registration on the ACME server. func (r *Registrar) UpdateRegistration(options RegisterOptions) (*Resource, error) { if r == nil || r.user == nil { return nil, errors.New("acme: cannot update a nil client or user") } accMsg := acme.Account{ TermsOfServiceAgreed: options.TermsOfServiceAgreed, Contact: []string{}, } if r.user.GetEmail() != "" { log.Infof("acme: Registering account for %s", r.user.GetEmail()) accMsg.Contact = []string{mailTo + r.user.GetEmail()} } accountURL := r.user.GetRegistration().URI account, err := r.core.Accounts.Update(accountURL, accMsg) if err != nil { return nil, err } return &Resource{URI: accountURL, Body: account}, nil } // DeleteRegistration deletes the client's user registration from the ACME server. func (r *Registrar) DeleteRegistration() error { if r == nil || r.user == nil { return errors.New("acme: cannot unregister a nil client or user") } log.Infof("acme: Deleting account for %s", r.user.GetEmail()) return r.core.Accounts.Deactivate(r.user.GetRegistration().URI) } // ResolveAccountByKey will attempt to look up an account using the given account key // and return its registration resource. func (r *Registrar) ResolveAccountByKey() (*Resource, error) { log.Infof("acme: Trying to resolve account by key") accMsg := acme.Account{OnlyReturnExisting: true} account, err := r.core.Accounts.New(accMsg) if err != nil { return nil, err } return &Resource{URI: account.Location, Body: account.Account}, nil } ================================================ FILE: registration/registar_test.go ================================================ package registration import ( "crypto/rand" "crypto/rsa" "fmt" "net/http" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/acme/api" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRegistrar_ResolveAccountByKey(t *testing.T) { server := tester.MockACMEServer(). Route("/account", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { rw.Header().Set("Location", fmt.Sprintf("http://%s/account", req.Context().Value(http.LocalAddrContextKey))) servermock.JSONEncode(acme.Account{Status: "valid"}).ServeHTTP(rw, req) })). BuildHTTPS(t) key, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err, "Could not generate test key") user := mockUser{ email: "test@test.com", regres: &Resource{}, privatekey: key, } core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) registrar := NewRegistrar(core, user) res, err := registrar.ResolveAccountByKey() require.NoError(t, err, "Unexpected error resolving account by key") assert.Equal(t, "valid", res.Body.Status, "Unexpected account status") } ================================================ FILE: registration/user.go ================================================ package registration import ( "crypto" ) // User interface is to be implemented by users of this library. // It is used by the client type to get user specific information. type User interface { GetEmail() string GetRegistration() *Resource GetPrivateKey() crypto.PrivateKey } ================================================ FILE: registration/user_test.go ================================================ package registration import ( "crypto" "crypto/rsa" ) type mockUser struct { email string regres *Resource privatekey *rsa.PrivateKey } func (u mockUser) GetEmail() string { return u.email } func (u mockUser) GetRegistration() *Resource { return u.regres } func (u mockUser) GetPrivateKey() crypto.PrivateKey { return u.privatekey }