diff --git a/HISTORY/SONNET/0107.md b/HISTORY/SONNET/0107.md index 981f5c9..4c7ea0a 100644 --- a/HISTORY/SONNET/0107.md +++ b/HISTORY/SONNET/0107.md @@ -148,3 +148,145 @@ if os.Getenv("NUBES_DEBUG_HTTP") == "1" { Создано задач: 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 diff --git a/TEST_STAND/buck0/main.tf b/TEST_STAND/buck0/main.tf index 21eae5e..78e82da 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.70" + version = "5.0.71" } } } diff --git a/universal_rebuild/internal/core/client.go b/universal_rebuild/internal/core/client.go index 8534a34..8f8a1d4 100644 --- a/universal_rebuild/internal/core/client.go +++ b/universal_rebuild/internal/core/client.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/http/httputil" "net/url" "os" "regexp" @@ -833,27 +834,21 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa return nil, nil, err } - // ?endpoint= формат — как генератор (Python) и curl + // ?endpoint= формат — без url.Values.Encode (сохраняет / как /) endpointPath := path extraQuery := "" if idx := strings.Index(path, "?"); idx >= 0 { endpointPath = path[:idx] extraQuery = path[idx+1:] } - q := req.URL.Query() - q.Set("endpoint", endpointPath) + rawQuery := "endpoint=" + endpointPath if extraQuery != "" { - // Добавляем параметры из path в URL (не в endpoint) - for _, param := range strings.Split(extraQuery, "&") { - kv := strings.SplitN(param, "=", 2) - if len(kv) == 2 { - q.Set(kv[0], kv[1]) - } - } + rawQuery += "&" + extraQuery } - req.URL.RawQuery = q.Encode() + req.URL.RawQuery = rawQuery req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "*/*") if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -861,6 +856,12 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa req.Header.Set("Authorization", "Bearer "+c.ApiToken) } + // DEBUG + if os.Getenv("NUBES_DEBUG_HTTP") == "1" { + dump, _ := httputil.DumpRequestOut(req, body != nil) + fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump) + } + resp, err := c.HttpClient.Do(req) if err != nil { lastErr = err diff --git a/universal_rebuild/main.go b/universal_rebuild/main.go index af5a54d..04a7521 100644 --- a/universal_rebuild/main.go +++ b/universal_rebuild/main.go @@ -10,7 +10,7 @@ import ( ) var ( - version string = "5.0.70" + version string = "5.0.71" ) func main() {