500 lines
21 KiB
Markdown
500 lines
21 KiB
Markdown
Вот, отправь Соннету:
|
||
|
||
---
|
||
|
||
**Контекст**: Пишем 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 "^>|^<"
|
||
```
|
||
|
||
Разница в одну строку в дампе покажет причину.
|