Files
tf_provider/HISTORY/SONNET/0107.md
T

500 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Вот, отправь Соннету:
---
**Контекст**: Пишем 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_120`**403 ушёл**, но стал `EOF` (сервер закрывает соединение без ответа).
4. Убрали utls, оставили `InsecureSkipVerify: true``EOF`.
5. Вернули REST-путь (`index.cfm/instances`) + `InsecureSkipVerify: true``EOF`.
**Что работает**: curl с токеном напрямую с того же сервера:
```bash
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 [](file:///home/naeel/.vscode-server/data/User/workspaceStorage/358cf615179442f06eab7245b983b753/GitHub.copilot-chat/memory-tool/memories/repo/tf_provider_inventory.md)
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 [](file:///home/naeel/tf_provider/universal_rebuild/internal/core/client.go#800-800), lines 800 to 900
Read [](file:///home/naeel/tf_provider/internal/core/client.go#177-177), 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 [](file:///home/naeel/tf_provider/universal_rebuild/internal/provider/provider.go#105-105), lines 105 to 165
Теперь посмотрю на S3-ресурс и полный URL, чтобы понять что именно запрашивается:
Searched for text `s3|bucket|S3|Bucket` (`**/universal_rebuild/internal/core/client.go`), 8 results
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/core/client.go#1-1), lines 1 to 50
Теперь у меня достаточно контекста. Сформирую план.
Created memory file [](file:///home/naeel/.vscode-server/data/User/workspaceStorage/358cf615179442f06eab7245b983b753/GitHub.copilot-chat/memory-tool/memories/MjE3NzcyMzEtOGRkYy00ODA4LWI0OGItOGEyZDliNmU5YjFl/plan.md)
## Анализ и план
### Корневая причина 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()` добавить две строки:
```go
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:
```go
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
```
**Шаг 3 — Debug dump** (если 1+2 не помогают)
Добавить через env-флаг `NUBES_DEBUG_HTTP=1`:
```go
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**:
```go
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**:
```bash
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 [](file:///home/naeel/tf_provider/universal_rebuild/internal/core/client.go#804-804), lines 804 to 880
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/provider/provider.go#122-122), lines 122 to 155
## Диагноз: 3 кандидата, #1 самый вероятный
Код виден полностью. Вот что реально отличает Go POST от curl POST.
---
### #1 (главный подозреваемый): `url.Values.Encode()` кодирует `/` как `%2F`
```go
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` уже в коде:
```bash
NUBES_DEBUG_HTTP=1 terraform apply 2>&1 | grep -A 30 ">>> REQ POST"
```
И сравнить с:
```bash
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:**
```go
// Вместо q.Set("endpoint",...) + q.Encode()
rawQuery := "endpoint=" + endpointPath // слэш не кодируется
if extraQuery != "" {
rawQuery += "&" + extraQuery
}
req.URL.RawQuery = rawQuery
```
**Fix #2 — добавить `Accept: */*`** (как curl):
```go
req.Header.Set("Accept", "*/*")
```
**Fix #3 (опционально) — отключить автоматическое gzip в transport:**
```go
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**.
**Код**:
```go
// 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:
```bash
env | grep -i proxy
```
Фикс — убрать прокси из transport:
```go
transport.Proxy = nil // добавить в provider.go
```
---
### #2: `DefaultTransport` уже модифицирован до `Clone()`
Terraform Plugin Framework инициализирует gRPC-транспорт и **может изменить глобальный `http.DefaultTransport`** до того, как ваш `Configure` его клонирует. Standalone-тест стартует с чистым DefaultTransport.
Фикс — не клонировать DefaultTransport, создать с нуля:
```go
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` (форсирует новое соединение на каждый запрос):
```go
req.Close = true
```
Если после этого POST пройдёт — причина именно в connection reuse.
---
### #4: Параллельность в Terraform
Terraform может запускать несколько `Create` параллельно. Rapid-fire POST'ы с одного IP → DDoS-Guard rate limit → 403. Standalone-тест делает один POST.
---
## Как дампить реальные байты из провайдера
### Уровень HTTP (уже есть, но улучшить)
Добавить дамп и ответа:
```go
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)
```go
// В 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
}
```
Запуск:
```bash
# Терминал 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 — логировать сырые байты)
```go
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
}
```
---
## Что делать прямо сейчас
```bash
# Шаг 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 "^>|^<"
```
Разница в одну строку в дампе покажет причину.