v5.0.68: TLSNextProto fix — disable HTTP/2 ALPN (DDoS-Guard EOF)

This commit is contained in:
“Naeel”
2026-07-01 15:40:09 +04:00
parent e06e8de134
commit 7862ea7e45
5 changed files with 165 additions and 73 deletions
+150
View File
@@ -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 <url>` — проверить что с `--http2` curl тоже получает EOF (подтвердит гипотезу)
3. `terraform plan` после успешного apply
---
Ждать «делай» для реализации?
Создано задач: 3
+1 -1
View File
@@ -2,7 +2,7 @@ terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes-test/nubes"
version = "5.0.66"
version = "5.0.68"
}
}
}
+7 -13
View File
@@ -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)
}
@@ -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.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
var (
version string = "5.0.66"
version string = "5.0.68"
)
func main() {