Files
MyGoNavi/internal/jvm/http_contract.go
Syngnat f16e2f15c2 🐛 fix(jvm): 加固诊断与变更安全边界
- 诊断 SSE 支持空心跳事件,避免无输出时解码失败

- Arthas Tunnel 增加会话过期清理、配置漂移校验和取消兜底

- Provider 合约清理 Base URL 查询参数和片段,避免探测泄露敏感信息

- JVM 变更请求强制校验原因并规范化写入审计字段
2026-04-26 14:34:43 +08:00

198 lines
5.5 KiB
Go

package jvm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
type contractRuntime struct {
baseURL *url.URL
apiKey string
client *http.Client
timeout time.Duration
errorPrefix string
}
func newContractRuntime(baseURLText string, apiKey string, timeout time.Duration, errorPrefix string) (contractRuntime, error) {
baseURL, err := normalizeContractBaseURL(baseURLText, errorPrefix)
if err != nil {
return contractRuntime{}, err
}
if timeout <= 0 {
timeout = 5 * time.Second
}
return contractRuntime{
baseURL: baseURL,
apiKey: strings.TrimSpace(apiKey),
client: &http.Client{Timeout: timeout},
timeout: timeout,
errorPrefix: strings.TrimSpace(errorPrefix),
}, nil
}
func normalizeContractBaseURL(rawBaseURL string, errorPrefix string) (*url.URL, error) {
baseURL := strings.TrimSpace(rawBaseURL)
if baseURL == "" {
return nil, fmt.Errorf("%s baseURL is required", errorPrefix)
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("%s baseURL is invalid: %s", errorPrefix, baseURL)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("%s scheme is unsupported: %s", errorPrefix, parsed.Scheme)
}
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed, nil
}
func (r contractRuntime) doJSON(
ctx context.Context,
method string,
action string,
relativePath string,
query url.Values,
requestBody any,
out any,
) error {
var bodyReader io.Reader
if requestBody != nil {
payload, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("%s %s request encode failed: %w", r.errorPrefix, action, err)
}
bodyReader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, r.resolveURL(relativePath, query), bodyReader)
if err != nil {
return fmt.Errorf("%s %s request build failed: %w", r.errorPrefix, action, err)
}
req.Header.Set("Accept", "application/json")
if r.apiKey != "" {
req.Header.Set("X-API-Key", r.apiKey)
}
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.client.Do(req)
if err != nil {
return wrapContractRequestError(r.errorPrefix, action, r.timeout, err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return buildContractStatusError(r.errorPrefix, action, resp)
}
if out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
if err := decodeContractJSON(resp.Body, out); err != nil {
return fmt.Errorf("%s %s returned invalid JSON: %w", r.errorPrefix, action, err)
}
return nil
}
func (r contractRuntime) resolveURL(relativePath string, query url.Values) string {
resolved := *r.baseURL
resolved.RawQuery = ""
resolved.Fragment = ""
basePath := strings.TrimRight(strings.TrimSpace(resolved.Path), "/")
childPath := strings.TrimLeft(strings.TrimSpace(relativePath), "/")
switch {
case basePath == "" && childPath == "":
resolved.Path = ""
case basePath == "":
resolved.Path = "/" + childPath
case childPath == "":
resolved.Path = basePath
default:
resolved.Path = basePath + "/" + childPath
}
if len(query) > 0 {
resolved.RawQuery = query.Encode()
}
return resolved.String()
}
func doContractProbe(ctx context.Context, runtime contractRuntime, method string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, runtime.baseURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("%s probe request build failed: %w", runtime.errorPrefix, err)
}
if runtime.apiKey != "" {
req.Header.Set("X-API-Key", runtime.apiKey)
}
resp, err := runtime.client.Do(req)
if err != nil {
return nil, wrapContractRequestError(runtime.errorPrefix, "probe", runtime.timeout, err)
}
return resp, nil
}
func isReachableStatus(statusCode int) bool {
return (statusCode >= 200 && statusCode < 400) || statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden
}
func decodeContractJSON(body io.Reader, out any) error {
decoder := json.NewDecoder(body)
decoder.UseNumber()
if err := decoder.Decode(out); err != nil {
if errors.Is(err, io.EOF) {
return fmt.Errorf("empty response body")
}
return err
}
var extra json.RawMessage
if err := decoder.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
func buildContractStatusError(errorPrefix string, action string, resp *http.Response) error {
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
if err != nil {
return fmt.Errorf("%s %s request failed: %s", errorPrefix, action, resp.Status)
}
message := strings.TrimSpace(string(body))
if message == "" {
return fmt.Errorf("%s %s request failed: %s", errorPrefix, action, resp.Status)
}
return fmt.Errorf("%s %s request failed: %s: %s", errorPrefix, action, resp.Status, message)
}
func wrapContractRequestError(errorPrefix string, action string, timeout time.Duration, err error) error {
if errors.Is(err, context.DeadlineExceeded) || isContractTimeoutError(err) {
return fmt.Errorf("%s %s request timed out after %s: %w", errorPrefix, action, timeout, err)
}
if errors.Is(err, context.Canceled) {
return fmt.Errorf("%s %s request canceled: %w", errorPrefix, action, err)
}
return fmt.Errorf("%s %s request failed: %w", errorPrefix, action, err)
}
func isContractTimeoutError(err error) bool {
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}