diff --git a/HISTORY/SONNET/0107.md b/HISTORY/SONNET/0107.md new file mode 100644 index 0000000..981f5c9 --- /dev/null +++ b/HISTORY/SONNET/0107.md @@ -0,0 +1,150 @@ +Вот, отправь Соннету: + +--- + +**Контекст**: Пишем 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 ` — проверить что с `--http2` curl тоже получает EOF (подтвердит гипотезу) +3. `terraform plan` после успешного apply + +--- + +Ждать «делай» для реализации? + +Создано задач: 3 + diff --git a/TEST_STAND/buck0/main.tf b/TEST_STAND/buck0/main.tf index d0a7838..a772615 100644 --- a/TEST_STAND/buck0/main.tf +++ b/TEST_STAND/buck0/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { nubes = { source = "terra.k8c.ru/nubes-test/nubes" - version = "5.0.66" + version = "5.0.68" } } } diff --git a/universal_rebuild/internal/core/client.go b/universal_rebuild/internal/core/client.go index ab663eb..0a950ac 100644 --- a/universal_rebuild/internal/core/client.go +++ b/universal_rebuild/internal/core/client.go @@ -507,7 +507,7 @@ func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, service if len(found) == 0 { page := 1 for { - reqURL := fmt.Sprintf("%s?endpoint=/instances&page=%d&size=100", c.ApiEndpoint, page) + reqURL := fmt.Sprintf("%s/instances?page=%d&size=100", c.ApiEndpoint, page) req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, err @@ -581,7 +581,7 @@ func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, service } func (c *UniversalClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - url := fmt.Sprintf("%s?endpoint=/instances/%s", c.ApiEndpoint, instanceUid) + url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err @@ -627,7 +627,7 @@ func isInstanceDeleted(state *InstanceStateResponse) bool { // GetInstanceStateRaw получает состояние инстанса БЕЗ валидации статуса. // Используется для проверки ref-параметров: нужно читать даже deleted/suspended инстансы. func (c *UniversalClient) GetInstanceStateRaw(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - reqURL := fmt.Sprintf("%s?endpoint=/instances/%s", c.ApiEndpoint, instanceUid) + reqURL := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, err @@ -828,21 +828,15 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa body = bytes.NewBuffer(b) } - req, err := http.NewRequestWithContext(ctx, method, c.ApiEndpoint, body) + req, err := http.NewRequestWithContext(ctx, method, c.ApiEndpoint+path, body) if err != nil { return nil, nil, err } - // ?endpoint= формат — как генератор, чтобы пройти DDoS-Guard - q := req.URL.Query() - q.Set("endpoint", path) - req.URL.RawQuery = q.Encode() - - req.Close = false - req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", userAgent) - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } if c.ApiToken != "" { req.Header.Set("Authorization", "Bearer "+c.ApiToken) } diff --git a/universal_rebuild/internal/provider/provider.go b/universal_rebuild/internal/provider/provider.go index 74b8cb1..55c84e2 100644 --- a/universal_rebuild/internal/provider/provider.go +++ b/universal_rebuild/internal/provider/provider.go @@ -1,13 +1,9 @@ package provider import ( - "bufio" "context" "crypto/tls" - "fmt" - "net" "net/http" - "net/url" "os" "strings" "time" @@ -21,7 +17,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" - utls "github.com/refraction-networking/utls" ) var _ provider.Provider = &NubesProvider{} @@ -128,59 +123,12 @@ func (p *NubesProvider) Configure(ctx context.Context, req provider.ConfigureReq // HTTP transport: клонируем DefaultTransport чтобы сохранить системные настройки (proxy, timeouts). transport := http.DefaultTransport.(*http.Transport).Clone() transport.TLSHandshakeTimeout = 60 * time.Second - transport.ForceAttemptHTTP2 = false - - // utls: маскируем Go TLS под Firefox, чтобы пройти DDoS-Guard (JA3 fingerprint). - // Стандартный crypto/tls и Chrome блокируются на deck-api-*.ngcloud.ru. - transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - // Используем прокси если задан (HTTPS_PROXY) — без него API не доступен - dialer := &net.Dialer{Timeout: 30 * time.Second} - proxyFunc := transport.Proxy - target := addr - if proxyFunc != nil { - proxy, err := proxyFunc(&http.Request{URL: &url.URL{Scheme: "https", Host: target}}) - if err == nil && proxy != nil { - conn, err := dialer.DialContext(ctx, "tcp", proxy.Host) - if err != nil { - return nil, err - } - // CONNECT tunnel - fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target) - br := bufio.NewReader(conn) - resp, err := http.ReadResponse(br, nil) - if err != nil || resp.StatusCode != 200 { - conn.Close() - return nil, fmt.Errorf("proxy CONNECT failed: %v", err) - } - host, _, _ := net.SplitHostPort(target) - uconn := utls.UClient(conn, &utls.Config{ - ServerName: host, - InsecureSkipVerify: insecureSkipVerify, - MinVersion: tls.VersionTLS12, - }, utls.HelloFirefox_120) - if err := uconn.HandshakeContext(ctx); err != nil { - conn.Close() - return nil, err - } - return uconn, nil - } - } - // Прямое соединение (без прокси) - conn, err := dialer.DialContext(ctx, network, addr) - if err != nil { - return nil, err - } - host, _, _ := net.SplitHostPort(addr) - uconn := utls.UClient(conn, &utls.Config{ - ServerName: host, - InsecureSkipVerify: insecureSkipVerify, - MinVersion: tls.VersionTLS12, - }, utls.HelloFirefox_120) - if err := uconn.HandshakeContext(ctx); err != nil { - conn.Close() - return nil, err - } - return uconn, nil + // Отключаем HTTP/2: ColdFusion + DDoS-Guard не поддерживают h2 → EOF. + // Пустой TLSNextProto убирает h2 из ALPN → только HTTP/1.1. + transport.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper) + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: insecureSkipVerify, + MinVersion: tls.VersionTLS12, } // Определяем уровень логирования операций: none (тихий) / info / debug. diff --git a/universal_rebuild/main.go b/universal_rebuild/main.go index c30aa42..e4f8b96 100644 --- a/universal_rebuild/main.go +++ b/universal_rebuild/main.go @@ -10,7 +10,7 @@ import ( ) var ( - version string = "5.0.66" + version string = "5.0.68" ) func main() {