// SPDX-License-Identifier: GPL-3.0-or-later

#include "claim.h"

#include "registry/registry.h"

#include <curl/curl.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/err.h>

#define CLAIM_RESPONSE_SIZE_LIMIT (10 * 1024 * 1024)

struct claim_response_buffer {
    BUFFER *wb;
    bool too_large;
};

static bool check_and_generate_certificates() {
    FILE *fp;
    EVP_PKEY *pkey = NULL;
    EVP_PKEY_CTX *pctx = NULL;

    CLEAN_CHAR_P *private_key_file = filename_from_path_entry_strdupz(netdata_configured_cloud_dir, "private.pem");
    CLEAN_CHAR_P *public_key_file = filename_from_path_entry_strdupz(netdata_configured_cloud_dir, "public.pem");

    // Check if the public key exists; it is the completion marker for the pair.
    fp = fopen(public_key_file, "r");
    if (fp) {
        fclose(fp);
        return true;
    }

    // Generate the RSA key
    pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, NULL);
    if (!pctx) {
        claim_agent_failure_reason_set("Cannot generate RSA key, EVP_PKEY_CTX_new_id() failed");
        return false;
    }

    if (EVP_PKEY_keygen_init(pctx) <= 0) {
        claim_agent_failure_reason_set("Cannot generate RSA key, EVP_PKEY_keygen_init() failed");
        EVP_PKEY_CTX_free(pctx);
        return false;
    }

    if (EVP_PKEY_CTX_set_rsa_keygen_bits(pctx, 2048) <= 0) {
        claim_agent_failure_reason_set("Cannot generate RSA key, EVP_PKEY_CTX_set_rsa_keygen_bits() failed");
        EVP_PKEY_CTX_free(pctx);
        return false;
    }

    if (EVP_PKEY_keygen(pctx, &pkey) <= 0) {
        claim_agent_failure_reason_set("Cannot generate RSA key, EVP_PKEY_keygen() failed");
        EVP_PKEY_CTX_free(pctx);
        return false;
    }

    EVP_PKEY_CTX_free(pctx);

    // Save private key
    fp = fopen(private_key_file, "wb");
    if (!fp || !PEM_write_PrivateKey(fp, pkey, NULL, NULL, 0, NULL, NULL)) {
        claim_agent_failure_reason_set("Cannot write private key file: %s", private_key_file);
        if (fp) fclose(fp);
        EVP_PKEY_free(pkey);
        return false;
    }
    if (fclose(fp) != 0) {
        claim_agent_failure_reason_set("Cannot write private key file: %s", private_key_file);
        EVP_PKEY_free(pkey);
        return false;
    }

    // Save public key
    fp = fopen(public_key_file, "wb");
    if (!fp || !PEM_write_PUBKEY(fp, pkey)) {
        claim_agent_failure_reason_set("Cannot write public key file: %s", public_key_file);
        if (fp) {
            fclose(fp);
            unlink(public_key_file);
        }
        EVP_PKEY_free(pkey);
        return false;
    }
    if (fclose(fp) != 0) {
        claim_agent_failure_reason_set("Cannot write public key file: %s", public_key_file);
        unlink(public_key_file);
        EVP_PKEY_free(pkey);
        return false;
    }

    EVP_PKEY_free(pkey);
    return true;
}

static size_t response_write_callback(void *ptr, size_t size, size_t nmemb, void *stream) {
    struct claim_response_buffer *response = stream;

    if (unlikely(nmemb && size > SIZE_MAX / nmemb)) {
        response->too_large = true;
        return 0;
    }
    size_t real_size = size * nmemb;

    if (unlikely(real_size > CLAIM_RESPONSE_SIZE_LIMIT - buffer_strlen(response->wb))) {
        response->too_large = true;
        return 0;
    }

    buffer_memcat(response->wb, ptr, real_size);

    return real_size;
}

static const char *curl_add_json_room(BUFFER *wb, const char *start, const char *end, bool last_item)
{
    size_t len = end - start;

    // copy the item to an new buffer and terminate it
    char *buf = mallocz(len + 1);
    memcpy(buf, start, len);
    buf[len] = '\0';

    // add it to the json array
    const char *trimmed = trim(buf); // remove leading and trailing spaces
    if(trimmed)
        buffer_json_add_array_item_string(wb, trimmed);

    freez(buf);

    if (last_item)
        return NULL;

    // prepare for the next item
    start = end + 1;

    // skip multiple separators or spaces
    while(*start == ',' || *start == ' ') start++;

    return start;
}

void curl_add_rooms_json_array(BUFFER *wb, const char *rooms) {
    buffer_json_member_add_array(wb, "rooms");
    if(rooms && *rooms) {
        const char *start = rooms, *end = NULL;

        // Skip initial separators or spaces
        while (*start == ',' || *start == ' ')
            start++;

        // Process each item in the comma-separated list
        while ((end = strchr(start, ',')) != NULL)
            start = curl_add_json_room(wb, start, end, false);

        // Process the last item if any
        if (*start)
            curl_add_json_room(wb, start, &start[strlen(start)], true);
    }
    buffer_json_array_close(wb);
}

static int debug_callback(CURL *handle, curl_infotype type, char *data, size_t size, void *userptr) {
    (void)handle; // Unused
    (void)userptr; // Unused

    if (type == CURLINFO_TEXT)
        nd_log(NDLS_DAEMON, NDLP_INFO, "CLAIM: Info: %s", data);
    else if (type == CURLINFO_HEADER_OUT)
        nd_log(NDLS_DAEMON, NDLP_INFO, "CLAIM: Send header: %.*s", (int)size, data);
    else if (type == CURLINFO_DATA_OUT)
        nd_log(NDLS_DAEMON, NDLP_INFO, "CLAIM: Send data: %.*s", (int)size, data);
    else if (type == CURLINFO_SSL_DATA_OUT)
        nd_log(NDLS_DAEMON, NDLP_INFO, "CLAIM: Send SSL data: %.*s", (int)size, data);
    else if (type == CURLINFO_HEADER_IN)
        nd_log(NDLS_DAEMON, NDLP_INFO, "CLAIM: Receive header: %.*s", (int)size, data);
    else if (type == CURLINFO_DATA_IN)
        nd_log(NDLS_DAEMON, NDLP_INFO, "CLAIM: Receive data: %.*s", (int)size, data);
    else if (type == CURLINFO_SSL_DATA_IN)
        nd_log(NDLS_DAEMON, NDLP_INFO, "CLAIM: Receive SSL data: %.*s", (int)size, data);

    return 0;
}

static bool cleanup_curl_request_failure(CURL *curl, struct curl_slist *headers, bool *can_retry, CURLcode res,
                                         const char *option) {
    claim_agent_failure_reason_set("Cannot configure request (%s failed: %s)", option, curl_easy_strerror(res));

    curl_easy_cleanup(curl);
    if(headers)
        curl_slist_free_all(headers);

    *can_retry = false;
    return false;
}

static bool send_curl_request(const char *machine_guid, const char *hostname, const char *token, const char *rooms, const char *url, const char *proxy, bool insecure, bool *can_retry) {
    CURL *curl;
    CURLcode res;
    char target_url[2048];
    char public_key[2048] = "";
    size_t public_key_bytes_read = 0;
    FILE *fp;
    struct curl_slist *headers = NULL;

    // create a new random claim id
    nd_uuid_t claimed_id;
    uuid_generate_random(claimed_id);
    char claimed_id_str[UUID_STR_LEN];
    uuid_unparse_lower(claimed_id, claimed_id_str);

    // generate the URL to post
    snprintf(target_url, sizeof(target_url), "%s%sapi/v1/spaces/nodes/%s",
             url, strendswith(url, "/") ? "" : "/", claimed_id_str);

    // Read the public key
    CLEAN_CHAR_P *public_key_file = filename_from_path_entry_strdupz(netdata_configured_cloud_dir, "public.pem");
    fp = fopen(public_key_file, "r");
    if (!fp || (public_key_bytes_read = fread(public_key, 1, sizeof(public_key) - 1, fp)) == 0) {
        claim_agent_failure_reason_set("cannot read public key file '%s'", public_key_file);
        if (fp) fclose(fp);
        *can_retry = false;
        return false;
    }
    public_key[public_key_bytes_read] = '\0';
    fclose(fp);

    // check if we have trusted.pem
    // or cloud_fullchain.pem, for backwards compatibility
    CLEAN_CHAR_P *trusted_key_file = filename_from_path_entry_strdupz(netdata_configured_cloud_dir, "trusted.pem");
    fp = fopen(trusted_key_file, "r");
    if(fp)
        fclose(fp);
    else {
        freez(trusted_key_file);
        trusted_key_file = filename_from_path_entry_strdupz(netdata_configured_cloud_dir, "cloud_fullchain.pem");
        fp = fopen(trusted_key_file, "r");
        if(fp)
            fclose(fp);
        else {
            freez(trusted_key_file);
            trusted_key_file = NULL;
        }
    }

    // generate the JSON request message
    CLEAN_BUFFER *wb = buffer_create(0, NULL);
    buffer_json_initialize(wb, "\"", "\"", 0, true, BUFFER_JSON_OPTIONS_MINIFY);

    buffer_json_member_add_object(wb, "node");
    {
        buffer_json_member_add_string(wb, "id", claimed_id_str);
        buffer_json_member_add_string(wb, "hostname", hostname);
    }
    buffer_json_object_close(wb); // node

    buffer_json_member_add_string(wb, "token", token);
    curl_add_rooms_json_array(wb, rooms);
    buffer_json_member_add_string(wb, "publicKey", public_key);
    buffer_json_member_add_string(wb, "mGUID", machine_guid);
    buffer_json_finalize(wb);

    // initialize libcurl
    curl = curl_easy_init();
    if(!curl) {
        claim_agent_failure_reason_set("Cannot initialize request (curl_easy_init() failed)");
        *can_retry = true;
        return false;
    }

#define CURL_SETOPT_OR_RETURN(option, value)                                                       \
    do {                                                                                           \
        res = curl_easy_setopt(curl, option, value);                                               \
        if(unlikely(res != CURLE_OK))                                                              \
            return cleanup_curl_request_failure(curl, headers, can_retry, res, #option);          \
    } while(0)

    // curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);
    CURL_SETOPT_OR_RETURN(CURLOPT_DEBUGFUNCTION, debug_callback);

    // we will receive the response in this
    CLEAN_BUFFER *response = buffer_create(0, NULL);
    struct claim_response_buffer response_buffer = {
        .wb = response,
        .too_large = false,
    };

    // configure the request
    struct curl_slist *headers_with_content_type = curl_slist_append(headers, "Content-Type: application/json");
    if(unlikely(!headers_with_content_type)) {
        claim_agent_failure_reason_set("Cannot append Content-Type header to the claim request");
        curl_easy_cleanup(curl);
        *can_retry = false;
        return false;
    }
    headers = headers_with_content_type;

    CURL_SETOPT_OR_RETURN(CURLOPT_URL, target_url);
    CURL_SETOPT_OR_RETURN(CURLOPT_CUSTOMREQUEST, "PUT");
    CURL_SETOPT_OR_RETURN(CURLOPT_POSTFIELDS, buffer_tostring(wb));
    CURL_SETOPT_OR_RETURN(CURLOPT_HTTPHEADER, headers);
    CURL_SETOPT_OR_RETURN(CURLOPT_WRITEFUNCTION, response_write_callback);
    CURL_SETOPT_OR_RETURN(CURLOPT_WRITEDATA, &response_buffer);
    CURL_SETOPT_OR_RETURN(CURLOPT_MAXFILESIZE_LARGE, (curl_off_t)CLAIM_RESPONSE_SIZE_LIMIT);

    if(trusted_key_file)
        CURL_SETOPT_OR_RETURN(CURLOPT_CAINFO, trusted_key_file);

    // Proxy configuration
    if (proxy) {
        if (!*proxy || strcmp(proxy, "none") == 0) {
            // disable proxy configuration in libcurl
            CURL_SETOPT_OR_RETURN(CURLOPT_PROXY, "");
            proxy = "none";
        }

        else if (strcmp(proxy, "env") != 0) {
            // set the custom proxy for libcurl
            CURL_SETOPT_OR_RETURN(CURLOPT_PROXY, proxy);
        }

        else {
            // otherwise, libcurl will use its own proxy environment variables
            proxy = "env";
        }
    }

    // Insecure option
    if (insecure) {
        CURL_SETOPT_OR_RETURN(CURLOPT_SSL_VERIFYPEER, 0L);
        CURL_SETOPT_OR_RETURN(CURLOPT_SSL_VERIFYHOST, 0L);
    }

    // Set timeout options
    CURL_SETOPT_OR_RETURN(CURLOPT_TIMEOUT, 10L);
    CURL_SETOPT_OR_RETURN(CURLOPT_CONNECTTIMEOUT, 5L);

#undef CURL_SETOPT_OR_RETURN

    // execute the request
    res = curl_easy_perform(curl);
    if (res != CURLE_OK) {
        bool response_too_large = (res == CURLE_FILESIZE_EXCEEDED || response_buffer.too_large);

        if (response_too_large)
            claim_agent_failure_reason_set("Request failed: response body exceeded %zu bytes\n"
                                           "proxy: '%s',\n"
                                           "insecure: %s,\n"
                                           "public key file: '%s',\n"
                                           "trusted key file: '%s'",
                                           (size_t)CLAIM_RESPONSE_SIZE_LIMIT,
                                           proxy,
                                           insecure ? "true" : "false",
                                           public_key_file ? public_key_file : "none",
                                           trusted_key_file ? trusted_key_file : "none");
        else
            claim_agent_failure_reason_set("Request failed with error: %s\n"
                                           "proxy: '%s',\n"
                                           "insecure: %s,\n"
                                           "public key file: '%s',\n"
                                           "trusted key file: '%s'",
                                           curl_easy_strerror(res),
                                           proxy,
                                           insecure ? "true" : "false",
                                           public_key_file ? public_key_file : "none",
                                           trusted_key_file ? trusted_key_file : "none");
        curl_easy_cleanup(curl);
        curl_slist_free_all(headers);
        *can_retry = !response_too_large;
        return false;
    }

    // Get HTTP response code
    long http_status_code;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_status_code);

    bool ret = false;
    if(http_status_code == 204) {
        if(!cloud_conf_regenerate(claimed_id_str, machine_guid, hostname, token, rooms, url, proxy, insecure)) {
            claim_agent_failure_reason_set("Failed to save claiming info to disk");
        }
        else {
            claim_agent_failure_reason_set(NULL);
            ret = true;
        }

        *can_retry = false;
    }
    else if (http_status_code == 422) {
        if(buffer_strlen(response)) {
            struct json_object *parsed_json;
            struct json_object *error_key_obj;
            const char *error_key = NULL;

            parsed_json = json_tokener_parse(buffer_tostring(response));
            if(parsed_json) {
                if (json_object_object_get_ex(parsed_json, "errorMsgKey", &error_key_obj))
                    error_key = json_object_get_string(error_key_obj);

                if(error_key) {
                    if (strcmp(error_key, "ErrInvalidNodeID") == 0)
                        claim_agent_failure_reason_set("Failed: the node id is invalid");
                    else if (strcmp(error_key, "ErrInvalidNodeName") == 0)
                        claim_agent_failure_reason_set("Failed: the node name is invalid");
                    else if (strcmp(error_key, "ErrInvalidRoomID") == 0)
                        claim_agent_failure_reason_set("Failed: one or more room ids are invalid");
                    else if (strcmp(error_key, "ErrInvalidPublicKey") == 0)
                        claim_agent_failure_reason_set("Failed: the public key is invalid");
                    else
                        claim_agent_failure_reason_set("Failed with description '%s'", error_key);
                }
                else
                    claim_agent_failure_reason_set("Failed with a response code %ld", http_status_code);

                json_object_put(parsed_json);
            }
            else
                claim_agent_failure_reason_set("Failed with a response code %ld", http_status_code);
        }
        else
            claim_agent_failure_reason_set("Failed with an empty response, code %ld", http_status_code);

        *can_retry = false;
    }
    else if(http_status_code == 102) {
        claim_agent_failure_reason_set("Claiming is in progress");
        *can_retry = false;
    }
    else if(http_status_code == 403) {
        claim_agent_failure_reason_set("Failed: token is expired, not found, or invalid");
        *can_retry = false;
    }
    else if(http_status_code == 409) {
        claim_agent_failure_reason_set("Failed: agent is already claimed");
        *can_retry = false;
    }
    else if(http_status_code == 500) {
        claim_agent_failure_reason_set("Failed: received Internal Server Error");
        *can_retry = true;
    }
    else if(http_status_code == 503) {
        claim_agent_failure_reason_set("Failed: Netdata Cloud is unavailable");
        *can_retry = true;
    }
    else if(http_status_code == 504) {
        claim_agent_failure_reason_set("Failed: Gateway Timeout");
        *can_retry = true;
    }
    else {
        claim_agent_failure_reason_set("Failed with response code %ld", http_status_code);
        *can_retry = true;
    }

    curl_easy_cleanup(curl);
    curl_slist_free_all(headers);
    return ret;
}

bool claim_agent(const char *url, const char *token, const char *rooms, const char *proxy, bool insecure) {
    static SPINLOCK spinlock = SPINLOCK_INITIALIZER;
    spinlock_lock(&spinlock);

    if (!check_and_generate_certificates()) {
        spinlock_unlock(&spinlock);
        return false;
    }

    bool done = false, can_retry = true;
    size_t retries = 0;
    do {
        done = send_curl_request(machine_guid_get_txt(), registry_get_this_machine_hostname(), token, rooms, url, proxy, insecure, &can_retry);
        if (done) break;
        sleep_usec(300 * USEC_PER_MS + 100 * retries * USEC_PER_MS);
        retries++;
    } while(can_retry && retries < 5);

    spinlock_unlock(&spinlock);
    return done;
}

static bool claim_extra_opts_has_token(const char *opts, const char *token, size_t token_len) {
    while(opts && *opts) {
        while(isspace((uint8_t)*opts))
            opts++;

        const char *end = opts;
        while(*end && !isspace((uint8_t)*end))
            end++;

        if((size_t)(end - opts) == token_len && memcmp(opts, token, token_len) == 0)
            return true;

        opts = end;
    }

    return false;
}

bool claim_agent_from_environment(void) {
    const char *url = getenv("NETDATA_CLAIM_URL");
    if(!url || !*url) {
        url = inicfg_get(&cloud_config, CONFIG_SECTION_GLOBAL, "url", DEFAULT_CLOUD_BASE_URL);
        if(!url || !*url) return false;
    }

    const char *token = getenv("NETDATA_CLAIM_TOKEN");
    if(!token || !*token)
        return false;

    const char *rooms = getenv("NETDATA_CLAIM_ROOMS");
    if(!rooms)
        rooms = "";

    const char *proxy = getenv("NETDATA_CLAIM_PROXY");
    if(!proxy || !*proxy)
        proxy = "env";

    bool insecure = CONFIG_BOOLEAN_NO;
    const char *from_env = getenv("NETDATA_EXTRA_CLAIM_OPTS");
    if(claim_extra_opts_has_token(from_env, "-insecure", sizeof("-insecure") - 1))
        insecure = CONFIG_BOOLEAN_YES;

    return claim_agent(url, token, rooms, proxy, insecure);
}

// Static config for claim.conf
static struct config claim_config = APPCONFIG_INITIALIZER;

// Function to free the static claim_config for shutdown cleanup
void claim_config_free(void) {
    inicfg_free(&claim_config);
}

bool claim_agent_from_claim_conf(void) {
    static SPINLOCK spinlock = SPINLOCK_INITIALIZER;
    bool ret = false;

    spinlock_lock(&spinlock);

    errno_clear();
    char *filename = filename_from_path_entry_strdupz(netdata_configured_user_config_dir, "claim.conf");
    bool loaded = inicfg_load(&claim_config, filename, 1, NULL);
    freez(filename);

    if(loaded) {
        const char *url = inicfg_get(&claim_config, CONFIG_SECTION_GLOBAL, "url", DEFAULT_CLOUD_BASE_URL);
        const char *token = inicfg_get(&claim_config, CONFIG_SECTION_GLOBAL, "token", "");
        const char *rooms = inicfg_get(&claim_config, CONFIG_SECTION_GLOBAL, "rooms", "");
        const char *proxy = inicfg_get(&claim_config, CONFIG_SECTION_GLOBAL, "proxy", "env");
        bool insecure = inicfg_get_boolean(&claim_config, CONFIG_SECTION_GLOBAL, "insecure", CONFIG_BOOLEAN_NO);

        if(token && *token && url && *url)
            ret = claim_agent(url, token, rooms, proxy, insecure);
    }

    spinlock_unlock(&spinlock);

    return ret;
}

bool claim_agent_from_split_files(void) {
    char filename[FILENAME_MAX + 1];

    snprintfz(filename, sizeof(filename), "%s/token", netdata_configured_cloud_dir);
    long token_len = 0;
    char *token = read_by_filename(filename, &token_len);
    if(!token || !*token) {
        freez(token);
        return false;
    }

    snprintfz(filename, sizeof(filename), "%s/rooms", netdata_configured_cloud_dir);
    long rooms_len = 0;
    char *rooms = read_by_filename(filename, &rooms_len);
    if(!rooms || !*rooms) {
        freez(rooms);
        rooms = NULL;
    }

    bool ret = claim_agent(cloud_config_url_get(), token, rooms, cloud_config_proxy_get(), cloud_config_insecure_get());

    if(ret) {
        snprintfz(filename, sizeof(filename), "%s/token", netdata_configured_cloud_dir);
        unlink(filename);

        snprintfz(filename, sizeof(filename), "%s/rooms", netdata_configured_cloud_dir);
        unlink(filename);
    }

    freez(token);
    freez(rooms);

    return ret;
}

bool claim_agent_automatically(void) {
    // Use /etc/netdata/claim.conf

    if(claim_agent_from_claim_conf())
        return true;

    // Users may set NETDATA_CLAIM_TOKEN and NETDATA_CLAIM_ROOMS
    // A good choice for docker container users.

    if(claim_agent_from_environment())
        return true;

    // Users may store token and rooms in /var/lib/netdata/cloud.d
    // This was a bad choice, since users may have to create this directory
    // which may end up with the wrong permissions, preventing netdata from storing
    // the required information there.

    if(claim_agent_from_split_files())
        return true;

    return false;
}
