Files
tf_provider/HISTORY/SONNET/0107.md
T

21 KiB
Raw Blame History

Вот, отправь Соннету:


Контекст: Пишем Terraform Provider для Nubes Cloud (ColdFusion API). Провайдер на Go, версия 5.0.66, опубликован в registry terra.k8c.ru/nubes-test/nubes.

Суть проблемы: terraform apply для создания S3-бакета падает с ошибкой. Прошли путь от 403 до EOF.

Хронология:

  1. HTTP 403 — DDoS-Guard блокирует Go crypto/tls (JA3 fingerprint). curl с того же сервера работает.
  2. Поменяли URL с index.cfm/instances на index.cfm?endpoint=/instances — 403 остался.
  3. Добавили utls.HelloFirefox_120403 ушёл, но стал EOF (сервер закрывает соединение без ответа).
  4. Убрали utls, оставили InsecureSkipVerify: trueEOF.
  5. Вернули REST-путь (index.cfm/instances) + InsecureSkipVerify: trueEOF.

Что работает: curl с токеном напрямую с того же сервера:

curl -H "Authorization: Bearer $TOKEN" -H "User-Agent: Mozilla/5.0" \
  "https://deck-api-test.ngcloud.ru/api/v1/index.cfm?endpoint=/instances&page=1&size=100"
# → JSON, 200 OK

Код: doRequest() в client.go (строка 831). Использует http.DefaultTransport.Clone() с TLSClientConfig{InsecureSkipVerify: true, MinVersion: TLS1.2}.

Вопросы:

  1. Почему Go HTTP клиент (с идентичными заголовками) получает EOF там, где curl получает JSON?
  2. Может ли ColdFusion-сервер требовать определённый порядок заголовков или HTTP-метод (POST вместо GET)?
  3. Как заставить Go http.Client отправлять точно такие же запросы как curl? Может, через net/http/httputil дампить запрос?

Файлы:

  • client.go
  • provider.go

Список задач обновлен

Searched for files matching **/client.go, 2 matches

Read memory

Searched for text doRequest|DoRequest (**/universal_rebuild/internal/core/client.go), 20 results

Searched for text doRequest|DoRequest (**/internal/core/client.go), 20 results

Read , lines 800 to 900

Read , lines 177 to 260

Searched for text HttpClient|NewClient|InsecureSkipVerify|TLSClientConfig|DefaultTransport (**/universal_rebuild/internal/core/client.go), 5 results

Searched for text HttpClient|NewClient|InsecureSkipVerify|TLSClientConfig|DefaultTransport (**/internal/core/client.go), 7 results

Searched for text HttpClient|NewUniversalClient|InsecureSkipVerify|TLSClientConfig|DefaultTransport|http.Client{ (**/universal_rebuild/internal/provider/**), 11 results

Searched for text HttpClient|NewUniversalClient|InsecureSkipVerify|TLSClientConfig|DefaultTransport|http.Client{ (**/internal/provider/**), 20 results

Read , lines 105 to 165

Теперь посмотрю на S3-ресурс и полный URL, чтобы понять что именно запрашивается:

Searched for text s3|bucket|S3|Bucket (**/universal_rebuild/internal/core/client.go), 8 results

Read , lines 1 to 50

Теперь у меня достаточно контекста. Сформирую план.

Created memory file

Анализ и план

Корневая причина EOF

Главный виновник — HTTP/2.

http.DefaultTransport.Clone() копирует ссылку на TLSNextProto map, в которой зарегистрирован HTTP/2 (h2) handler. При TLS-хендшейке Go предлагает h2 в ALPN-расширении. ColdFusion + DDoS-Guard не поддерживают HTTP/2 — сервер принимает TLS-соединение, но сразу закрывает его без HTTP-ответа → EOF.

Именно поэтому curl работает: он по умолчанию использует HTTP/1.1 и не предлагает h2 в ALPN.

InsecureSkipVerify здесь роли не играет — он влияет только на проверку сертификата, не на TLS-хендшейк.


Ответы на вопросы

1. Почему Go получает EOF, а curl — нет?
Go через ALPN предлагает ["h2", "http/1.1"]. Сервер видит h2 и закрывает соединение (ColdFusion не понимает HTTP/2). curl предлагает только ["http/1.1"] — сервер отвечает нормально.

2. Может ли ColdFusion требовать особый порядок заголовков?
Маловероятно причина. Порядок заголовков важен для WAAP/DDoS-Guard fingerprinting, но если 403 ушёл, то DDoS-Guard вас пропускает, а EOF — это именно HTTP/2 на уровне приложения.

3. Как заставить Go отправлять то же, что curl?
Двумя действиями ниже.


Plan: Fix EOF (2 изменения)

Файлы к изменению:

  • provider.go — строка ~124
  • client.go — строка ~836 (doRequest)

Шаг 1 — Отключить HTTP/2 (приоритет #1, наиболее вероятный фикс)

В provider.go после transport := http.DefaultTransport.(*http.Transport).Clone() добавить две строки:

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSHandshakeTimeout = 60 * time.Second
// Отключаем HTTP/2: ColdFusion + DDoS-Guard не поддерживают h2 → EOF
transport.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
transport.ForceAttemptHTTP2 = false  // запрещаем upgrade

ForceAttemptHTTP2 = false одного недостаточно — нужен именно пустой TLSNextProto, иначе h2 всё равно остаётся в ALPN.

Шаг 2 — Убрать Content-Type на GET (приоритет #2, возможная причина WAF-блокировки)

В doRequest() в client.go заголовок Content-Type выставлять только если payload != nil:

if payload != nil {
    req.Header.Set("Content-Type", "application/json")
}

Шаг 3 — Debug dump (если 1+2 не помогают)

Добавить через env-флаг NUBES_DEBUG_HTTP=1:

if os.Getenv("NUBES_DEBUG_HTTP") == "1" {
    dump, _ := httputil.DumpRequestOut(req, true)
    fmt.Fprintf(os.Stderr, ">>> REQUEST:\n%s\n", dump)
}

Потом сравнить вывод с curl -v --http1.1 -H "Authorization: Bearer ..." ....


Верификация:

  1. terraform apply для S3-бакета — нет EOF
  2. curl -v --http2 <url> — проверить что с --http2 curl тоже получает EOF (подтвердит гипотезу)
  3. terraform plan после успешного apply

Ждать «делай» для реализации?

Создано задач: 3

Вопрос Соннету:


Контекст: Terraform Provider на Go. API: ColdFusion за DDoS-Guard.

Что сделали:

  • TLSNextProto = make(map[string]...) — GET работает (200 JSON)
  • ?endpoint= формат URL — как у генератора
  • User-Agent: Mozilla/5.0

Проблема: GET → 200. POST → 403. curl (GET и POST) → оба 200.

Код POST:

func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) {
    // payload — json-сериализованная структура
    req, _ := http.NewRequestWithContext(ctx, method, c.ApiEndpoint, body)
    // ?endpoint=/instances
    q := req.URL.Query()
    q.Set("endpoint", path)
    req.URL.RawQuery = q.Encode()
    
    req.Header.Set("User-Agent", "Mozilla/5.0")
    if body != nil {
        req.Header.Set("Content-Type", "application/json")
    }
    req.Header.Set("Authorization", "Bearer "+token)
}

Работающий curl:

curl -X POST \
  -H "User-Agent: Mozilla/5.0" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"serviceId":13}' \
  "https://deck-api-test.ngcloud.ru/api/v1/index.cfm?endpoint=/instances"

Вопрос: Что в Go http.Client (c TLSNextProto = make(...)) может вызывать 403 на POST, при том что GET работает, и curl POST тоже работает? Может ли http.NewRequestWithContext добавлять заголовки/байты, которые триггерят DDoS-Guard на POST но не на GET?

Список задач обновлен

Прочитаю актуальный код doRequest и инициализацию transport, чтобы ответить точно, а не по памяти:

Read , lines 804 to 880

Read , lines 122 to 155

Диагноз: 3 кандидата, #1 самый вероятный

Код виден полностью. Вот что реально отличает Go POST от curl POST.


#1 (главный подозреваемый): url.Values.Encode() кодирует / как %2F

q.Set("endpoint", endpointPath)  // endpointPath = "/instances"
req.URL.RawQuery = q.Encode()    // → endpoint=%2Finstances

curl отправляет: ?endpoint=/instances
Go отправляет: ?endpoint=%2Finstances

Почему GET проходит, а POST нет? DDoS-Guard WAF для POST-запросов почти всегда имеет allowlist эндпоинтов — проверяет, что endpoint соответствует разрешённым путям. /instances совпадает, %2Finstances — нет → 403. GET-запросы проверяются слабее (read-only, меньший риск).


#2: Нет заголовка Accept

curl всегда добавляет Accept: */* автоматически.
Ваш Go-код его не ставит.

DDoS-Guard fingerprinting видит: POST с телом + нет Accept → нет Accept = нет браузера = потенциальный бот.


#3: Accept-Encoding: gzip добавляет transport

Т.к. DisableCompression не выставлен, transport добавляет Accept-Encoding: gzip ко всем запросам. Для GET это тоже есть → GET проходит → вероятно, не причина. Но на всякий случай стоит проверить.


Как проверить немедленно

DumpRequestOut уже в коде:

NUBES_DEBUG_HTTP=1 terraform apply 2>&1 | grep -A 30 ">>> REQ POST"

И сравнить с:

curl -v -X POST -H "User-Agent: Mozilla/5.0" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"serviceId":13}' \
  "https://deck-api-test.ngcloud.ru/api/v1/index.cfm?endpoint=/instances" 2>&1 | grep -E "^>|^<"

Что менять

Fix #1 — не использовать q.Encode() для endpoint:

// Вместо q.Set("endpoint",...) + q.Encode()
rawQuery := "endpoint=" + endpointPath   // слэш не кодируется
if extraQuery != "" {
    rawQuery += "&" + extraQuery
}
req.URL.RawQuery = rawQuery

Fix #2 — добавить Accept: */* (как curl):

req.Header.Set("Accept", "*/*")

Fix #3 (опционально) — отключить автоматическое gzip в transport:

transport.DisableCompression = true

Итог

Причина Почему GET ок Почему POST 403
%2Finstances в URL WAF для GET мягче WAF для POST проверяет allowlist путей
Нет Accept То же Browser fingerprint не проходит
Connection: close - (здесь req.Close = true нет, уже убрано)

Начинать с Fix #1 — вероятность ~70% что это именно оно.

Создано задач: 4

Вопрос Соннету:


Контекст: Terraform Provider Go. API за DDoS-Guard. GET работает, POST — 403.

Проверено:

  • TLSNextProto = make(...) GET 200
  • ?endpoint=/instances (не %2F) —
  • User-Agent: Mozilla/5.0
  • Accept: */*
  • curl POST (те же URL, тело, заголовки) — 200/400
  • Go-тест POST (отдельная программа, тот же TLS-конфиг) — 201

Провайдер: doRequest()json.Marshal(payload)bytes.NewBuffer(b)http.NewRequestWithContext(ctx, "POST", url, body)c.HttpClient.Do(req)403.

Код:

// provider.go
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSNextProto = make(map[string]func(...))
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}
client := &core.UniversalClient{
    HttpClient: &http.Client{Transport: transport, Timeout: 300 * time.Second},
    ...
}

// client.go doRequest()
b, _ := json.Marshal(payload)
body := bytes.NewBuffer(b)
req, _ := http.NewRequestWithContext(ctx, "POST", c.ApiEndpoint, body)
req.URL.RawQuery = "endpoint=/instances"
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Accept", "*/*")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.HttpClient.Do(req)

Вопрос: Что в Go http.Client с кастомным Transport (пустой TLSNextProto + TLSClientConfig) может вызывать 403 только на POST, при том что:

  1. Отдельная Go-программа с идентичным TLS-конфигом делает POST успешно (201)
  2. curl делает POST успешно
  3. GET-запросы через тот же doRequest работают

Есть ли способ дампнуть сырые байты TLS-хендшейка или HTTP-запроса из провайдера (не из тестовой программы), чтобы сравнить с curl?

Список задач обновлен

Ключевой факт: standalone Go-программа с тем же кодом → 201, провайдер → 403. Значит причина не в TLS-конфиге и не в заголовках — она в окружении или состоянии самого провайдера.


Четыре кандидата

#1 (самый вероятный): HTTPS_PROXY в окружении Terraform

http.DefaultTransport.Clone() копирует Proxy: http.ProxyFromEnvironment. Если при terraform apply выставлена переменная HTTPS_PROXY, трафик пойдёт через прокси. Прокси может GET пропускать (кэш), а POST — блокировать или трансформировать.

Standalone-тест работает? Проверьте, запускался ли он в том же shell с теми же env:

env | grep -i proxy

Фикс — убрать прокси из transport:

transport.Proxy = nil  // добавить в provider.go

#2: DefaultTransport уже модифицирован до Clone()

Terraform Plugin Framework инициализирует gRPC-транспорт и может изменить глобальный http.DefaultTransport до того, как ваш Configure его клонирует. Standalone-тест стартует с чистым DefaultTransport.

Фикс — не клонировать DefaultTransport, создать с нуля:

transport := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
    }).DialContext,
    TLSHandshakeTimeout:   60 * time.Second,
    ResponseHeaderTimeout: 60 * time.Second,
    MaxIdleConnsPerHost:   10,
    IdleConnTimeout:       90 * time.Second,
    TLSNextProto:          make(map[string]func(authority string, c *tls.Conn) http.RoundTripper),
    TLSClientConfig: &tls.Config{
        InsecureSkipVerify: insecureSkipVerify,
        MinVersion:         tls.VersionTLS12,
    },
}

#3: Повторное использование TCP-соединения (keep-alive)

Провайдер: клиент создаётся один раз → GET занимает соединение → POST переиспользует то же соединение из пула. DDoS-Guard или ColdFusion может иметь правило: POST на keep-alive соединении после GET = подозрительно.

Standalone-тест: свежий клиент → каждый запрос на новом соединении.

Быстрая проверка — добавить req.Close = true (форсирует новое соединение на каждый запрос):

req.Close = true

Если после этого POST пройдёт — причина именно в connection reuse.


#4: Параллельность в Terraform

Terraform может запускать несколько Create параллельно. Rapid-fire POST'ы с одного IP → DDoS-Guard rate limit → 403. Standalone-тест делает один POST.


Как дампить реальные байты из провайдера

Уровень HTTP (уже есть, но улучшить)

Добавить дамп и ответа:

if os.Getenv("NUBES_DEBUG_HTTP") == "1" {
    dump, _ := httputil.DumpRequestOut(req, true)
    fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, req.URL.String(), dump)

    resp, err := c.HttpClient.Do(req)
    
    respDump, _ := httputil.DumpResponse(resp, false) // false = без тела
    fmt.Fprintf(os.Stderr, "\n<<< RESP %d\n%s\n", resp.StatusCode, respDump)
    return resp, err
}

Уровень TLS (сырые байты + расшифровка через Wireshark)

// В provider.go при создании transport:
if keylogPath := os.Getenv("SSLKEYLOGFILE"); keylogPath != "" {
    f, _ := os.OpenFile(keylogPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
    transport.TLSClientConfig.KeyLogWriter = f
}

Запуск:

# Терминал 1: захват трафика
sudo tcpdump -i any -w /tmp/cap.pcap 'host deck-api-test.ngcloud.ru'

# Терминал 2: terraform apply
SSLKEYLOGFILE=/tmp/sslkeys.log terraform apply

# После: открыть cap.pcap в Wireshark
# Edit → Preferences → TLS → (Pre)-Master-Secret log: /tmp/sslkeys.log

Уровень TCP (кастомный DialContext — логировать сырые байты)

transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
    c, err := (&net.Dialer{Timeout: 30 * time.Second}).DialContext(ctx, network, addr)
    if err != nil || os.Getenv("NUBES_DEBUG_HTTP") != "1" {
        return c, err
    }
    return &loggingConn{Conn: c, tag: addr}, nil
}

type loggingConn struct {
    net.Conn
    tag string
}
func (lc *loggingConn) Write(b []byte) (int, error) {
    fmt.Fprintf(os.Stderr, "TCP→%s WRITE %d bytes: %q\n", lc.tag, len(b), b)
    return lc.Conn.Write(b)
}
func (lc *loggingConn) Read(b []byte) (int, error) {
    n, err := lc.Conn.Read(b)
    fmt.Fprintf(os.Stderr, "TCP←%s READ %d bytes: %q\n", lc.tag, n, b[:n])
    return n, err
}

Что делать прямо сейчас

# Шаг 1: проверить прокси
env | grep -iE "proxy|https_proxy|http_proxy"

# Шаг 2: если прокси нет, тест без connection reuse
# добавить req.Close = true в doRequest, пересобрать, apply

# Шаг 3: если не помогло — дамп
NUBES_DEBUG_HTTP=1 terraform apply 2>&1 | grep -A 40 ">>> REQ POST"
# сравнить с:
curl -v -X POST ... 2>&1 | grep -E "^>|^<"

Разница в одну строку в дампе покажет причину.