21 KiB
Вот, отправь Соннету:
Контекст: Пишем Terraform Provider для Nubes Cloud (ColdFusion API). Провайдер на Go, версия 5.0.66, опубликован в registry terra.k8c.ru/nubes-test/nubes.
Суть проблемы: terraform apply для создания S3-бакета падает с ошибкой. Прошли путь от 403 до EOF.
Хронология:
HTTP 403— DDoS-Guard блокирует Gocrypto/tls(JA3 fingerprint). curl с того же сервера работает.- Поменяли URL с
index.cfm/instancesнаindex.cfm?endpoint=/instances— 403 остался. - Добавили
utls.HelloFirefox_120— 403 ушёл, но сталEOF(сервер закрывает соединение без ответа). - Убрали utls, оставили
InsecureSkipVerify: true—EOF. - Вернули REST-путь (
index.cfm/instances) +InsecureSkipVerify: true—EOF.
Что работает: 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}.
Вопросы:
- Почему Go HTTP клиент (с идентичными заголовками) получает EOF там, где curl получает JSON?
- Может ли ColdFusion-сервер требовать определённый порядок заголовков или HTTP-метод (POST вместо GET)?
- Как заставить Go
http.Clientотправлять точно такие же запросы как curl? Может, черезnet/http/httputilдампить запрос?
Файлы:
- client.go
- provider.go
Список задач обновлен
Searched for files matching **/client.go, 2 matches
Searched for text doRequest|DoRequest (**/universal_rebuild/internal/core/client.go), 20 results
Searched for text doRequest|DoRequest (**/internal/core/client.go), 20 results
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
Теперь посмотрю на S3-ресурс и полный URL, чтобы понять что именно запрашивается:
Searched for text s3|bucket|S3|Bucket (**/universal_rebuild/internal/core/client.go), 8 results
Теперь у меня достаточно контекста. Сформирую план.
Анализ и план
Корневая причина 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 ..." ....
Верификация:
terraform applyдля S3-бакета — нет EOFcurl -v --http2 <url>— проверить что с--http2curl тоже получает EOF (подтвердит гипотезу)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, чтобы ответить точно, а не по памяти:
Диагноз: 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, при том что:
- Отдельная Go-программа с идентичным TLS-конфигом делает POST успешно (201)
- curl делает POST успешно
- 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 "^>|^<"
Разница в одну строку в дампе покажет причину.