From c419bb38e992f1d4428fd7290d1af6ec241295d0 Mon Sep 17 00:00:00 2001 From: FoxyHunter Date: Mon, 1 Jun 2026 00:07:11 +0200 Subject: [PATCH] Feat: add gandi api methods --- src/gandi.c | 276 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/gandi.h | 47 +++++++++ 2 files changed, 323 insertions(+) create mode 100644 src/gandi.c create mode 100644 src/gandi.h diff --git a/src/gandi.c b/src/gandi.c new file mode 100644 index 0000000..c974dfe --- /dev/null +++ b/src/gandi.c @@ -0,0 +1,276 @@ +#include +#include +#include +#include +#include +#include +#include "gandi.h" +#include "log.h" + +#define GANDI_API_BASE "https://api.gandi.net/v5/livedns" + +static char s_api_key[256] = {0}; + +typedef struct { + char *buf; + size_t len; + size_t cap; +} DynBuf; + +static size_t write_cb(void *data, size_t size, size_t nmemb, void *userp) { + DynBuf *out = (DynBuf *)userp; + size_t total = size * nmemb; + + if (out->len + total + 1 > out->cap) { + size_t new_cap = out->cap ? out->cap * 2 : 4096; + while (new_cap < out->len + total + 1) new_cap *= 2; + char *tmp = realloc(out->buf, new_cap); + if (!tmp) return 0; + out->buf = tmp; + out->cap = new_cap; + } + + memcpy(out->buf + out->len, data, total); + out->len += total; + out->buf[out->len] = '\0'; + return total; +} + +int gandi_init(const char *api_key) { + if (!api_key || api_key[0] == '\0') return -1; + strncpy(s_api_key, api_key, sizeof(s_api_key) - 1); + s_api_key[sizeof(s_api_key) - 1] = '\0'; + return 0; +} + +static CURL *make_curl(DynBuf *buf, struct curl_slist **headers_out) { + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + char auth_header[320]; + snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", s_api_key); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, auth_header); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_CAINFO, "/etc/ssl/certs/ca-certificates.crt"); + curl_easy_setopt(curl, CURLOPT_CAPATH, "/etc/ssl/certs"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, buf); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + + *headers_out = headers; + return curl; +} + +static int check_http_code(CURL *curl) { + long code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + + if (code == 401 || code == 403) return -2; + if (code < 200 || code >= 300) return -1; + return 0; +} + +static int patch_record( + const char *domain, + const char *rec_name, + const char *old_ip, + const char *new_ip +) { + cJSON *patch = cJSON_CreateObject(); + cJSON *add_arr = cJSON_CreateArray(); + cJSON *rem_arr = cJSON_CreateArray(); + cJSON_AddItemToArray(add_arr, cJSON_CreateString(new_ip)); + cJSON_AddItemToArray(rem_arr, cJSON_CreateString(old_ip)); + cJSON_AddItemToObject(patch, "add_rrset_values", add_arr); + cJSON_AddItemToObject(patch, "remove_rrset_values", rem_arr); + char *patch_str = cJSON_PrintUnformatted(patch); + cJSON_Delete(patch); + + char url[512]; + snprintf(url, sizeof(url), + "%s/domains/%s/records/%s/A", GANDI_API_BASE, domain, rec_name); + + DynBuf buf = {0}; + struct curl_slist *headers = NULL; + CURL *curl = make_curl(&buf, &headers); + if (!curl) { free(patch_str); return -1; } + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PATCH"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, patch_str); + + CURLcode res = curl_easy_perform(curl); + int ret = (res == CURLE_OK) ? check_http_code(curl) : -1; + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + free(buf.buf); + free(patch_str); + return ret; +} + +int gandi_get_self_ip(const char *domain, const char *record_name, char *out_ip) { + char url[512]; + snprintf(url, sizeof(url), + "%s/domains/%s/records/%s/A", GANDI_API_BASE, domain, record_name); + + DynBuf buf = {0}; + struct curl_slist *headers = NULL; + CURL *curl = make_curl(&buf, &headers); + if (!curl) return -1; + + curl_easy_setopt(curl, CURLOPT_URL, url); + + int ret = -1; + + CURLcode cres = curl_easy_perform(curl); + if (cres != CURLE_OK) { + log_error("gandi: curl error for %s: (%d) %s", + url, (int)cres, curl_easy_strerror(cres)); + ret = -1; + goto cleanup; + } + + ret = check_http_code(curl); + if (ret == -2) log_error("gandi: auth error (HTTP 401/403) for %s", url); + else if (ret != 0) log_error("gandi: non-2xx HTTP response for %s", url); + if (ret != 0) goto cleanup; + + if (!buf.buf || buf.buf[0] == '\0') { + log_error("gandi: empty response body for %s", url); + ret = -1; + goto cleanup; + } + + cJSON *root = cJSON_Parse(buf.buf); + if (!root) { + log_error("gandi: JSON parse failed for %s", url); + ret = -1; + goto cleanup; + } + + cJSON *values = cJSON_GetObjectItem(root, "rrset_values"); + if (!cJSON_IsArray(values) || cJSON_GetArraySize(values) <= 0) { + log_warn("gandi: rrset_values missing/empty for %s (record %s.%s type A)", + url, record_name, domain); + cJSON_Delete(root); + ret = -3; + goto cleanup; + } + + const char *ip = cJSON_GetStringValue(cJSON_GetArrayItem(values, 0)); + if (!ip || ip[0] == '\0') { + log_warn("gandi: rrset_values[0] not a string for %s", url); + cJSON_Delete(root); + ret = -3; + goto cleanup; + } + + strncpy(out_ip, ip, GANDI_MAX_IP_LEN - 1); + out_ip[GANDI_MAX_IP_LEN - 1] = '\0'; + + cJSON_Delete(root); + ret = 0; + +cleanup: + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + free(buf.buf); + return ret; +} + +int gandi_update_domains(const char *domain, const char *old_ip, const char *new_ip) { + char url[512]; + snprintf(url, sizeof(url), + "%s/domains/%s/records", GANDI_API_BASE, domain); + + DynBuf buf = {0}; + struct curl_slist *headers = NULL; + CURL *curl = make_curl(&buf, &headers); + if (!curl) return -1; + + curl_easy_setopt(curl, CURLOPT_URL, url); + + int ret = -1; + int matched = 0; + int failed = 0; + int n = 0; + cJSON *records = NULL; + + if (curl_easy_perform(curl) != CURLE_OK) goto cleanup; + + ret = check_http_code(curl); + if (ret == -2) log_error("gandi: auth error (HTTP 401/403) for %s", url); + else if (ret != 0) log_error("gandi: non-2xx HTTP response for %s", url); + if (ret != 0) goto cleanup; + + records = cJSON_Parse(buf.buf); + if (!records || !cJSON_IsArray(records)) { + ret = -1; + goto cleanup; + } + + n = cJSON_GetArraySize(records); + for (int i = 0; i < n; i++) { + cJSON *rec = cJSON_GetArrayItem(records, i); + cJSON *type = cJSON_GetObjectItem(rec, "rrset_type"); + if (!cJSON_IsString(type) || strcmp(type->valuestring, "A") != 0) continue; + + cJSON *values = cJSON_GetObjectItem(rec, "rrset_values"); + if (!cJSON_IsArray(values)) continue; + + int has_old = 0; + int vcount = cJSON_GetArraySize(values); + for (int j = 0; j < vcount; j++) { + const char *v = cJSON_GetStringValue(cJSON_GetArrayItem(values, j)); + if (v && strcmp(v, old_ip) == 0) { has_old = 1; break; } + } + if (!has_old) continue; + + cJSON *name_item = cJSON_GetObjectItem(rec, "rrset_name"); + const char *rec_name = cJSON_IsString(name_item) ? name_item->valuestring : "@"; + + int pret = patch_record(domain, rec_name, old_ip, new_ip); + if (pret == 0) { + log_info("gandi: [%s] updated record \"%s\" A: %s -> %s", + domain, rec_name, old_ip, new_ip); + matched++; + } else { + log_error("gandi: [%s] failed to patch record \"%s\" (err %d)", + domain, rec_name, pret); + failed++; + } + } + + if (matched == 0 && failed == 0) ret = -3; + else if (failed > 0) ret = -1; + else ret = 0; + +cleanup: + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + free(buf.buf); + cJSON_Delete(records); + return ret; +} + +int gandi_update_record( + const char *domain, + const char *record_name, + const char *old_ip, + const char *new_ip +) { + int ret = patch_record(domain, record_name, old_ip, new_ip); + if (ret == 0) + log_info("gandi: [%s] updated record \"%s\" A: %s -> %s", + domain, record_name, old_ip, new_ip); + else + log_error("gandi: [%s] failed to patch record \"%s\" (err %d)", + domain, record_name, ret); + return ret; +} diff --git a/src/gandi.h b/src/gandi.h new file mode 100644 index 0000000..8c6b9f1 --- /dev/null +++ b/src/gandi.h @@ -0,0 +1,47 @@ +#ifndef GANDI_H +#define GANDI_H + +#define GANDI_MAX_IP_LEN 46 + +/* + * Stores the API key internally. + * Must be called once at startup before calling other gandi methods. + * + * Returns 0 on success. + * Returns -1 if api_key is NULL or empty. + */ +int gandi_init(const char *api_key); + +/* + * Fetches the current IPv4 addr from the special gandi fqdn + * + * Returns 0 on success. + * Returns -1 on network or curl error. + * Returns -2 on API auth error (401/403). + * Returns -3 if the record was not found or has no values. + */ +int gandi_get_self_ip(const char *domain, const char *record_name, char *out_ip); + +/* + * Fetches all records of all domains, + * looks for all A records where rrset_value + * is set to `old_ip` and patches each one to `new_ip`. + * + * Returns 0 on success (all records updated). + * Returns -1 on network or curl error, or if any patch failed. + * Returns -2 on API auth error (401/403). + * Returns -3 if no A records had old_ip. + */ +int gandi_update_domains(const char *fqdn, const char *old_ip, const char *new_ip); + +/* + * Patches an A record's rrset_value from `old_ip` to `new_ip`. + * + * Returns 0 on success. + * Returns -1 on network or curl error. + * Returns -2 on API auth error (401/403). + */ +int gandi_update_record(const char *domain, const char *record_name, + const char *old_ip, const char *new_ip); + +#endif /* GANDI_H */