You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

405 lines
11 KiB

  1. #!/usr/bin/env sh
  2. # acme.sh DNS API for Timeweb Cloud provider (https://timeweb.cloud).
  3. #
  4. # Author: https://github.com/nikolaypronchev.
  5. #
  6. # Prerequisites:
  7. # Timeweb Cloud API JWT token. Obtain one from the Timeweb Cloud control panel
  8. # ("API and Terraform" section: https://timeweb.cloud/my/api-keys). The JWT token
  9. # must be provided to this script in one of two ways:
  10. # 1. As the "TW_Token" variable, for example: "export TW_Token=eyJhbG...zUxMiIs";
  11. # 2. As a "TW_Token" config entry in acme.sh account config file
  12. # (usually located at ~/.acme.sh/account.conf by default).
  13. TW_Api="https://api.timeweb.cloud/api/v1"
  14. ################ Public functions ################
  15. # Adds an ACME DNS-01 challenge DNS TXT record via the Timeweb Cloud API.
  16. #
  17. # Param1: The ACME DNS-01 challenge FQDN.
  18. # Param2: The value of the ACME DNS-01 challenge TXT record.
  19. #
  20. # Example: dns_timeweb_add "_acme-challenge.sub.domain.com" "D-52Wm...4uYM"
  21. dns_timeweb_add() {
  22. _debug "$(__green "Timeweb DNS API"): \"dns_timeweb_add\" started."
  23. _timeweb_set_acme_fqdn "$1" || return 1
  24. _timeweb_set_acme_txt "$2" || return 1
  25. _timeweb_check_token || return 1
  26. _timeweb_split_acme_fqdn || return 1
  27. _timeweb_dns_txt_add || return 1
  28. _debug "$(__green "Timeweb DNS API"): \"dns_timeweb_add\" finished."
  29. }
  30. # Removes a DNS TXT record via the Timeweb Cloud API.
  31. #
  32. # Param1: The ACME DNS-01 challenge FQDN.
  33. # Param2: The value of the ACME DNS-01 challenge TXT record.
  34. #
  35. # Example: dns_timeweb_rm "_acme-challenge.sub.domain.com" "D-52Wm...4uYM"
  36. dns_timeweb_rm() {
  37. _debug "$(__green "Timeweb DNS API"): \"dns_timeweb_rm\" started."
  38. _timeweb_set_acme_fqdn "$1" || return 1
  39. _timeweb_set_acme_txt "$2" || return 1
  40. _timeweb_check_token || return 1
  41. _timeweb_split_acme_fqdn || return 1
  42. _timeweb_get_dns_txt || return 1
  43. _timeweb_dns_txt_remove || return 1
  44. _debug "$(__green "Timeweb DNS API"): \"dns_timeweb_rm\" finished."
  45. }
  46. ################ Private functions ################
  47. # Checks and sets the ACME DNS-01 challenge FQDN.
  48. #
  49. # Param1: The ACME DNS-01 challenge FQDN.
  50. #
  51. # Example: _timeweb_set_acme_fqdn "_acme-challenge.sub.domain.com"
  52. #
  53. # Sets the "Acme_Fqdn" variable (_acme-challenge.sub.domain.com)
  54. _timeweb_set_acme_fqdn() {
  55. Acme_Fqdn=$1
  56. _debug "Setting ACME DNS-01 challenge FQDN \"$Acme_Fqdn\"."
  57. [ -z "$Acme_Fqdn" ] && {
  58. _err "ACME DNS-01 challenge FQDN is empty."
  59. return 1
  60. }
  61. return 0
  62. }
  63. # Checks and sets the value of the ACME DNS-01 challenge TXT record.
  64. #
  65. # Param1: Value of the ACME DNS-01 challenge TXT record.
  66. #
  67. # Example: _timeweb_set_acme_txt "D-52Wm...4uYM"
  68. #
  69. # Sets the "Acme_Txt" variable to the provided value (D-52Wm...4uYM)
  70. _timeweb_set_acme_txt() {
  71. Acme_Txt=$1
  72. _debug "Setting the value of the ACME DNS-01 challenge TXT record to \"$Acme_Txt\"."
  73. [ -z "$Acme_Txt" ] && {
  74. _err "ACME DNS-01 challenge TXT record value is empty."
  75. return 1
  76. }
  77. return 0
  78. }
  79. # Checks if the Timeweb Cloud API JWT token is present (refer to the script description).
  80. # Adds or updates the token in the acme.sh account configuration.
  81. _timeweb_check_token() {
  82. _debug "Checking for the presence of the Timeweb Cloud API JWT token."
  83. TW_Token="${TW_Token:-$(_readaccountconf_mutable TW_Token)}"
  84. [ -z "$TW_Token" ] && {
  85. _err "Timeweb Cloud API JWT token was not found."
  86. return 1
  87. }
  88. _saveaccountconf_mutable TW_Token "$TW_Token"
  89. }
  90. # Divides the ACME DNS-01 challenge FQDN into its main domain and subdomain components.
  91. _timeweb_split_acme_fqdn() {
  92. _debug "Trying to divide \"$Acme_Fqdn\" into its main domain and subdomain components."
  93. TW_Page_Limit=100
  94. TW_Page_Offset=0
  95. while [ -z "$TW_Domains_Total" ] ||
  96. [ "$((TW_Domains_Total + TW_Page_Limit))" -gt "$((TW_Page_Offset + TW_Page_Limit))" ]; do
  97. _timeweb_list_domains "$TW_Page_Limit" "$TW_Page_Offset" || return 1
  98. # Remove the 'subdomains' subarray to prevent confusion with FQDNs.
  99. TW_Domains=$(
  100. echo "$TW_Domains" |
  101. sed 's/"subdomains":\[[^]]*]//g'
  102. )
  103. [ -z "$TW_Domains" ] && {
  104. _err "Failed to parse the list of domains."
  105. return 1
  106. }
  107. while
  108. TW_Domain=$(
  109. echo "$TW_Domains" |
  110. sed -n 's/.*{[^{]*"fqdn":"\([^"]*\)"[^}]*}.*/\1/p'
  111. )
  112. [ -n "$TW_Domain" ] && {
  113. _timeweb_is_main_domain "$TW_Domain" && return 0
  114. TW_Domains=$(
  115. echo "$TW_Domains" |
  116. sed 's/{\([^{]*"fqdn":"'"$TW_Domain"'"[^}]*\)}//'
  117. )
  118. continue
  119. }
  120. do :; done
  121. TW_Page_Offset=$(_math "$TW_Page_Offset" + "$TW_Page_Limit")
  122. done
  123. _err "Failed to divide \"$Acme_Fqdn\" into its main domain and subdomain components."
  124. return 1
  125. }
  126. # Searches for a previously added DNS TXT record.
  127. #
  128. # Sets the "TW_Dns_Txt_Id" variable.
  129. _timeweb_get_dns_txt() {
  130. _debug "Trying to locate a DNS TXT record with the value \"$Acme_Txt\"."
  131. TW_Page_Limit=100
  132. TW_Page_Offset=0
  133. while [ -z "$TW_Dns_Records_Total" ] ||
  134. [ "$((TW_Dns_Records_Total + TW_Page_Limit))" -gt "$((TW_Page_Offset + TW_Page_Limit))" ]; do
  135. _timeweb_list_dns_records "$TW_Page_Limit" "$TW_Page_Offset" || return 1
  136. while
  137. Dns_Record=$(
  138. echo "$TW_Dns_Records" |
  139. sed -n 's/.*{\([^{]*{[^{]*'"$Acme_Txt"'[^}]*}[^}]*\)}.*/\1/p'
  140. )
  141. [ -n "$Dns_Record" ] && {
  142. _timeweb_is_added_txt "$Dns_Record" && return 0
  143. TW_Dns_Records=$(
  144. echo "$TW_Dns_Records" |
  145. sed 's/{\([^{]*{[^{]*'"$Acme_Txt"'[^}]*}[^}]*\)}//'
  146. )
  147. continue
  148. }
  149. do :; done
  150. TW_Page_Offset=$(_math "$TW_Page_Offset" + "$TW_Page_Limit")
  151. done
  152. _err "DNS TXT record was not found."
  153. return 1
  154. }
  155. # Lists domains via the Timeweb Cloud API.
  156. #
  157. # Param 1: Limit for listed domains.
  158. # Param 2: Offset for domains list.
  159. #
  160. # Sets the "TW_Domains" variable.
  161. # Sets the "TW_Domains_Total" variable.
  162. _timeweb_list_domains() {
  163. _debug "Listing domains via Timeweb Cloud API. Limit: $1, offset: $2."
  164. export _H1="Authorization: Bearer $TW_Token"
  165. if ! TW_Domains=$(_get "$TW_Api/domains?limit=$1&offset=$2"); then
  166. _err "The request to the Timeweb Cloud API failed."
  167. return 1
  168. fi
  169. [ -z "$TW_Domains" ] && {
  170. _err "Empty response from the Timeweb Cloud API."
  171. return 1
  172. }
  173. TW_Domains_Total=$(
  174. echo "$TW_Domains" |
  175. sed 's/.*"meta":{"total":\([0-9]*\)[^0-9].*/\1/'
  176. )
  177. [ -z "$TW_Domains_Total" ] && {
  178. _err "Failed to extract the total count of domains."
  179. return 1
  180. }
  181. [ "$TW_Domains_Total" -eq "0" ] && {
  182. _err "Domains are missing."
  183. return 1
  184. }
  185. _debug "Total count of domains in the Timeweb Cloud account: $TW_Domains_Total."
  186. }
  187. # Lists domain DNS records via the Timeweb Cloud API.
  188. #
  189. # Param 1: Limit for listed DNS records.
  190. # Param 2: Offset for DNS records list.
  191. #
  192. # Sets the "TW_Dns_Records" variable.
  193. # Sets the "TW_Dns_Records_Total" variable.
  194. _timeweb_list_dns_records() {
  195. _debug "Listing domain DNS records via the Timeweb Cloud API. Limit: $1, offset: $2."
  196. export _H1="Authorization: Bearer $TW_Token"
  197. if ! TW_Dns_Records=$(_get "$TW_Api/domains/$TW_Main_Domain/dns-records?limit=$1&offset=$2"); then
  198. _err "The request to the Timeweb Cloud API failed."
  199. return 1
  200. fi
  201. [ -z "$TW_Dns_Records" ] && {
  202. _err "Empty response from the Timeweb Cloud API."
  203. return 1
  204. }
  205. TW_Dns_Records_Total=$(
  206. echo "$TW_Dns_Records" |
  207. sed 's/.*"meta":{"total":\([0-9]*\)[^0-9].*/\1/'
  208. )
  209. [ -z "$TW_Dns_Records_Total" ] && {
  210. _err "Failed to extract the total count of DNS records."
  211. return 1
  212. }
  213. [ "$TW_Dns_Records_Total" -eq "0" ] && {
  214. _err "DNS records are missing."
  215. return 1
  216. }
  217. _debug "Total count of DNS records: $TW_Dns_Records_Total."
  218. }
  219. # Verifies whether the domain is the primary domain for the ACME DNS-01 challenge FQDN.
  220. # The requirement is that the provided domain is the top-level domain
  221. # for the ACME DNS-01 challenge FQDN.
  222. #
  223. # Param 1: Domain object returned by Timeweb Cloud API.
  224. #
  225. # Sets the "TW_Main_Domain" variable (e.g. "_acme-challenge.s1.domain.co.uk" → "domain.co.uk").
  226. # Sets the "TW_Subdomains" variable (e.g. "_acme-challenge.s1.domain.co.uk" → "_acme-challenge.s1").
  227. _timeweb_is_main_domain() {
  228. _debug "Checking if \"$1\" is the main domain of the ACME DNS-01 challenge FQDN."
  229. [ -z "$1" ] && {
  230. _debug "Failed to extract FQDN. Skipping domain."
  231. return 1
  232. }
  233. ! echo ".$Acme_Fqdn" | grep -qi "\.$1$" && {
  234. _debug "Domain does not match the ACME DNS-01 challenge FQDN. Skipping domain."
  235. return 1
  236. }
  237. TW_Main_Domain=$1
  238. TW_Subdomains=$(
  239. echo "$Acme_Fqdn" |
  240. sed "s/\.*.\{${#1}\}$//"
  241. )
  242. _debug "Matched domain. ACME DNS-01 challenge FQDN split as [$TW_Subdomains].[$TW_Main_Domain]."
  243. return 0
  244. }
  245. # Verifies whether a DNS record was previously added based on the following criteria:
  246. # - The value matches the ACME DNS-01 challenge TXT record value;
  247. # - The record type is TXT;
  248. # - The subdomain matches the ACME DNS-01 challenge FQDN.
  249. #
  250. # Param 1: DNS record object returned by Timeweb Cloud API.
  251. #
  252. # Sets the "TW_Dns_Txt_Id" variable.
  253. _timeweb_is_added_txt() {
  254. _debug "Checking if \"$1\" is a previously added DNS TXT record."
  255. echo "$1" | grep -qv '"type":"TXT"' && {
  256. _debug "Not a TXT record. Skipping the record."
  257. return 1
  258. }
  259. if [ -n "$TW_Subdomains" ]; then
  260. echo "$1" | grep -qvi "\"subdomain\":\"$TW_Subdomains\"" && {
  261. _debug "Subdomains do not match. Skipping the record."
  262. return 1
  263. }
  264. else
  265. echo "$1" | grep -q '"subdomain\":"..*"' && {
  266. _debug "Subdomains do not match. Skipping the record."
  267. return 1
  268. }
  269. fi
  270. TW_Dns_Txt_Id=$(
  271. echo "$1" |
  272. sed 's/.*"id":\([0-9]*\)[^0-9].*/\1/'
  273. )
  274. [ -z "$TW_Dns_Txt_Id" ] && {
  275. _debug "Failed to extract the DNS record ID. Skipping the record."
  276. return 1
  277. }
  278. _debug "Matching DNS TXT record ID is \"$TW_Dns_Txt_Id\"."
  279. return 0
  280. }
  281. # Adds a DNS TXT record via the Timeweb Cloud API.
  282. _timeweb_dns_txt_add() {
  283. _debug "Adding a new DNS TXT record via the Timeweb Cloud API."
  284. export _H1="Authorization: Bearer $TW_Token"
  285. export _H2="Content-Type: application/json"
  286. if ! TW_Response=$(
  287. _post "{
  288. \"subdomain\":\"$TW_Subdomains\",
  289. \"type\":\"TXT\",
  290. \"value\":\"$Acme_Txt\"
  291. }" \
  292. "$TW_Api/domains/$TW_Main_Domain/dns-records"
  293. ); then
  294. _err "The request to the Timeweb Cloud API failed."
  295. return 1
  296. fi
  297. [ -z "$TW_Response" ] && {
  298. _err "An unexpected empty response was received from the Timeweb Cloud API."
  299. return 1
  300. }
  301. TW_Dns_Txt_Id=$(
  302. echo "$TW_Response" |
  303. sed 's/.*"id":\([0-9]*\)[^0-9].*/\1/'
  304. )
  305. [ -z "$TW_Dns_Txt_Id" ] && {
  306. _err "Failed to extract the DNS TXT Record ID."
  307. return 1
  308. }
  309. _debug "DNS TXT record has been added. ID: \"$TW_Dns_Txt_Id\"."
  310. }
  311. # Removes a DNS record via the Timeweb Cloud API.
  312. _timeweb_dns_txt_remove() {
  313. _debug "Removing DNS record via the Timeweb Cloud API."
  314. export _H1="Authorization: Bearer $TW_Token"
  315. if ! TW_Response=$(
  316. _post \
  317. "" \
  318. "$TW_Api/domains/$TW_Main_Domain/dns-records/$TW_Dns_Txt_Id" \
  319. "" \
  320. "DELETE"
  321. ); then
  322. _err "The request to the Timeweb Cloud API failed."
  323. return 1
  324. fi
  325. [ -n "$TW_Response" ] && {
  326. _err "Received an unexpected response body from the Timeweb Cloud API."
  327. return 1
  328. }
  329. _debug "DNS TXT record with ID \"$TW_Dns_Txt_Id\" has been removed."
  330. }