更新
This commit is contained in:
@@ -21,3 +21,5 @@ ALLOW_IPS=192.168.1.20
|
||||
|
||||
# 应用日志根目录(默认 {程序目录}/../log/forward/)
|
||||
# LOG_DIR=D:\worker\code\log
|
||||
|
||||
# API 请求明细(无需配置):{程序目录}/api-logs/{YYYY-mm-dd HH}/ 每请求一个 .log,含完整请求/响应(不脱敏)
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
*.exe
|
||||
.env
|
||||
../log/
|
||||
api-logs/
|
||||
|
||||
44
README.md
44
README.md
@@ -9,6 +9,7 @@
|
||||
| `POST /province/supervise/data` | `SUPERVISE_TARGET_URL`(28212) | 加密后的监管业务 JSON |
|
||||
| `POST /mng/file/auth/upload` | `FILE_TARGET_URL`(28211) | 处方 PDF multipart 上传 |
|
||||
| `GET /health` | 本地 | 健康检查 |
|
||||
| `GET /t` | 本地 | 政务云测试页(读 file api-logs、连通测试、回放、导出 ApiPost) |
|
||||
|
||||
转发规则:
|
||||
|
||||
@@ -94,6 +95,19 @@ Windows 也可双击或执行项目根目录下的 [`run.bat`](d:/worker/code/xk
|
||||
|
||||
IDE 调试:Working Directory 设为项目根,运行文件选 `main.go` 即可。
|
||||
|
||||
## 政务云测试页 `/t`
|
||||
|
||||
启动后浏览器访问:`http://<forward-host>:16001/t`(随 forward 进程开放,**仅建议内网使用**)。
|
||||
|
||||
功能:
|
||||
|
||||
- **连通测试**:对当前 `.env` 中 28212 / 28211 目标 URL 发起探测。
|
||||
- **file 日志**:左侧列出 `api-logs/**/_file_*.log`,点击解析 **入站(块1)** / **出站(块2)** 的 Headers 与 Body(multipart 为 `body_base64`)。
|
||||
- **发送**:按出站头/体向政务云回放 POST(不含 `X-Forward-Token`)。
|
||||
- **导出 ApiPost**:下载 Postman Collection v2.1 JSON,在 ApiPost 中选择 **导入 → Postman 集合**。
|
||||
|
||||
日志含 `X-Authorization` 等敏感信息,请勿将导出的集合或 `/t` 暴露到公网。
|
||||
|
||||
本地联调 mock 上游时,在 `.env` 中设置:
|
||||
|
||||
```env
|
||||
@@ -112,4 +126,32 @@ FILE_TARGET_URL=http://127.0.0.1:18001/api/u
|
||||
2026-05-19T22:00:02+08:00 | 192.168.1.20 | supervise | dry_run | target=https://... | body_len=1024 | 3ms
|
||||
```
|
||||
|
||||
不记录完整 body 与 uploadToken。
|
||||
摘要行不含完整 body;明细见下方 **API 请求日志**。
|
||||
|
||||
## API 请求日志(api-logs)
|
||||
|
||||
每次 HTTP 请求(含 `/health`、监管、文件上传、403 本地拒绝、dry-run)在**程序同级目录**写入独立文件:
|
||||
|
||||
```text
|
||||
{程序目录}/api-logs/2026-05-27 14/20260527143015_supervise_a1b2c3d4.log
|
||||
```
|
||||
|
||||
单文件固定 **三段**(完整原文,不脱敏):
|
||||
|
||||
```text
|
||||
=== META ===
|
||||
Time / ClientIP / Path / Kind(supervise|file) / DurationMs / Status
|
||||
|
||||
=== 1. 入站请求(transit → forward)===
|
||||
URL、原始 Headers(含 X-Forward-Token)、Body(multipart/PDF 为 body_len + body_base64)
|
||||
|
||||
=== 2. 出站转发(forward → 政务云)===
|
||||
TargetURL、白名单过滤后 Headers、Body(与入站相同字节)
|
||||
|
||||
=== 3. 回复(政务云 → forward → transit)===
|
||||
UpstreamStatus、UpstreamHeaders、Body;JSON 响应额外输出 BodyJSON(缩进格式化)
|
||||
```
|
||||
|
||||
排查超时:若 **块1+块2** 完整、**块3** 为空且 `DurationMs` 很大,说明已发往政务云但上游无响应(常见为 28211 网络不通)。
|
||||
|
||||
**注意**:日志含 `X-Authorization`、`X-Forward-Token`、业务密文及 PDF,已加入 `.gitignore`,请勿提交仓库或暴露到外网。
|
||||
|
||||
364
internal/forward/apilog.go
Normal file
364
internal/forward/apilog.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiLogsDirName = "api-logs"
|
||||
|
||||
var (
|
||||
apiLogMu sync.Mutex
|
||||
apiLogRootOverride string // 测试用,非空时覆盖 programDir()/api-logs
|
||||
)
|
||||
|
||||
type apiLogCtxKey struct{}
|
||||
|
||||
// apiLogCollector 单次 HTTP 请求的三段日志采集器(入站 / 出站 / 上游回复)。
|
||||
type apiLogCollector struct {
|
||||
mu sync.Mutex
|
||||
|
||||
Kind string
|
||||
Target string
|
||||
InboundBody []byte
|
||||
|
||||
outboundURL string
|
||||
outboundHeaders http.Header
|
||||
outboundSet bool
|
||||
|
||||
upstreamStatus int
|
||||
upstreamHeaders http.Header
|
||||
upstreamBody []byte
|
||||
upstreamSet bool
|
||||
}
|
||||
|
||||
func newAPILogCollector(inboundBody []byte) *apiLogCollector {
|
||||
return &apiLogCollector{InboundBody: inboundBody}
|
||||
}
|
||||
|
||||
func withAPILogCollector(r *http.Request, c *apiLogCollector) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), apiLogCtxKey{}, c))
|
||||
}
|
||||
|
||||
func apiLogCollectorFrom(ctx context.Context) *apiLogCollector {
|
||||
c, _ := ctx.Value(apiLogCtxKey{}).(*apiLogCollector)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *apiLogCollector) SetMeta(kind, target string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Kind = kind
|
||||
c.Target = target
|
||||
}
|
||||
|
||||
func (c *apiLogCollector) SetOutbound(targetURL string, headers http.Header) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.outboundURL = targetURL
|
||||
c.outboundHeaders = cloneHeader(headers)
|
||||
c.outboundSet = true
|
||||
}
|
||||
|
||||
func (c *apiLogCollector) SetUpstream(status int, headers http.Header, body []byte) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.upstreamStatus = status
|
||||
c.upstreamHeaders = cloneHeader(headers)
|
||||
c.upstreamBody = append([]byte(nil), body...)
|
||||
c.upstreamSet = true
|
||||
}
|
||||
|
||||
func (c *apiLogCollector) snapshot() (kind, target, outboundURL string, outboundHdr, upstreamHdr http.Header, inboundBody, upstreamBody []byte, outboundSet, upstreamSet bool, upstreamStatus int) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.Kind, c.Target, c.outboundURL, c.outboundHeaders, c.upstreamHeaders, c.InboundBody, c.upstreamBody, c.outboundSet, c.upstreamSet, c.upstreamStatus
|
||||
}
|
||||
|
||||
func apiLogRoot() string {
|
||||
if apiLogRootOverride != "" {
|
||||
return apiLogRootOverride
|
||||
}
|
||||
return filepath.Join(programDir(), apiLogsDirName)
|
||||
}
|
||||
|
||||
// APILogRoot 返回 api-logs 根目录(供 testweb 等子包通过 Deps 注入使用)。
|
||||
func APILogRoot() string {
|
||||
return apiLogRoot()
|
||||
}
|
||||
|
||||
// initAPILog 创建程序同级 api-logs 根目录。
|
||||
func initAPILog() error {
|
||||
return os.MkdirAll(apiLogRoot(), 0o755)
|
||||
}
|
||||
|
||||
// logHTTP 记录每次 HTTP 请求的三段 api-log(入站 / 出站 / 回复),按小时子目录、每请求单文件。
|
||||
func logHTTP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
reqBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read request body failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(reqBody))
|
||||
|
||||
collector := newAPILogCollector(reqBody)
|
||||
r = withAPILogCollector(r, collector)
|
||||
|
||||
cw := &captureResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(cw, r)
|
||||
|
||||
if err := writeAPILogFile(r, collector, cw, time.Since(start)); err != nil {
|
||||
appLogf("%s | api-log write error | %v", time.Now().Format(time.RFC3339), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type captureResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
body bytes.Buffer
|
||||
capturedHdr http.Header
|
||||
}
|
||||
|
||||
func (c *captureResponseWriter) WriteHeader(statusCode int) {
|
||||
if !c.wroteHeader {
|
||||
c.status = statusCode
|
||||
c.wroteHeader = true
|
||||
c.capturedHdr = cloneHeader(c.ResponseWriter.Header())
|
||||
}
|
||||
c.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (c *captureResponseWriter) Write(b []byte) (int, error) {
|
||||
if !c.wroteHeader {
|
||||
c.WriteHeader(http.StatusOK)
|
||||
}
|
||||
_, _ = c.body.Write(b)
|
||||
return c.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func cloneHeader(h http.Header) http.Header {
|
||||
out := make(http.Header, len(h))
|
||||
for k, vs := range h {
|
||||
cp := make([]string, len(vs))
|
||||
copy(cp, vs)
|
||||
out[k] = cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeAPILogFile(r *http.Request, col *apiLogCollector, cw *captureResponseWriter, elapsed time.Duration) error {
|
||||
now := time.Now()
|
||||
hourDir := now.Format("2006-01-02 15")
|
||||
dir := filepath.Join(apiLogRoot(), hourDir)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("%s_%s_%s.log",
|
||||
now.Format("20060102150405"),
|
||||
pathSlug(r.URL.Path),
|
||||
shortID(),
|
||||
)
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
kind, _, outboundURL, outboundHdr, upstreamHdr, inboundBody, upstreamBody, outboundSet, upstreamSet, upstreamStatus := col.snapshot()
|
||||
if kind == "" {
|
||||
kind = pathSlug(r.URL.Path)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "=== META ===\n")
|
||||
fmt.Fprintf(&buf, "Time: %s\n", now.Format(time.RFC3339Nano))
|
||||
fmt.Fprintf(&buf, "ClientIP: %s\n", clientIP(r))
|
||||
fmt.Fprintf(&buf, "Method: %s\n", r.Method)
|
||||
fmt.Fprintf(&buf, "Path: %s\n", r.URL.Path)
|
||||
fmt.Fprintf(&buf, "Kind: %s\n", kind)
|
||||
if r.URL.RawQuery != "" {
|
||||
fmt.Fprintf(&buf, "Query: %s\n", r.URL.RawQuery)
|
||||
}
|
||||
fmt.Fprintf(&buf, "DurationMs: %d\n", elapsed.Milliseconds())
|
||||
fmt.Fprintf(&buf, "Status: %d\n\n", cw.status)
|
||||
|
||||
writeInboundBlock(&buf, r, kind, inboundBody)
|
||||
|
||||
buf.WriteString("\n=== 2. 出站转发(forward → 政务云)===\n")
|
||||
if outboundSet {
|
||||
fmt.Fprintf(&buf, "TargetURL: %s\n", outboundURL)
|
||||
fmt.Fprintf(&buf, "Method: %s\n\n", http.MethodPost)
|
||||
buf.WriteString("Headers:\n")
|
||||
writeHeaders(&buf, outboundHdr)
|
||||
buf.WriteString("\nBody:\n")
|
||||
writeLogBody(&buf, kind, outboundHdr, inboundBody)
|
||||
} else {
|
||||
buf.WriteString("(未发起出站转发)\n")
|
||||
}
|
||||
|
||||
buf.WriteString("\n=== 3. 回复(政务云 → forward → transit)===\n")
|
||||
if upstreamSet {
|
||||
writeResponseBlock(&buf, upstreamStatus, upstreamHdr, upstreamBody)
|
||||
} else {
|
||||
buf.WriteString("UpstreamStatus: (无上游响应)\n\n")
|
||||
buf.WriteString("ClientResponseHeaders:\n")
|
||||
if cw.capturedHdr != nil {
|
||||
writeHeaders(&buf, cw.capturedHdr)
|
||||
}
|
||||
buf.WriteString("\nBody:\n")
|
||||
buf.Write(cw.body.Bytes())
|
||||
if cw.capturedHdr != nil && isJSONContentType(cw.capturedHdr) {
|
||||
writeBodyJSON(&buf, cw.body.Bytes())
|
||||
}
|
||||
}
|
||||
buf.WriteByte('\n')
|
||||
|
||||
apiLogMu.Lock()
|
||||
defer apiLogMu.Unlock()
|
||||
return os.WriteFile(path, buf.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
func writeInboundBlock(buf *bytes.Buffer, r *http.Request, kind string, body []byte) {
|
||||
buf.WriteString("=== 1. 入站请求(transit → forward)===\n")
|
||||
inboundURL := r.URL.String()
|
||||
if r.URL.Scheme == "" {
|
||||
inboundURL = fmt.Sprintf("%s %s%s", r.Method, r.Host, r.URL.RequestURI())
|
||||
} else {
|
||||
inboundURL = fmt.Sprintf("%s %s", r.Method, inboundURL)
|
||||
}
|
||||
fmt.Fprintf(buf, "URL: %s\n\n", inboundURL)
|
||||
buf.WriteString("Headers:\n")
|
||||
writeHeaders(buf, r.Header)
|
||||
buf.WriteString("\nBody:\n")
|
||||
writeLogBody(buf, kind, r.Header, body)
|
||||
}
|
||||
|
||||
func writeLogBody(buf *bytes.Buffer, kind string, headers http.Header, body []byte) {
|
||||
if len(body) == 0 {
|
||||
buf.WriteString("(empty)\n")
|
||||
return
|
||||
}
|
||||
hdrMap := headerToMap(headers)
|
||||
if plainBodyForLog(kind, hdrMap, body) {
|
||||
buf.Write(body)
|
||||
buf.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(buf, "body_len=%d\nbody_base64=%s\n", len(body), base64.StdEncoding.EncodeToString(body))
|
||||
}
|
||||
|
||||
func plainBodyForLog(kind string, headers map[string][]string, body []byte) bool {
|
||||
return plainBodyForDryRun(kind, headers, body)
|
||||
}
|
||||
|
||||
func headerToMap(h http.Header) map[string][]string {
|
||||
out := make(map[string][]string, len(h))
|
||||
for k, vs := range h {
|
||||
cp := make([]string, len(vs))
|
||||
copy(cp, vs)
|
||||
out[k] = cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeResponseBlock(buf *bytes.Buffer, status int, headers http.Header, body []byte) {
|
||||
fmt.Fprintf(buf, "UpstreamStatus: %d\n\n", status)
|
||||
buf.WriteString("UpstreamHeaders:\n")
|
||||
writeHeaders(buf, headers)
|
||||
buf.WriteString("\nBody:\n")
|
||||
if len(body) == 0 {
|
||||
buf.WriteString("(empty)\n")
|
||||
} else {
|
||||
buf.Write(body)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if isJSONContentType(headers) {
|
||||
writeBodyJSON(buf, body)
|
||||
}
|
||||
}
|
||||
|
||||
func writeBodyJSON(buf *bytes.Buffer, body []byte) {
|
||||
var v any
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
return
|
||||
}
|
||||
indented, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf.WriteString("\nBodyJSON:\n")
|
||||
buf.Write(indented)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
func isJSONContentType(h http.Header) bool {
|
||||
ct := strings.ToLower(h.Get("Content-Type"))
|
||||
return strings.Contains(ct, "json")
|
||||
}
|
||||
|
||||
func writeHeaders(w *bytes.Buffer, h http.Header) {
|
||||
for k, vs := range h {
|
||||
for _, v := range vs {
|
||||
fmt.Fprintf(w, "%s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pathSlug(path string) string {
|
||||
switch path {
|
||||
case "/province/supervise/data":
|
||||
return "supervise"
|
||||
case "/mng/file/auth/upload":
|
||||
return "file"
|
||||
case "/health":
|
||||
return "health"
|
||||
default:
|
||||
s := strings.Trim(path, "/")
|
||||
if s == "" {
|
||||
return "root"
|
||||
}
|
||||
s = strings.ReplaceAll(s, "/", "_")
|
||||
if len(s) > 32 {
|
||||
s = s[:32]
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func shortID() string {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "0000"
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// setAPILogRootForTest 仅供测试注入日志根目录。
|
||||
func setAPILogRootForTest(root string) {
|
||||
apiLogRootOverride = root
|
||||
}
|
||||
219
internal/forward/apilog_test.go
Normal file
219
internal/forward/apilog_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLogHTTP_writesThreeBlockLog(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
setAPILogRootForTest(tmp)
|
||||
t.Cleanup(func() { setAPILogRootForTest("") })
|
||||
|
||||
const (
|
||||
reqBody = `{"hello":"world"}`
|
||||
respBody = `{"code":200,"msg":"ok"}`
|
||||
)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Test-Token"); got != "secret-value-12345" {
|
||||
t.Errorf("header=%q", got)
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if string(b) != reqBody {
|
||||
t.Errorf("body=%q", b)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(respBody))
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(logHTTP(mux))
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/province/supervise/data", strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Test-Token", "secret-value-12345")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
content := readOnlyAPILogFile(t, tmp)
|
||||
|
||||
checks := []string{
|
||||
"=== META ===",
|
||||
"Kind: supervise",
|
||||
"=== 1. 入站请求(transit → forward)===",
|
||||
"X-Test-Token: secret-value-12345",
|
||||
reqBody,
|
||||
"=== 2. 出站转发(forward → 政务云)===",
|
||||
"(未发起出站转发)",
|
||||
"=== 3. 回复(政务云 → forward → transit)===",
|
||||
respBody,
|
||||
`"code": 200`,
|
||||
"BodyJSON:",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(content, c) {
|
||||
t.Errorf("log missing %q\n---\n%s", c, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogHTTP_forwardProxyThreeBlocks(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
setAPILogRootForTest(tmp)
|
||||
t.Cleanup(func() { setAPILogRootForTest("") })
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/mng/file/auth/upload" {
|
||||
t.Errorf("path=%s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Authorization") != "tok" {
|
||||
t.Errorf("X-Authorization=%q", r.Header.Get("X-Authorization"))
|
||||
}
|
||||
if r.Header.Get("X-Forward-Token") != "" {
|
||||
t.Error("X-Forward-Token must not reach upstream")
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if string(b) != "pdf-bytes" {
|
||||
t.Errorf("body=%q", b)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"record":{"fileId":"abc123"}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
fileTarget := strings.TrimRight(upstream.URL, "/") + "/mng/file/auth/upload"
|
||||
proxy, err := newReverseProxy(fileTarget, "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/mng/file/auth/upload", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleForward(w, r, "file", proxy, fileTarget, "", "", true)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(logHTTP(mux))
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/mng/file/auth/upload", strings.NewReader("pdf-bytes"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=abc")
|
||||
req.Header.Set("X-Authorization", "tok")
|
||||
req.Header.Set("X-Forward-Token", "should-strip")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
content := readOnlyAPILogFile(t, tmp)
|
||||
checks := []string{
|
||||
"Kind: file",
|
||||
"=== 1. 入站请求(transit → forward)===",
|
||||
"X-Forward-Token: should-strip",
|
||||
"body_len=9",
|
||||
"=== 2. 出站转发(forward → 政务云)===",
|
||||
"TargetURL: " + fileTarget,
|
||||
"X-Authorization: tok",
|
||||
"=== 3. 回复(政务云 → forward → transit)===",
|
||||
"UpstreamStatus: 200",
|
||||
`"fileId": "abc123"`,
|
||||
"BodyJSON:",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(content, c) {
|
||||
t.Errorf("log missing %q\n---\n%s", c, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "X-Forward-Token:") && strings.Contains(content, "=== 2. 出站转发") {
|
||||
// X-Forward-Token must not appear in outbound headers block
|
||||
outboundSection := content[strings.Index(content, "=== 2. 出站转发"):strings.Index(content, "=== 3. 回复")]
|
||||
if strings.Contains(outboundSection, "X-Forward-Token") {
|
||||
t.Errorf("outbound block leaked X-Forward-Token:\n%s", outboundSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogHTTP_forbiddenHasOutboundAndClientResponse(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
setAPILogRootForTest(tmp)
|
||||
t.Cleanup(func() { setAPILogRootForTest("") })
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleForward(w, r, "supervise", nil, "http://example.com/up", "10.0.0.1", "secret", true)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(logHTTP(mux))
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/province/supervise/data", strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Forward-Token", "wrong")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
content := readOnlyAPILogFile(t, tmp)
|
||||
checks := []string{
|
||||
"=== 2. 出站转发(forward → 政务云)===",
|
||||
"TargetURL: http://example.com/up",
|
||||
"=== 3. 回复(政务云 → forward → transit)===",
|
||||
"forbidden",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(content, c) {
|
||||
t.Errorf("log missing %q\n---\n%s", c, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readOnlyAPILogFile(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
hourDirs, err := os.ReadDir(root)
|
||||
if err != nil || len(hourDirs) == 0 {
|
||||
t.Fatalf("no hour dir under %s: %v", root, err)
|
||||
}
|
||||
files, err := os.ReadDir(filepath.Join(root, hourDirs[0].Name()))
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("want 1 log file, got %d err=%v", len(files), err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, hourDirs[0].Name(), files[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -16,6 +18,11 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
outboundDialTimeout = 10 * time.Second
|
||||
outboundResponseHeaderTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
// dryRunResponse 为 ENABLE_FORWARD=false 时返回给 transit-go 的 JSON 结构(回显将要转发的出站内容)。
|
||||
type dryRunResponse struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
@@ -35,13 +42,30 @@ func newReverseProxy(target, kind string) (*httputil.ReverseProxy, error) {
|
||||
return nil, err
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
// 步骤 2:政务网自签证书,TLS 跳过校验(与现网一致)
|
||||
// 步骤 2:政务网自签证书,TLS 跳过校验;出站连接/读头超时便于快速失败
|
||||
proxy.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书
|
||||
DialContext: (&net.Dialer{Timeout: outboundDialTimeout}).DialContext,
|
||||
ResponseHeaderTimeout: outboundResponseHeaderTimeout,
|
||||
}
|
||||
// 步骤 3:每次转发前改写出站 URL 与 Header(Body 由 ReverseProxy 原样流式透传)
|
||||
// 步骤 3:每次转发前改写出站 URL 与 Header,并记录出站快照供 api-log 第 2 块
|
||||
proxy.Director = func(req *http.Request) {
|
||||
applyOutboundTarget(req, targetURL, kind)
|
||||
if col := apiLogCollectorFrom(req.Context()); col != nil {
|
||||
col.SetOutbound(req.URL.String(), req.Header)
|
||||
}
|
||||
}
|
||||
// 步骤 4:捕获上游响应供 api-log 第 3 块,Body 回填后继续透传
|
||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if col := apiLogCollectorFrom(resp.Request.Context()); col != nil {
|
||||
col.SetUpstream(resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
@@ -59,32 +83,62 @@ func applyOutboundTarget(req *http.Request, targetURL *url.URL, kind string) {
|
||||
req.Header = filtered
|
||||
}
|
||||
|
||||
// buildOutboundSnapshot 读取入站 Body,按与真实转发相同规则组装出站 URL/Header/Body,供回显模式使用。
|
||||
func buildOutboundSnapshot(r *http.Request, target, kind string) (targetURL string, headers map[string][]string, body []byte, err error) {
|
||||
// buildOutboundFromInbound 按 Director 相同规则组装出站 URL/Header(不读 Body)。
|
||||
func buildOutboundFromInbound(headers http.Header, target, kind string) (targetURL string, hdr map[string][]string, err error) {
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
filtered := make(http.Header)
|
||||
copyAllowedHeaders(filtered, headers, kind)
|
||||
hdr = make(map[string][]string, len(filtered))
|
||||
for k, vs := range filtered {
|
||||
cp := make([]string, len(vs))
|
||||
copy(cp, vs)
|
||||
hdr[k] = cp
|
||||
}
|
||||
return parsed.String(), hdr, nil
|
||||
}
|
||||
|
||||
// recordOutboundSnapshot 将出站快照写入 api-log 采集器(403/dry-run 等未走 Director 时使用)。
|
||||
func recordOutboundSnapshot(ctx context.Context, headers http.Header, target, kind string) {
|
||||
col := apiLogCollectorFrom(ctx)
|
||||
if col == nil {
|
||||
return
|
||||
}
|
||||
targetURL, hdrMap, err := buildOutboundFromInbound(headers, target, kind)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filtered := make(http.Header)
|
||||
for k, vs := range hdrMap {
|
||||
for _, v := range vs {
|
||||
filtered.Add(k, v)
|
||||
}
|
||||
}
|
||||
col.SetOutbound(targetURL, filtered)
|
||||
}
|
||||
|
||||
// buildOutboundSnapshot 读取入站 Body,按与真实转发相同规则组装出站 URL/Header/Body,供回显模式使用。
|
||||
func buildOutboundSnapshot(r *http.Request, target, kind string) (targetURL string, headers map[string][]string, body []byte, err error) {
|
||||
body, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
_ = r.Body.Close()
|
||||
|
||||
filtered := make(http.Header)
|
||||
copyAllowedHeaders(filtered, r.Header, kind)
|
||||
|
||||
hdr := make(map[string][]string, len(filtered))
|
||||
for k, vs := range filtered {
|
||||
cp := make([]string, len(vs))
|
||||
copy(cp, vs)
|
||||
hdr[k] = cp
|
||||
targetURL, headers, err = buildOutboundFromInbound(r.Header, target, kind)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
return parsed.String(), hdr, body, nil
|
||||
return targetURL, headers, body, nil
|
||||
}
|
||||
|
||||
// handleForward 处理监管/文件两条转发路由:校验 → 回显或真实转发 → 记日志。
|
||||
func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *httputil.ReverseProxy, target, allowIPs, forwardSecret string, enableForward bool) {
|
||||
if col := apiLogCollectorFrom(r.Context()); col != nil {
|
||||
col.SetMeta(kind, target)
|
||||
}
|
||||
// 步骤 1:仅允许 POST
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -93,12 +147,14 @@ func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *h
|
||||
ip := clientIP(r)
|
||||
// 步骤 2:来源 IP 白名单(ALLOW_IPS 非空时生效)
|
||||
if allowIPs != "" && !ipAllowed(ip, allowIPs) {
|
||||
recordOutboundSnapshot(r.Context(), r.Header, target, kind)
|
||||
appLogf("%s | %s | %s | forbidden | not in ALLOW_IPS", time.Now().Format(time.RFC3339), ip, kind)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// 步骤 3:内网共享密钥(FORWARD_SHARED_SECRET 非空时校验 X-Forward-Token)
|
||||
if forwardSecret != "" && r.Header.Get("X-Forward-Token") != forwardSecret {
|
||||
recordOutboundSnapshot(r.Context(), r.Header, target, kind)
|
||||
appLogf("%s | %s | %s | forbidden | bad forward token", time.Now().Format(time.RFC3339), ip, kind)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
@@ -128,6 +184,7 @@ func handleDryRun(w http.ResponseWriter, r *http.Request, kind, target, ip strin
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
recordOutboundSnapshot(r.Context(), r.Header, target, kind)
|
||||
// 步骤 2:构造 JSON(body 明文仅 UTF-8 且非 multipart 时填充;body_base64 始终提供)
|
||||
resp := dryRunResponse{
|
||||
DryRun: true,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"xk-hy-forward-go/internal/forward/testweb"
|
||||
)
|
||||
|
||||
// Run 启动 forward 全部 HTTP 服务:/health、/province/supervise/data、/mng/file/auth/upload。
|
||||
@@ -15,6 +17,9 @@ func Run() {
|
||||
if err := initAppLog(); err != nil {
|
||||
log.Printf("应用日志初始化失败: %v", err)
|
||||
}
|
||||
if err := initAPILog(); err != nil {
|
||||
log.Printf("api-logs 目录初始化失败: %v", err)
|
||||
}
|
||||
|
||||
// 步骤 3:读取监听地址、访问控制、是否真实转发
|
||||
listen := env("LISTEN_ADDR", ":16001")
|
||||
@@ -57,12 +62,19 @@ func Run() {
|
||||
handleForward(w, r, "file", fileProxy, fileTarget, allowIPs, forwardSecret, enableForward)
|
||||
})
|
||||
|
||||
// 步骤 8b:政务云测试页 /t(ApiPost 风格,读 file api-logs、连通测试、导出 Postman)
|
||||
testweb.Register(mux, testweb.Deps{
|
||||
SuperviseTarget: superviseTarget,
|
||||
FileTarget: fileTarget,
|
||||
APILogRoot: APILogRoot(),
|
||||
})
|
||||
|
||||
// 步骤 9:打印启动配置并监听
|
||||
msg := fmt.Sprintf("xk-hy-forward-go 监听 %s | forward_enabled=%v | supervise=%s | file=%s",
|
||||
listen, enableForward, superviseTarget, fileTarget)
|
||||
appLogf(msg)
|
||||
log.Print(msg)
|
||||
if err := http.ListenAndServe(listen, mux); err != nil {
|
||||
if err := http.ListenAndServe(listen, logHTTP(mux)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
98
internal/forward/testweb/apilog_list.go
Normal file
98
internal/forward/testweb/apilog_list.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package testweb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var logRelRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}/\d{8,14}_file_[a-f0-9]+\.log$`)
|
||||
|
||||
// FileLogEntry api-logs 中的 file 通道日志条目。
|
||||
type FileLogEntry struct {
|
||||
Rel string `json:"rel"`
|
||||
Name string `json:"name"`
|
||||
HourDir string `json:"hourDir"`
|
||||
Modified string `json:"modified"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ListFileLogs 列出 api-logs 下所有 *_file_*.log(按修改时间倒序)。
|
||||
func ListFileLogs(root string) ([]FileLogEntry, error) {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []FileLogEntry
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if !strings.Contains(name, "_file_") || !strings.HasSuffix(name, ".log") {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !logRelRE.MatchString(rel) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hourDir := filepath.ToSlash(filepath.Dir(rel))
|
||||
entries = append(entries, FileLogEntry{
|
||||
Rel: rel,
|
||||
Name: name,
|
||||
HourDir: hourDir,
|
||||
Modified: info.ModTime().Format(time.RFC3339),
|
||||
Size: info.Size(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Modified > entries[j].Modified
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// resolveLogRelPath 校验 rel 并返回绝对路径。
|
||||
func resolveLogRelPath(root, rel string) (string, error) {
|
||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return "", fmt.Errorf("invalid rel path")
|
||||
}
|
||||
if !logRelRE.MatchString(rel) {
|
||||
return "", fmt.Errorf("rel path not allowed")
|
||||
}
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full := filepath.Join(root, filepath.FromSlash(rel))
|
||||
full, err = filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(full, root+string(os.PathSeparator)) && full != root {
|
||||
return "", fmt.Errorf("path escape")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
318
internal/forward/testweb/apilog_parse.go
Normal file
318
internal/forward/testweb/apilog_parse.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package testweb
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
blockInboundRE = regexp.MustCompile(`(?m)^=== 1\. 入站请求`)
|
||||
blockOutboundRE = regexp.MustCompile(`(?m)^=== 2\. 出站转发`)
|
||||
blockResponseRE = regexp.MustCompile(`(?m)^=== 3\. 回复`)
|
||||
)
|
||||
|
||||
// ParsedAPILog 解析后的 api-log 三段结构。
|
||||
type ParsedAPILog struct {
|
||||
Rel string `json:"rel"`
|
||||
Meta map[string]string `json:"meta"`
|
||||
Inbound RequestSection `json:"inbound"`
|
||||
Outbound RequestSection `json:"outbound"`
|
||||
Response ResponseSection `json:"response"`
|
||||
ParseWarnings []string `json:"parseWarnings,omitempty"`
|
||||
OutboundAbsent bool `json:"outboundAbsent"`
|
||||
}
|
||||
|
||||
// RequestSection 请求段(入站或出站)。
|
||||
type RequestSection struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
BodyRaw string `json:"bodyRaw,omitempty"`
|
||||
BodyLen int `json:"bodyLen"`
|
||||
BodyBase64 string `json:"bodyBase64,omitempty"`
|
||||
BodyBytes []byte `json:"-"`
|
||||
BodyPlain bool `json:"bodyPlain"`
|
||||
}
|
||||
|
||||
// ResponseSection 上游/客户端回复段。
|
||||
type ResponseSection struct {
|
||||
Status int `json:"status,omitempty"`
|
||||
StatusText string `json:"statusText,omitempty"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body,omitempty"`
|
||||
BodyJSON string `json:"bodyJSON,omitempty"`
|
||||
UpstreamSet bool `json:"upstreamSet"`
|
||||
}
|
||||
|
||||
// ParseAPILogBytes 解析 api-log 文件内容。
|
||||
func ParseAPILogBytes(rel string, raw []byte) (*ParsedAPILog, error) {
|
||||
content := string(raw)
|
||||
p := &ParsedAPILog{Rel: rel}
|
||||
p.Meta = parseMeta(content)
|
||||
|
||||
inboundStart := blockInboundRE.FindStringIndex(content)
|
||||
outboundStart := blockOutboundRE.FindStringIndex(content)
|
||||
responseStart := blockResponseRE.FindStringIndex(content)
|
||||
|
||||
if inboundStart == nil {
|
||||
return nil, fmt.Errorf("missing inbound block")
|
||||
}
|
||||
|
||||
var inboundText, outboundText, responseText string
|
||||
if outboundStart != nil {
|
||||
inboundText = content[inboundStart[0]:outboundStart[0]]
|
||||
} else if responseStart != nil {
|
||||
inboundText = content[inboundStart[0]:responseStart[0]]
|
||||
} else {
|
||||
inboundText = content[inboundStart[0]:]
|
||||
}
|
||||
if outboundStart != nil {
|
||||
if responseStart != nil {
|
||||
outboundText = content[outboundStart[0]:responseStart[0]]
|
||||
} else {
|
||||
outboundText = content[outboundStart[0]:]
|
||||
}
|
||||
}
|
||||
if responseStart != nil {
|
||||
responseText = content[responseStart[0]:]
|
||||
}
|
||||
|
||||
var warns []string
|
||||
in, w := parseRequestSection(inboundText, true)
|
||||
p.Inbound = in
|
||||
warns = append(warns, w...)
|
||||
|
||||
if outboundText != "" {
|
||||
if strings.Contains(outboundText, "(未发起出站转发)") {
|
||||
p.OutboundAbsent = true
|
||||
} else {
|
||||
out, w2 := parseRequestSection(outboundText, false)
|
||||
p.Outbound = out
|
||||
warns = append(warns, w2...)
|
||||
}
|
||||
} else {
|
||||
p.OutboundAbsent = true
|
||||
}
|
||||
|
||||
if responseText != "" {
|
||||
p.Response = parseResponseSection(responseText)
|
||||
}
|
||||
p.ParseWarnings = warns
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ParseAPILogFile 读取并解析 api-log 文件。
|
||||
func ParseAPILogFile(root, rel string) (*ParsedAPILog, error) {
|
||||
path, err := resolveLogRelPath(root, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseAPILogBytes(rel, raw)
|
||||
}
|
||||
|
||||
func parseMeta(content string) map[string]string {
|
||||
meta := make(map[string]string)
|
||||
idx := strings.Index(content, "=== META ===")
|
||||
if idx < 0 {
|
||||
return meta
|
||||
}
|
||||
end := strings.Index(content[idx:], "\n\n")
|
||||
section := content[idx:]
|
||||
if end > 0 {
|
||||
section = section[:end]
|
||||
}
|
||||
for _, line := range strings.Split(section, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "===") {
|
||||
continue
|
||||
}
|
||||
if k, v, ok := strings.Cut(line, ": "); ok {
|
||||
meta[k] = v
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func parseRequestSection(section string, inbound bool) (RequestSection, []string) {
|
||||
var rs RequestSection
|
||||
rs.Headers = make(map[string]string)
|
||||
var warns []string
|
||||
|
||||
lines := strings.Split(section, "\n")
|
||||
i := 0
|
||||
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
|
||||
i++
|
||||
}
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
if strings.HasPrefix(line, "URL: ") {
|
||||
rs.URL = strings.TrimSpace(strings.TrimPrefix(line, "URL: "))
|
||||
if inbound && strings.HasPrefix(rs.URL, "POST ") {
|
||||
rs.URL = strings.TrimSpace(strings.TrimPrefix(rs.URL, "POST "))
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "TargetURL: ") {
|
||||
rs.URL = strings.TrimSpace(strings.TrimPrefix(line, "TargetURL: "))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Method: ") {
|
||||
rs.Method = strings.TrimSpace(strings.TrimPrefix(line, "Method: "))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "Headers:" {
|
||||
i++
|
||||
for i < len(lines) {
|
||||
hl := lines[i]
|
||||
if hl == "" || strings.HasPrefix(hl, "Body:") || strings.HasPrefix(hl, "===") {
|
||||
break
|
||||
}
|
||||
if k, v, ok := strings.Cut(hl, ": "); ok {
|
||||
rs.Headers[k] = v
|
||||
}
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "Body:" {
|
||||
i++
|
||||
bodyLines, consumed, w := readBodyLines(lines[i:])
|
||||
warns = append(warns, w...)
|
||||
i += consumed
|
||||
applyBodyToSection(&rs, bodyLines)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return rs, warns
|
||||
}
|
||||
|
||||
func readBodyLines(lines []string) (bodyLines []string, consumed int, warns []string) {
|
||||
for consumed < len(lines) {
|
||||
line := lines[consumed]
|
||||
if strings.HasPrefix(line, "===") {
|
||||
break
|
||||
}
|
||||
bodyLines = append(bodyLines, line)
|
||||
consumed++
|
||||
}
|
||||
return bodyLines, consumed, warns
|
||||
}
|
||||
|
||||
func applyBodyToSection(rs *RequestSection, bodyLines []string) {
|
||||
text := strings.Join(bodyLines, "\n")
|
||||
text = strings.TrimSuffix(text, "\n")
|
||||
if text == "" || text == "(empty)" {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(text, "body_len=") {
|
||||
for _, line := range bodyLines {
|
||||
if strings.HasPrefix(line, "body_len=") {
|
||||
if n, err := strconv.Atoi(strings.TrimPrefix(line, "body_len=")); err == nil {
|
||||
rs.BodyLen = n
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "body_base64=") {
|
||||
b64 := strings.TrimPrefix(line, "body_base64=")
|
||||
rs.BodyBase64 = b64
|
||||
dec, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rs.BodyBytes = dec
|
||||
rs.BodyLen = len(dec)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
rs.BodyRaw = text
|
||||
rs.BodyPlain = true
|
||||
rs.BodyBytes = []byte(text)
|
||||
rs.BodyLen = len(rs.BodyBytes)
|
||||
}
|
||||
|
||||
func parseResponseSection(section string) ResponseSection {
|
||||
var rs ResponseSection
|
||||
rs.Headers = make(map[string]string)
|
||||
|
||||
lines := strings.Split(section, "\n")
|
||||
i := 0
|
||||
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
|
||||
i++
|
||||
}
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
if strings.HasPrefix(line, "UpstreamStatus: ") {
|
||||
st := strings.TrimSpace(strings.TrimPrefix(line, "UpstreamStatus: "))
|
||||
if st == "(无上游响应)" {
|
||||
rs.UpstreamSet = false
|
||||
} else {
|
||||
rs.UpstreamSet = true
|
||||
if n, err := strconv.Atoi(st); err == nil {
|
||||
rs.Status = n
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "UpstreamHeaders:" || strings.TrimSpace(line) == "ClientResponseHeaders:" {
|
||||
i++
|
||||
for i < len(lines) {
|
||||
hl := lines[i]
|
||||
if hl == "" || strings.HasPrefix(hl, "Body:") || strings.HasPrefix(hl, "BodyJSON:") || strings.HasPrefix(hl, "===") {
|
||||
break
|
||||
}
|
||||
if k, v, ok := strings.Cut(hl, ": "); ok {
|
||||
rs.Headers[k] = v
|
||||
}
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "Body:" {
|
||||
i++
|
||||
var bodyLines []string
|
||||
for i < len(lines) {
|
||||
if strings.TrimSpace(lines[i]) == "BodyJSON:" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(lines[i], "===") {
|
||||
break
|
||||
}
|
||||
bodyLines = append(bodyLines, lines[i])
|
||||
i++
|
||||
}
|
||||
rs.Body = strings.TrimSuffix(strings.Join(bodyLines, "\n"), "\n")
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "BodyJSON:" {
|
||||
i++
|
||||
var jsonLines []string
|
||||
for i < len(lines) {
|
||||
if strings.HasPrefix(lines[i], "===") {
|
||||
break
|
||||
}
|
||||
jsonLines = append(jsonLines, lines[i])
|
||||
i++
|
||||
}
|
||||
rs.BodyJSON = strings.TrimSuffix(strings.Join(jsonLines, "\n"), "\n")
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
if rs.Status > 0 || rs.Body != "" {
|
||||
rs.UpstreamSet = true
|
||||
}
|
||||
return rs
|
||||
}
|
||||
141
internal/forward/testweb/apilog_parse_test.go
Normal file
141
internal/forward/testweb/apilog_parse_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package testweb
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleLog = `=== META ===
|
||||
Time: 2026-05-28T08:00:00+08:00
|
||||
ClientIP: 127.0.0.1
|
||||
Method: POST
|
||||
Path: /mng/file/auth/upload
|
||||
Kind: file
|
||||
DurationMs: 100
|
||||
Status: 200
|
||||
|
||||
=== 1. 入站请求(transit → forward)===
|
||||
URL: POST 127.0.0.1:16001/mng/file/auth/upload
|
||||
|
||||
Headers:
|
||||
Content-Type: multipart/form-data; boundary=abc
|
||||
X-Authorization: tok-in
|
||||
X-Forward-Token: secret
|
||||
|
||||
Body:
|
||||
body_len=3
|
||||
body_base64=Zm9v
|
||||
|
||||
=== 2. 出站转发(forward → 政务云)===
|
||||
TargetURL: https://59.202.52.129:28211/mng/file/auth/upload
|
||||
Method: POST
|
||||
|
||||
Headers:
|
||||
Content-Type: multipart/form-data; boundary=abc
|
||||
X-Authorization: tok-out
|
||||
|
||||
Body:
|
||||
body_len=3
|
||||
body_base64=Zm9v
|
||||
|
||||
=== 3. 回复(政务云 → forward → transit)===
|
||||
UpstreamStatus: 200
|
||||
|
||||
UpstreamHeaders:
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{"success":true,"record":{"fileId":"id1"}}
|
||||
|
||||
BodyJSON:
|
||||
{
|
||||
"success": true,
|
||||
"record": {
|
||||
"fileId": "id1"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func TestParseAPILogBytes_outboundBody(t *testing.T) {
|
||||
p, err := ParseAPILogBytes("2026-05-28 08/20260528080000_file_abcd.log", []byte(sampleLog))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Outbound.URL != "https://59.202.52.129:28211/mng/file/auth/upload" {
|
||||
t.Fatalf("outbound url=%q", p.Outbound.URL)
|
||||
}
|
||||
if p.Outbound.Headers["X-Authorization"] != "tok-out" {
|
||||
t.Fatalf("auth=%q", p.Outbound.Headers["X-Authorization"])
|
||||
}
|
||||
if string(p.Outbound.BodyBytes) != "foo" {
|
||||
t.Fatalf("body=%q", p.Outbound.BodyBytes)
|
||||
}
|
||||
if p.Response.Status != 200 || !strings.Contains(p.Response.Body, "fileId") {
|
||||
t.Fatalf("response=%+v", p.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFileLogs_and_resolve(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
hour := filepath.Join(tmp, "2026-05-28 08")
|
||||
if err := os.MkdirAll(hour, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "20260528080000_file_abcd.log"
|
||||
if err := os.WriteFile(filepath.Join(hour, name), []byte(sampleLog), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := ListFileLogs(tmp)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list=%v err=%v", list, err)
|
||||
}
|
||||
rel := list[0].Rel
|
||||
p, err := ParseAPILogFile(tmp, rel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Meta["Kind"] != "file" {
|
||||
t.Fatalf("kind=%v", p.Meta)
|
||||
}
|
||||
if _, err := resolveLogRelPath(tmp, "../etc/passwd"); err == nil {
|
||||
t.Fatal("expected reject ..")
|
||||
}
|
||||
if _, err := resolveLogRelPath(tmp, "bad/name.log"); err == nil {
|
||||
t.Fatal("expected reject pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPostmanCollection_multipart(t *testing.T) {
|
||||
p, err := ParseAPILogBytes("x.log", []byte(sampleLog))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := ToPostmanCollection(p, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(data)
|
||||
if !strings.Contains(s, "postman.com/json/collection/v2.1.0") {
|
||||
t.Fatal("missing schema")
|
||||
}
|
||||
if !strings.Contains(s, "formdata") {
|
||||
t.Fatal("expected formdata")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_applyBody_plain(t *testing.T) {
|
||||
var rs RequestSection
|
||||
rs.Headers = map[string]string{"Content-Type": "application/json"}
|
||||
applyBodyToSection(&rs, []string{`{"a":1}`})
|
||||
if !rs.BodyPlain || string(rs.BodyBytes) != `{"a":1}` {
|
||||
t.Fatalf("%+v", rs)
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte("bin"))
|
||||
applyBodyToSection(&rs, []string{"body_len=3", "body_base64=" + b64})
|
||||
if string(rs.BodyBytes) != "bin" {
|
||||
t.Fatalf("got %q", rs.BodyBytes)
|
||||
}
|
||||
}
|
||||
6
internal/forward/testweb/embed.go
Normal file
6
internal/forward/testweb/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package testweb
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed web/*
|
||||
var webFS embed.FS
|
||||
117
internal/forward/testweb/export_postman.go
Normal file
117
internal/forward/testweb/export_postman.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package testweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ToPostmanCollection 将解析后的日志转为 Postman Collection v2.1(ApiPost 可导入)。
|
||||
func ToPostmanCollection(log *ParsedAPILog, useInbound bool) ([]byte, error) {
|
||||
if log == nil {
|
||||
return nil, fmt.Errorf("nil log")
|
||||
}
|
||||
sec := log.Outbound
|
||||
name := "file upload outbound"
|
||||
if useInbound {
|
||||
sec = log.Inbound
|
||||
name = "file upload inbound"
|
||||
}
|
||||
if sec.URL == "" && log.Outbound.URL != "" {
|
||||
sec = log.Outbound
|
||||
}
|
||||
|
||||
headers := make([]map[string]any, 0, len(sec.Headers))
|
||||
for k, v := range sec.Headers {
|
||||
headers = append(headers, map[string]any{
|
||||
"key": k,
|
||||
"value": v,
|
||||
"type": "text",
|
||||
})
|
||||
}
|
||||
|
||||
_, bodyObj := postmanBody(sec)
|
||||
url := sec.URL
|
||||
if url == "" {
|
||||
url = "{{fileTarget}}"
|
||||
}
|
||||
|
||||
item := map[string]any{
|
||||
"name": name,
|
||||
"request": map[string]any{
|
||||
"method": "POST",
|
||||
"header": headers,
|
||||
"body": bodyObj,
|
||||
"url": url,
|
||||
"description": "从 forward-go api-log 导出;multipart 请在 ApiPost 中重新选择 PDF 文件",
|
||||
},
|
||||
}
|
||||
|
||||
if log.Response.Body != "" || log.Response.BodyJSON != "" {
|
||||
body := log.Response.Body
|
||||
if log.Response.BodyJSON != "" {
|
||||
body = log.Response.BodyJSON
|
||||
}
|
||||
item["response"] = []map[string]any{{
|
||||
"name": "example",
|
||||
"status": "OK",
|
||||
"code": log.Response.Status,
|
||||
"header": responseHeaders(log.Response.Headers),
|
||||
"body": body,
|
||||
"_postman_previewlanguage": "json",
|
||||
}}
|
||||
}
|
||||
|
||||
col := map[string]any{
|
||||
"info": map[string]any{
|
||||
"name": fmt.Sprintf("forward-file-%s", time.Now().Format("20060102150405")),
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
},
|
||||
"item": []map[string]any{item},
|
||||
}
|
||||
return json.MarshalIndent(col, "", " ")
|
||||
}
|
||||
|
||||
func postmanBody(sec RequestSection) (mode string, body map[string]any) {
|
||||
ct := strings.ToLower(sec.Headers["Content-Type"])
|
||||
if strings.Contains(ct, "multipart/form-data") {
|
||||
formdata := []map[string]any{
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "",
|
||||
"description": "请在 ApiPost 中选择 PDF;日志中为 body_base64 落盘",
|
||||
},
|
||||
{
|
||||
"key": "_note",
|
||||
"type": "text",
|
||||
"value": fmt.Sprintf("body_len=%d,从 forward api-log 导出", sec.BodyLen),
|
||||
"disabled": true,
|
||||
},
|
||||
}
|
||||
return "formdata", map[string]any{"mode": "formdata", "formdata": formdata}
|
||||
}
|
||||
raw := sec.BodyRaw
|
||||
if len(sec.BodyBytes) > 0 && !sec.BodyPlain {
|
||||
raw = string(sec.BodyBytes)
|
||||
}
|
||||
if raw == "" && sec.BodyBase64 != "" {
|
||||
raw = "[base64 body, len=" + fmt.Sprint(sec.BodyLen) + "]"
|
||||
}
|
||||
return "raw", map[string]any{
|
||||
"mode": "raw",
|
||||
"raw": raw,
|
||||
"options": map[string]any{
|
||||
"raw": map[string]any{"language": "json"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func responseHeaders(h map[string]string) []map[string]string {
|
||||
out := make([]map[string]string, 0, len(h))
|
||||
for k, v := range h {
|
||||
out = append(out, map[string]string{"key": k, "value": v})
|
||||
}
|
||||
return out
|
||||
}
|
||||
151
internal/forward/testweb/govtest.go
Normal file
151
internal/forward/testweb/govtest.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package testweb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
connectDialTimeout = 5 * time.Second
|
||||
connectReadTimeout = 15 * time.Second
|
||||
replayTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
func govHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书
|
||||
DialContext: (&net.Dialer{Timeout: connectDialTimeout}).DialContext,
|
||||
ResponseHeaderTimeout: connectReadTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectResult 政务云连通性探测结果。
|
||||
type ConnectResult struct {
|
||||
URL string `json:"url"`
|
||||
Channel string `json:"channel"`
|
||||
ElapsedMs int64 `json:"elapsedMs"`
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
}
|
||||
|
||||
// TestConnectivity 对 target URL 发起 POST 探测。
|
||||
func TestConnectivity(channel, target string) ConnectResult {
|
||||
res := ConnectResult{URL: target, Channel: channel}
|
||||
start := time.Now()
|
||||
defer func() { res.ElapsedMs = time.Since(start).Milliseconds() }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), connectReadTimeout+connectDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
res.Phase = "build_request"
|
||||
return res
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := govHTTPClient(connectReadTimeout + connectDialTimeout).Do(req)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline") {
|
||||
res.Phase = "timeout"
|
||||
} else if strings.Contains(err.Error(), "connection refused") {
|
||||
res.Phase = "tcp"
|
||||
} else if strings.Contains(err.Error(), "tls") || strings.Contains(err.Error(), "certificate") {
|
||||
res.Phase = "tls"
|
||||
} else {
|
||||
res.Phase = "network"
|
||||
}
|
||||
return res
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
res.HTTPStatus = resp.StatusCode
|
||||
res.OK = true
|
||||
res.Phase = "http"
|
||||
return res
|
||||
}
|
||||
|
||||
// ReplayResult 回放请求结果。
|
||||
type ReplayResult struct {
|
||||
URL string `json:"url"`
|
||||
ElapsedMs int64 `json:"elapsedMs"`
|
||||
HTTPStatus int `json:"httpStatus"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
BodyJSON string `json:"bodyJSON,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ReplayRequest 向政务云回放 POST。
|
||||
func ReplayRequest(url string, headers map[string]string, body []byte) ReplayResult {
|
||||
res := ReplayResult{URL: url, Headers: make(map[string]string)}
|
||||
start := time.Now()
|
||||
defer func() { res.ElapsedMs = time.Since(start).Milliseconds() }()
|
||||
|
||||
var bodyReader io.Reader
|
||||
if len(body) > 0 {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
} else {
|
||||
bodyReader = strings.NewReader("")
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bodyReader)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
for k, v := range headers {
|
||||
if strings.EqualFold(k, "X-Forward-Token") {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := govHTTPClient(replayTimeout).Do(req)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
res.HTTPStatus = resp.StatusCode
|
||||
for k, vs := range resp.Header {
|
||||
if len(vs) > 0 {
|
||||
res.Headers[k] = vs[0]
|
||||
}
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
res.Body = string(raw)
|
||||
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "json") {
|
||||
res.BodyJSON = formatJSON(raw)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func formatJSON(raw []byte) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var v any
|
||||
if json.Unmarshal(raw, &v) != nil {
|
||||
return s
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
259
internal/forward/testweb/server.go
Normal file
259
internal/forward/testweb/server.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package testweb
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Deps testweb 依赖。
|
||||
type Deps struct {
|
||||
SuperviseTarget string
|
||||
FileTarget string
|
||||
APILogRoot string
|
||||
}
|
||||
|
||||
// Register 注册 /t 页面与 API。
|
||||
func Register(mux *http.ServeMux, deps Deps) {
|
||||
s := &server{deps: deps}
|
||||
sub, _ := fs.Sub(webFS, "web")
|
||||
static := http.FileServer(http.FS(sub))
|
||||
|
||||
mux.HandleFunc("/t", s.handlePage)
|
||||
mux.HandleFunc("/t/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/t/static/") {
|
||||
http.StripPrefix("/t/static/", static).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/t" || r.URL.Path == "/t/" {
|
||||
s.handlePage(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/t/api/meta", s.handleMeta)
|
||||
mux.HandleFunc("/t/api/logs", s.handleLogs)
|
||||
mux.HandleFunc("/t/api/logs/detail", s.handleLogDetail)
|
||||
mux.HandleFunc("/t/api/logs/export", s.handleLogExport)
|
||||
mux.HandleFunc("/t/api/test/connect", s.handleTestConnect)
|
||||
mux.HandleFunc("/t/api/test/send", s.handleTestSend)
|
||||
}
|
||||
|
||||
type server struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func (s *server) handlePage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/t" && r.URL.Path != "/t/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, err := webFS.ReadFile("web/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "page not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func (s *server) handleMeta(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{
|
||||
"superviseTarget": s.deps.SuperviseTarget,
|
||||
"fileTarget": s.deps.FileTarget,
|
||||
"apiLogRoot": s.deps.APILogRoot,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleLogs(w http.ResponseWriter, _ *http.Request) {
|
||||
list, err := ListFileLogs(s.deps.APILogRoot)
|
||||
if err != nil {
|
||||
writeErr(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []FileLogEntry{}
|
||||
}
|
||||
writeJSON(w, list)
|
||||
}
|
||||
|
||||
func (s *server) handleLogDetail(w http.ResponseWriter, r *http.Request) {
|
||||
rel := r.URL.Query().Get("rel")
|
||||
parsed, err := ParseAPILogFile(s.deps.APILogRoot, rel)
|
||||
if err != nil {
|
||||
writeErr(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
out := toDetailDTO(parsed)
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
type logDetailJSON struct {
|
||||
Rel string `json:"rel"`
|
||||
Meta map[string]string `json:"meta"`
|
||||
Inbound sectionDTO `json:"inbound"`
|
||||
Outbound sectionDTO `json:"outbound"`
|
||||
Response ResponseSection `json:"response"`
|
||||
ParseWarnings []string `json:"parseWarnings,omitempty"`
|
||||
OutboundAbsent bool `json:"outboundAbsent"`
|
||||
}
|
||||
|
||||
type sectionDTO struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
BodyRaw string `json:"bodyRaw,omitempty"`
|
||||
BodyLen int `json:"bodyLen"`
|
||||
BodyBase64 string `json:"bodyBase64,omitempty"`
|
||||
BodyPlain bool `json:"bodyPlain"`
|
||||
}
|
||||
|
||||
func toDetailDTO(p *ParsedAPILog) logDetailJSON {
|
||||
return logDetailJSON{
|
||||
Rel: p.Rel,
|
||||
Meta: p.Meta,
|
||||
Inbound: sectionDTOFrom(p.Inbound),
|
||||
Outbound: sectionDTOFrom(p.Outbound),
|
||||
Response: p.Response,
|
||||
ParseWarnings: p.ParseWarnings,
|
||||
OutboundAbsent: p.OutboundAbsent,
|
||||
}
|
||||
}
|
||||
|
||||
func sectionDTOFrom(sec RequestSection) sectionDTO {
|
||||
d := sectionDTO{
|
||||
URL: sec.URL,
|
||||
Method: sec.Method,
|
||||
Headers: sec.Headers,
|
||||
BodyRaw: sec.BodyRaw,
|
||||
BodyLen: sec.BodyLen,
|
||||
BodyBase64: sec.BodyBase64,
|
||||
BodyPlain: sec.BodyPlain,
|
||||
}
|
||||
if len(sec.BodyBytes) > 0 && !sec.BodyPlain {
|
||||
d.BodyBase64 = base64.StdEncoding.EncodeToString(sec.BodyBytes)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (s *server) handleLogExport(w http.ResponseWriter, r *http.Request) {
|
||||
rel := r.URL.Query().Get("rel")
|
||||
useInbound := r.URL.Query().Get("source") == "inbound"
|
||||
parsed, err := ParseAPILogFile(s.deps.APILogRoot, rel)
|
||||
if err != nil {
|
||||
writeErr(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := ToPostmanCollection(parsed, useInbound)
|
||||
if err != nil {
|
||||
writeErr(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
name := "forward-file-" + strings.ReplaceAll(rel, "/", "_") + ".postman_collection.json"
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *server) handleTestConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ch := strings.ToLower(strings.TrimSpace(req.Channel))
|
||||
var results []ConnectResult
|
||||
switch ch {
|
||||
case "supervise", "28212":
|
||||
results = append(results, TestConnectivity("supervise", s.deps.SuperviseTarget))
|
||||
case "file", "28211":
|
||||
results = append(results, TestConnectivity("file", s.deps.FileTarget))
|
||||
default:
|
||||
results = append(results,
|
||||
TestConnectivity("supervise", s.deps.SuperviseTarget),
|
||||
TestConnectivity("file", s.deps.FileTarget),
|
||||
)
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
func (s *server) handleTestSend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
Rel string `json:"rel"`
|
||||
UseOutbound bool `json:"useOutbound"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
BodyBase64 string `json:"bodyBase64"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeErr(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(req.URL)
|
||||
headers := req.Headers
|
||||
var payload []byte
|
||||
|
||||
if req.Rel != "" {
|
||||
parsed, err := ParseAPILogFile(s.deps.APILogRoot, req.Rel)
|
||||
if err != nil {
|
||||
writeErr(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sec := parsed.Outbound
|
||||
if !req.UseOutbound {
|
||||
sec = parsed.Inbound
|
||||
}
|
||||
if url == "" {
|
||||
url = sec.URL
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
headers = sec.Headers
|
||||
}
|
||||
payload = sec.BodyBytes
|
||||
}
|
||||
if req.BodyBase64 != "" {
|
||||
dec, err := base64.StdEncoding.DecodeString(req.BodyBase64)
|
||||
if err != nil {
|
||||
writeErr(w, "invalid bodyBase64", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
payload = dec
|
||||
}
|
||||
if url == "" {
|
||||
writeErr(w, "url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if headers == nil {
|
||||
headers = map[string]string{}
|
||||
}
|
||||
|
||||
result := ReplayRequest(url, headers, payload)
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, msg string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
84
internal/forward/testweb/web/index.html
Normal file
84
internal/forward/testweb/web/index.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>政务云连接测试 · forward-go</title>
|
||||
<link rel="stylesheet" href="/t/static/t.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">T</span>
|
||||
<span class="title">forward 政务云测试</span>
|
||||
</div>
|
||||
<div class="connect-bar">
|
||||
<span class="label">连通测试</span>
|
||||
<button type="button" class="btn" data-connect="supervise">28212 监管</button>
|
||||
<button type="button" class="btn" data-connect="file">28211 文件</button>
|
||||
<button type="button" class="btn primary" data-connect="both">全部</button>
|
||||
<div id="connect-results" class="connect-results"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">
|
||||
<h2>file 日志</h2>
|
||||
<button type="button" class="btn sm" id="btn-refresh-logs">刷新</button>
|
||||
</div>
|
||||
<div class="meta-hint" id="meta-hint"></div>
|
||||
<ul class="log-list" id="log-list"></ul>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn" id="btn-load-outbound">加载出站(块2)</button>
|
||||
<button type="button" class="btn" id="btn-load-inbound">加载入站(块1)</button>
|
||||
<button type="button" class="btn" id="btn-export-outbound">导出 ApiPost(出站)</button>
|
||||
<button type="button" class="btn" id="btn-export-inbound">导出 ApiPost(入站)</button>
|
||||
<button type="button" class="btn" id="btn-curl">复制 cURL</button>
|
||||
</div>
|
||||
|
||||
<div class="url-bar">
|
||||
<select id="method" disabled>
|
||||
<option>POST</option>
|
||||
</select>
|
||||
<input type="text" id="url" class="url-input" placeholder="TargetURL">
|
||||
<button type="button" class="btn send primary" id="btn-send">发送</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab active" data-tab="headers">Headers</button>
|
||||
<button type="button" class="tab" data-tab="body">Body</button>
|
||||
<button type="button" class="tab" data-tab="response">响应</button>
|
||||
<button type="button" class="tab" data-tab="logmeta">日志 Meta</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-panels">
|
||||
<div class="panel active" id="panel-headers">
|
||||
<table class="kv-table" id="headers-table">
|
||||
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<button type="button" class="btn sm" id="btn-add-header">+ Header</button>
|
||||
</div>
|
||||
<div class="panel" id="panel-body">
|
||||
<div class="body-info" id="body-info"></div>
|
||||
<textarea id="body-raw" spellcheck="false" placeholder="请求体(明文或 base64 解码后预览)"></textarea>
|
||||
<input type="hidden" id="body-base64">
|
||||
</div>
|
||||
<div class="panel" id="panel-response">
|
||||
<pre id="response-view" class="code-block">(发送后或从日志块3 加载)</pre>
|
||||
</div>
|
||||
<div class="panel" id="panel-logmeta">
|
||||
<pre id="logmeta-view" class="code-block"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/t/static/t.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
224
internal/forward/testweb/web/t.css
Normal file
224
internal/forward/testweb/web/t.css
Normal file
@@ -0,0 +1,224 @@
|
||||
:root {
|
||||
--bg: #1e1e2e;
|
||||
--sidebar: #181825;
|
||||
--surface: #252536;
|
||||
--surface2: #2d2d44;
|
||||
--border: #3d3d5c;
|
||||
--text: #e8e8f0;
|
||||
--muted: #9393b0;
|
||||
--accent: #7c6cf0;
|
||||
--accent-hover: #9588f5;
|
||||
--send: #f59e0b;
|
||||
--send-hover: #fbbf24;
|
||||
--ok: #4ade80;
|
||||
--err: #f87171;
|
||||
--warn: #fbbf24;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "PingFang SC", system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app { display: flex; flex-direction: column; height: 100vh; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--sidebar);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.logo {
|
||||
width: 28px; height: 28px;
|
||||
background: linear-gradient(135deg, var(--accent), #ec4899);
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 14px;
|
||||
}
|
||||
.title { font-weight: 600; font-size: 14px; }
|
||||
.connect-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.connect-bar .label { color: var(--muted); margin-right: 0.25rem; }
|
||||
.connect-results { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-left: 0.5rem; }
|
||||
.badge {
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.badge.ok { border-color: var(--ok); color: var(--ok); }
|
||||
.badge.err { border-color: var(--err); color: var(--err); }
|
||||
.layout { display: flex; flex: 1; min-height: 0; }
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-head h2 { margin: 0; font-size: 13px; font-weight: 600; }
|
||||
.meta-hint {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.log-list li {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.log-list li:hover { background: var(--surface); }
|
||||
.log-list li.active { background: var(--surface2); border-left: 3px solid var(--accent); }
|
||||
.log-list .name { font-weight: 500; font-size: 12px; }
|
||||
.log-list .sub { color: var(--muted); font-size: 11px; margin-top: 0.15rem; }
|
||||
.workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.url-bar {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.url-bar select {
|
||||
width: 72px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
.url-input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 0.75rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 0.55rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.tab-panels { flex: 1; min-height: 0; overflow: hidden; position: relative; }
|
||||
.panel {
|
||||
display: none;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.panel.active { display: flex; flex-direction: column; }
|
||||
.btn {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); }
|
||||
.btn.primary:hover { background: var(--accent-hover); }
|
||||
.btn.send { background: var(--send); border-color: var(--send); color: #1a1a1a; font-weight: 600; }
|
||||
.btn.send:hover { background: var(--send-hover); }
|
||||
.btn.sm { padding: 0.2rem 0.45rem; font-size: 11px; }
|
||||
.kv-table { width: 100%; border-collapse: collapse; }
|
||||
.kv-table th, .kv-table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.35rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
.kv-table th { background: var(--surface2); color: var(--muted); font-weight: 500; }
|
||||
.kv-table input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid transparent;
|
||||
color: var(--text);
|
||||
padding: 0.25rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
.kv-table input:focus { border-color: var(--accent); outline: none; }
|
||||
#body-raw {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
.body-info { color: var(--muted); font-size: 11px; margin-bottom: 0.35rem; }
|
||||
.code-block {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
250
internal/forward/testweb/web/t.js
Normal file
250
internal/forward/testweb/web/t.js
Normal file
@@ -0,0 +1,250 @@
|
||||
(function () {
|
||||
const state = {
|
||||
meta: null,
|
||||
logs: [],
|
||||
currentRel: null,
|
||||
currentDetail: null,
|
||||
loadSource: 'outbound',
|
||||
bodyBase64: '',
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
async function api(path, opts) {
|
||||
const r = await fetch(path, opts);
|
||||
const t = await r.text();
|
||||
let j;
|
||||
try { j = JSON.parse(t); } catch { j = { error: t }; }
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
return j;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
state.meta = await api('/t/api/meta');
|
||||
$('meta-hint').innerHTML =
|
||||
'28212: <code>' + esc(state.meta.superviseTarget) + '</code><br>' +
|
||||
'28211: <code>' + esc(state.meta.fileTarget) + '</code>';
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
state.logs = await api('/t/api/logs');
|
||||
const ul = $('log-list');
|
||||
ul.innerHTML = '';
|
||||
if (!state.logs.length) {
|
||||
ul.innerHTML = '<li style="cursor:default;color:var(--muted)">暂无 file 日志</li>';
|
||||
return;
|
||||
}
|
||||
state.logs.forEach((e) => {
|
||||
const li = document.createElement('li');
|
||||
li.dataset.rel = e.rel;
|
||||
li.innerHTML =
|
||||
'<div class="name">' + esc(e.name) + '</div>' +
|
||||
'<div class="sub">' + esc(e.hourDir) + ' · ' + esc(e.modified) + '</div>';
|
||||
li.onclick = () => selectLog(e.rel, li);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
async function selectLog(rel, liEl) {
|
||||
state.currentRel = rel;
|
||||
document.querySelectorAll('.log-list li').forEach((el) => el.classList.remove('active'));
|
||||
if (liEl) liEl.classList.add('active');
|
||||
state.currentDetail = await api('/t/api/logs/detail?rel=' + encodeURIComponent(rel));
|
||||
loadSection(state.loadSource === 'inbound' ? state.currentDetail.inbound : state.currentDetail.outbound);
|
||||
showLogResponse();
|
||||
$('logmeta-view').textContent = JSON.stringify({
|
||||
meta: state.currentDetail.meta,
|
||||
parseWarnings: state.currentDetail.parseWarnings,
|
||||
outboundAbsent: state.currentDetail.outboundAbsent,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function showLogResponse() {
|
||||
const r = state.currentDetail.response;
|
||||
let text = '';
|
||||
if (r.bodyJSON) text = r.bodyJSON;
|
||||
else if (r.body) text = r.body;
|
||||
else text = '(日志块3 无响应体)';
|
||||
if (r.status) text = 'HTTP ' + r.status + '\n\n' + text;
|
||||
$('response-view').textContent = text;
|
||||
}
|
||||
|
||||
function loadSection(sec) {
|
||||
if (!sec) return;
|
||||
$('url').value = sec.url || '';
|
||||
renderHeaders(sec.headers || {});
|
||||
const info = [];
|
||||
if (sec.bodyLen) info.push('body_len=' + sec.bodyLen);
|
||||
if (sec.bodyPlain) info.push('明文');
|
||||
else if (sec.bodyBase64) info.push('base64');
|
||||
$('body-info').textContent = info.join(' · ') || '';
|
||||
state.bodyBase64 = sec.bodyBase64 || '';
|
||||
if (sec.bodyPlain && sec.bodyRaw) {
|
||||
$('body-raw').value = sec.bodyRaw;
|
||||
} else if (sec.bodyBase64) {
|
||||
try {
|
||||
$('body-raw').value = '[binary ' + sec.bodyLen + ' bytes — 发送时将使用 bodyBase64 回放]';
|
||||
} catch (e) {
|
||||
$('body-raw').value = '';
|
||||
}
|
||||
} else {
|
||||
$('body-raw').value = sec.bodyRaw || '';
|
||||
}
|
||||
$('body-base64').value = state.bodyBase64;
|
||||
}
|
||||
|
||||
function renderHeaders(hdrs) {
|
||||
const tbody = $('headers-table').querySelector('tbody');
|
||||
tbody.innerHTML = '';
|
||||
Object.entries(hdrs).forEach(([k, v]) => addHeaderRow(k, v));
|
||||
if (!tbody.children.length) addHeaderRow('', '');
|
||||
}
|
||||
|
||||
function addHeaderRow(k, v) {
|
||||
const tbody = $('headers-table').querySelector('tbody');
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td><input class="hk" value="' + esc(k).replace(/"/g, '"') + '"></td>' +
|
||||
'<td><input class="hv" value="' + esc(v).replace(/"/g, '"') + '"></td>' +
|
||||
'<td><button type="button" class="btn sm del">删</button></td>';
|
||||
tr.querySelector('.del').onclick = () => tr.remove();
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function collectHeaders() {
|
||||
const h = {};
|
||||
$('headers-table').querySelectorAll('tbody tr').forEach((tr) => {
|
||||
const k = tr.querySelector('.hk').value.trim();
|
||||
const v = tr.querySelector('.hv').value.trim();
|
||||
if (k) h[k] = v;
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
async function runConnect(channel) {
|
||||
const box = $('connect-results');
|
||||
box.innerHTML = '<span class="badge">测试中…</span>';
|
||||
try {
|
||||
const res = await api('/t/api/test/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel }),
|
||||
});
|
||||
box.innerHTML = res.results.map((r) => {
|
||||
const cls = r.ok ? 'ok' : 'err';
|
||||
const detail = r.ok
|
||||
? 'http=' + r.httpStatus + ' ' + r.elapsedMs + 'ms'
|
||||
: (r.phase || '') + ' ' + (r.error || '');
|
||||
return '<span class="badge ' + cls + '" title="' + esc(r.url) + '">' +
|
||||
esc(r.channel) + ' ' + esc(detail) + '</span>';
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
box.innerHTML = '<span class="badge err">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequest() {
|
||||
const payload = {
|
||||
url: $('url').value.trim(),
|
||||
headers: collectHeaders(),
|
||||
useOutbound: state.loadSource === 'outbound',
|
||||
};
|
||||
if (state.currentRel) payload.rel = state.currentRel;
|
||||
const b64 = $('body-base64').value.trim();
|
||||
if (b64 && !$('body-raw').value.startsWith('[binary')) {
|
||||
payload.bodyBase64 = b64;
|
||||
} else if (b64) {
|
||||
payload.bodyBase64 = b64;
|
||||
} else {
|
||||
const raw = $('body-raw').value;
|
||||
if (raw && !raw.startsWith('[binary')) {
|
||||
payload.bodyBase64 = btoa(unescape(encodeURIComponent(raw)));
|
||||
}
|
||||
}
|
||||
$('response-view').textContent = '请求中…';
|
||||
try {
|
||||
const res = await api('/t/api/test/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
let text = 'HTTP ' + res.httpStatus + ' (' + res.elapsedMs + 'ms)\n\n';
|
||||
if (res.error) text += 'Error: ' + res.error;
|
||||
else if (res.bodyJSON) text += res.bodyJSON;
|
||||
else text += res.body || '';
|
||||
$('response-view').textContent = text;
|
||||
} catch (e) {
|
||||
$('response-view').textContent = '失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function exportLog(source) {
|
||||
if (!state.currentRel) {
|
||||
alert('请先选择日志');
|
||||
return;
|
||||
}
|
||||
window.location.href =
|
||||
'/t/api/logs/export?rel=' + encodeURIComponent(state.currentRel) +
|
||||
'&source=' + (source === 'inbound' ? 'inbound' : 'outbound');
|
||||
}
|
||||
|
||||
function copyCurl() {
|
||||
const url = $('url').value.trim();
|
||||
const hdrs = collectHeaders();
|
||||
let parts = ['curl -X POST "' + url + '"'];
|
||||
Object.entries(hdrs).forEach(([k, v]) => {
|
||||
parts.push('-H "' + k + ': ' + v.replace(/"/g, '\\"') + '"');
|
||||
});
|
||||
const b64 = $('body-base64').value.trim();
|
||||
if (b64) {
|
||||
parts.push('--data-binary @<(echo ' + b64 + ' | base64 -d)');
|
||||
} else {
|
||||
const raw = $('body-raw').value;
|
||||
if (raw && !raw.startsWith('[binary')) {
|
||||
parts.push("-d '" + raw.replace(/'/g, "'\\''") + "'");
|
||||
}
|
||||
}
|
||||
navigator.clipboard.writeText(parts.join(' \\\n ')).then(
|
||||
() => alert('已复制 cURL(multipart 二进制请用导出 ApiPost)'),
|
||||
() => alert('复制失败')
|
||||
);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach((tab) => {
|
||||
tab.onclick = () => {
|
||||
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||
document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
$('panel-' + tab.dataset.tab).classList.add('active');
|
||||
};
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-connect]').forEach((btn) => {
|
||||
btn.onclick = () => runConnect(btn.dataset.connect);
|
||||
});
|
||||
|
||||
$('btn-refresh-logs').onclick = loadLogs;
|
||||
$('btn-load-outbound').onclick = () => {
|
||||
state.loadSource = 'outbound';
|
||||
if (state.currentDetail) loadSection(state.currentDetail.outbound);
|
||||
};
|
||||
$('btn-load-inbound').onclick = () => {
|
||||
state.loadSource = 'inbound';
|
||||
if (state.currentDetail) loadSection(state.currentDetail.inbound);
|
||||
};
|
||||
$('btn-export-outbound').onclick = () => exportLog('outbound');
|
||||
$('btn-export-inbound').onclick = () => exportLog('inbound');
|
||||
$('btn-curl').onclick = copyCurl;
|
||||
$('btn-send').onclick = sendRequest;
|
||||
$('btn-add-header').onclick = () => addHeaderRow('', '');
|
||||
|
||||
loadMeta().then(loadLogs).catch((e) => {
|
||||
$('meta-hint').textContent = '加载失败: ' + e.message;
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user