更新
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
# 监听地址(内网机,供外网 transit-go 访问)
|
||||
LISTEN_ADDR=:16001
|
||||
|
||||
# 是否真正转发到政务云:true/1/yes(默认);false/0/no 时仅回显将要转发的请求头与请求体给 transit-go
|
||||
ENABLE_FORWARD=true
|
||||
|
||||
# 通道一:业务监管数据上报(28212)
|
||||
SUPERVISE_TARGET_URL=https://59.202.52.129:28212/province/supervise/data
|
||||
|
||||
|
||||
9
.idea/php.xml
generated
Normal file
9
.idea/php.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
33
README.md
33
README.md
@@ -38,7 +38,7 @@ cp .env.example .env
|
||||
|
||||
- 请使用 **`#` 作为注释**(不要用 `;`,godotenv 不会把 `;` 行当作注释)。
|
||||
- 从 IDE / 服务方式启动时,请把 **Working Directory** 设为 `xk-hy-forward-go` 项目根,或把 `.env` 放在 **exe 同目录**。
|
||||
- 核对启动日志中的 `supervise=`、`file=` 是否为你在 `.env` 里配置的 URL。
|
||||
- 核对启动日志中的 `forward_enabled=`、`supervise=`、`file=` 是否为你在 `.env` 里配置的项。
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -48,8 +48,38 @@ cp .env.example .env
|
||||
| `TARGET_URL` | (兼容) | 未设 `SUPERVISE_TARGET_URL` 时生效 |
|
||||
| `ALLOW_IPS` | 空 | 逗号分隔来源 IP;**生产建议填 transit 出口 IP** |
|
||||
| `FORWARD_SHARED_SECRET` | 空 | 非空时要求请求头 `X-Forward-Token` 一致(与 transit `.env` 同值) |
|
||||
| `ENABLE_FORWARD` | `true` | 是否真正转发到政务云;`false`/`0`/`no` 时进入回显模式(见下) |
|
||||
| `LOG_DIR` | 空 | 日志根目录;默认 `{程序目录}/../log/forward/` |
|
||||
|
||||
### 回显模式(`ENABLE_FORWARD=false`)
|
||||
|
||||
不请求政务云上游,按与真实转发**相同规则**(固定目标 URL + Header 白名单 + 原样 Body)组装出站内容,以 JSON 返回给 `xk-hy-transit-go`,便于联调核对将要发出的报文。
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"dry_run": true,
|
||||
"kind": "supervise",
|
||||
"target_url": "https://59.202.52.129:28212/province/supervise/data",
|
||||
"method": "POST",
|
||||
"headers": { "Content-Type": ["application/json"], "X-Ca-Signature": ["..."] },
|
||||
"body": "明文(监管 JSON 且 UTF-8 有效时)",
|
||||
"body_base64": "始终提供,可还原 multipart/二进制"
|
||||
}
|
||||
```
|
||||
|
||||
- `headers`:过滤后将要转发的请求头。
|
||||
- `body`:明文;`kind=file` 或 `multipart` 时为空,请用 `body_base64`。
|
||||
- 启动日志含 `forward_enabled=false`;应用日志行为 `dry_run | target=... | body_len=N`。
|
||||
|
||||
本地排查示例:
|
||||
|
||||
```env
|
||||
ENABLE_FORWARD=false
|
||||
ALLOW_IPS=
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
@@ -79,6 +109,7 @@ FILE_TARGET_URL=http://127.0.0.1:18001/api/u
|
||||
|
||||
```text
|
||||
2026-05-19T22:00:01+08:00 | 192.168.1.20 | supervise | http=200 | 120ms | https://59.202.52.129:28212/...
|
||||
2026-05-19T22:00:02+08:00 | 192.168.1.20 | supervise | dry_run | target=https://... | body_len=1024 | 3ms
|
||||
```
|
||||
|
||||
不记录完整 body 与 uploadToken。
|
||||
|
||||
@@ -15,6 +15,7 @@ var (
|
||||
appLogW io.Writer
|
||||
)
|
||||
|
||||
// initAppLog 创建日志目录并打开当日日志文件 app-YYYY-MM-DD.log,同时输出到控制台。
|
||||
func initAppLog() error {
|
||||
root := resolveLogRoot("forward")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
@@ -29,6 +30,7 @@ func initAppLog() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveLogRoot 解析日志根目录:优先 LOG_DIR/forward,否则 {程序目录}/../log/forward/。
|
||||
func resolveLogRoot(service string) string {
|
||||
if v := strings.TrimSpace(os.Getenv("LOG_DIR")); v != "" {
|
||||
return filepath.Join(v, service)
|
||||
@@ -37,6 +39,7 @@ func resolveLogRoot(service string) string {
|
||||
return filepath.Join(base, "..", "log", service)
|
||||
}
|
||||
|
||||
// programDir 返回程序所在目录;go run 临时目录时回退为当前工作目录。
|
||||
func programDir() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
@@ -53,6 +56,7 @@ func programDir() string {
|
||||
return "."
|
||||
}
|
||||
|
||||
// appLogf 线程安全地写入应用日志(带 [forward] 前缀)。
|
||||
func appLogf(format string, args ...any) {
|
||||
logMu.Lock()
|
||||
w := appLogW
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 配置读取:环境变量、默认上游地址、布尔开关解析。
|
||||
package forward
|
||||
|
||||
import (
|
||||
@@ -5,14 +6,31 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 政务云默认上游地址(未配置 SUPERVISE_TARGET_URL / FILE_TARGET_URL 时使用)。
|
||||
const (
|
||||
DefaultSuperviseTarget = "https://59.202.52.129:28212/province/supervise/data"
|
||||
DefaultFileTarget = "https://59.202.52.129:28211/mng/file/auth/upload"
|
||||
)
|
||||
|
||||
// env 读取字符串环境变量;空或仅空白时返回默认值 def。
|
||||
func env(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// envBool 读取布尔环境变量。
|
||||
// 未设置或空 → 返回 def;false/0/no/off(不区分大小写)→ false;其余非空值 → true。
|
||||
func envBool(key string, def bool) bool {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "false", "0", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
42
internal/forward/config_test.go
Normal file
42
internal/forward/config_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvBool(t *testing.T) {
|
||||
t.Setenv("TEST_ENV_BOOL", "")
|
||||
if envBool("TEST_ENV_BOOL", true) != true {
|
||||
t.Fatal("empty should use default true")
|
||||
}
|
||||
if envBool("TEST_ENV_BOOL", false) != false {
|
||||
t.Fatal("empty should use default false")
|
||||
}
|
||||
|
||||
for _, v := range []string{"false", "FALSE", "0", "no", "NO", "off", "OFF"} {
|
||||
t.Setenv("TEST_ENV_BOOL", v)
|
||||
if envBool("TEST_ENV_BOOL", true) {
|
||||
t.Fatalf("%q should be false", v)
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range []string{"true", "1", "yes", "on"} {
|
||||
t.Setenv("TEST_ENV_BOOL", v)
|
||||
if !envBool("TEST_ENV_BOOL", false) {
|
||||
t.Fatalf("%q should be true", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvString(t *testing.T) {
|
||||
const key = "TEST_ENV_STRING_XK"
|
||||
_ = os.Unsetenv(key)
|
||||
if env(key, "default") != "default" {
|
||||
t.Fatal("unset should return default")
|
||||
}
|
||||
t.Setenv(key, " value ")
|
||||
if env(key, "default") != "value" {
|
||||
t.Fatal("should trim spaces")
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import (
|
||||
)
|
||||
|
||||
// LoadEnvFiles 从多个候选路径加载 .env(不覆盖已存在的 OS 环境变量)。
|
||||
// 查找顺序:CWD → programDir → programDir/..
|
||||
// 查找顺序:当前工作目录 → 可执行文件目录 → 可执行文件上级目录。
|
||||
func LoadEnvFiles() bool {
|
||||
candidates := envFileCandidates()
|
||||
for _, p := range candidates {
|
||||
// 步骤 1:路径不存在则尝试下一个候选
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
continue
|
||||
}
|
||||
// 步骤 2:加载成功即返回(godotenv 不覆盖已有环境变量)
|
||||
if err := godotenv.Load(p); err != nil {
|
||||
log.Printf("[forward] 加载 %s 失败: %v", p, err)
|
||||
continue
|
||||
@@ -27,6 +29,7 @@ func LoadEnvFiles() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// envFileCandidates 生成 .env 候选路径列表(去重)。
|
||||
func envFileCandidates() []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
|
||||
@@ -2,6 +2,8 @@ package forward
|
||||
|
||||
import "net/http"
|
||||
|
||||
// superviseAllowedHeaders 监管通道(28212)允许透传到政务云的 Header 白名单。
|
||||
// 与 transit-go hy.BuildUpload 组包一致;其余头(Cookie、User-Agent、X-Forward-Token 等)会被剥离。
|
||||
var superviseAllowedHeaders = []string{
|
||||
"Content-Type",
|
||||
"X-Ca-Appkey",
|
||||
@@ -14,11 +16,13 @@ var superviseAllowedHeaders = []string{
|
||||
"requestBody",
|
||||
}
|
||||
|
||||
// fileAllowedHeaders 文件上传通道(28211)允许透传的 Header 白名单。
|
||||
var fileAllowedHeaders = []string{
|
||||
"Content-Type",
|
||||
"X-Authorization",
|
||||
}
|
||||
|
||||
// allowedHeaderSet 按通道 kind 返回允许头的 CanonicalKey 集合,供 O(1) 查找。
|
||||
func allowedHeaderSet(kind string) map[string]struct{} {
|
||||
list := superviseAllowedHeaders
|
||||
if kind == "file" {
|
||||
@@ -31,6 +35,7 @@ func allowedHeaderSet(kind string) map[string]struct{} {
|
||||
return set
|
||||
}
|
||||
|
||||
// copyAllowedHeaders 将 src 中位于白名单内的头复制到 dst(真实转发与回显模式共用)。
|
||||
func copyAllowedHeaders(dst, src http.Header, kind string) {
|
||||
allow := allowedHeaderSet(kind)
|
||||
for key, values := range src {
|
||||
|
||||
@@ -2,7 +2,10 @@ package forward
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -10,47 +13,102 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// dryRunResponse 为 ENABLE_FORWARD=false 时返回给 transit-go 的 JSON 结构(回显将要转发的出站内容)。
|
||||
type dryRunResponse struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Kind string `json:"kind"`
|
||||
TargetURL string `json:"target_url"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Body string `json:"body,omitempty"`
|
||||
BodyBase64 string `json:"body_base64"`
|
||||
}
|
||||
|
||||
// newReverseProxy 创建指向 target 的反向代理;Director 内会改写 URL 并过滤 Header 白名单。
|
||||
func newReverseProxy(target, kind string) (*httputil.ReverseProxy, error) {
|
||||
// 步骤 1:解析上游固定地址
|
||||
targetURL, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
// 步骤 2:政务网自签证书,TLS 跳过校验(与现网一致)
|
||||
proxy.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书
|
||||
}
|
||||
// 步骤 3:每次转发前改写出站 URL 与 Header(Body 由 ReverseProxy 原样流式透传)
|
||||
proxy.Director = func(req *http.Request) {
|
||||
req.URL.Scheme = targetURL.Scheme
|
||||
req.URL.Host = targetURL.Host
|
||||
req.URL.Path = targetURL.Path
|
||||
req.URL.RawPath = targetURL.RawPath
|
||||
req.URL.RawQuery = targetURL.RawQuery
|
||||
req.Host = targetURL.Host
|
||||
filtered := make(http.Header)
|
||||
copyAllowedHeaders(filtered, req.Header, kind)
|
||||
req.Header = filtered
|
||||
applyOutboundTarget(req, targetURL, kind)
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *httputil.ReverseProxy, target, allowIPs, forwardSecret string) {
|
||||
// applyOutboundTarget 将入站请求改写为指向政务云的出站形态(固定 URL + Header 白名单,不读 Body)。
|
||||
func applyOutboundTarget(req *http.Request, targetURL *url.URL, kind string) {
|
||||
req.URL.Scheme = targetURL.Scheme
|
||||
req.URL.Host = targetURL.Host
|
||||
req.URL.Path = targetURL.Path
|
||||
req.URL.RawPath = targetURL.RawPath
|
||||
req.URL.RawQuery = targetURL.RawQuery
|
||||
req.Host = targetURL.Host
|
||||
filtered := make(http.Header)
|
||||
copyAllowedHeaders(filtered, req.Header, kind)
|
||||
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) {
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
return parsed.String(), hdr, body, nil
|
||||
}
|
||||
|
||||
// handleForward 处理监管/文件两条转发路由:校验 → 回显或真实转发 → 记日志。
|
||||
func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *httputil.ReverseProxy, target, allowIPs, forwardSecret string, enableForward bool) {
|
||||
// 步骤 1:仅允许 POST
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
// 步骤 2:来源 IP 白名单(ALLOW_IPS 非空时生效)
|
||||
if allowIPs != "" && !ipAllowed(ip, allowIPs) {
|
||||
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 {
|
||||
appLogf("%s | %s | %s | forbidden | bad forward token", time.Now().Format(time.RFC3339), ip, kind)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// 步骤 4:未开启真实转发时,回显将要发往政务云的请求头与请求体
|
||||
if !enableForward {
|
||||
handleDryRun(w, r, kind, target, ip)
|
||||
return
|
||||
}
|
||||
// 步骤 5:ReverseProxy 转发至政务云,记录耗时与上游 HTTP 状态
|
||||
start := time.Now()
|
||||
sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
proxy.ServeHTTP(sw, r)
|
||||
@@ -60,6 +118,57 @@ func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *h
|
||||
log.Printf("forward %s %s http=%d %dms", ip, kind, sw.status, ms)
|
||||
}
|
||||
|
||||
// handleDryRun 不请求政务云,将按转发规则组装后的出站 URL、Header、Body 以 JSON 返回给 transit-go。
|
||||
func handleDryRun(w http.ResponseWriter, r *http.Request, kind, target, ip string) {
|
||||
start := time.Now()
|
||||
// 步骤 1:按与 Director 相同规则组装出站快照
|
||||
targetURL, headers, body, err := buildOutboundSnapshot(r, target, kind)
|
||||
if err != nil {
|
||||
appLogf("%s | %s | %s | dry_run_error | %v", time.Now().Format(time.RFC3339), ip, kind, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 步骤 2:构造 JSON(body 明文仅 UTF-8 且非 multipart 时填充;body_base64 始终提供)
|
||||
resp := dryRunResponse{
|
||||
DryRun: true,
|
||||
Kind: kind,
|
||||
TargetURL: targetURL,
|
||||
Method: http.MethodPost,
|
||||
Headers: headers,
|
||||
BodyBase64: base64.StdEncoding.EncodeToString(body),
|
||||
}
|
||||
if plainBodyForDryRun(kind, headers, body) {
|
||||
resp.Body = string(body)
|
||||
}
|
||||
// 步骤 3:写回 transit-go
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
appLogf("%s | %s | %s | dry_run_encode_error | %v", time.Now().Format(time.RFC3339), ip, kind, err)
|
||||
return
|
||||
}
|
||||
ms := time.Since(start).Milliseconds()
|
||||
line := fmt.Sprintf("%s | %s | %s | dry_run | target=%s | body_len=%d | %dms", time.Now().Format(time.RFC3339), ip, kind, targetURL, len(body), ms)
|
||||
appLogf(line)
|
||||
log.Printf("dry_run %s %s body_len=%d %dms", ip, kind, len(body), ms)
|
||||
}
|
||||
|
||||
// plainBodyForDryRun 判断是否可在 JSON 的 body 字段中放明文(file/multipart 或非法 UTF-8 则仅 base64)。
|
||||
func plainBodyForDryRun(kind string, headers map[string][]string, body []byte) bool {
|
||||
if kind == "file" {
|
||||
return false
|
||||
}
|
||||
ct := ""
|
||||
if vs, ok := headers["Content-Type"]; ok && len(vs) > 0 {
|
||||
ct = strings.ToLower(vs[0])
|
||||
}
|
||||
if strings.Contains(ct, "multipart/") {
|
||||
return false
|
||||
}
|
||||
return utf8.Valid(body)
|
||||
}
|
||||
|
||||
// statusRecorder 包装 ResponseWriter,记录上游返回的 HTTP 状态码供日志使用。
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
@@ -70,6 +179,7 @@ func (w *statusRecorder) WriteHeader(code int) {
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// clientIP 从 RemoteAddr 解析客户端 IP(去掉端口)。
|
||||
func clientIP(r *http.Request) string {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
@@ -78,6 +188,7 @@ func clientIP(r *http.Request) string {
|
||||
return ip
|
||||
}
|
||||
|
||||
// ipAllowed 判断 ip 是否在逗号分隔的 ALLOW_IPS 列表中(精确匹配)。
|
||||
func ipAllowed(ip, allow string) bool {
|
||||
for _, part := range strings.Split(allow, ",") {
|
||||
if strings.TrimSpace(part) == ip {
|
||||
|
||||
@@ -2,9 +2,12 @@ package forward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -104,9 +107,88 @@ func TestHandleForwardRejectsBadForwardToken(t *testing.T) {
|
||||
req.Header.Set("X-Forward-Token", "wrong")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handleForward(rec, req, "supervise", proxy, upstream.URL, "", "expected-secret")
|
||||
handleForward(rec, req, "supervise", proxy, upstream.URL, "", "expected-secret", true)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForwardDryRunReturnsOutboundPayload(t *testing.T) {
|
||||
const cipher = "BASE64_CIPHER_EXAMPLE"
|
||||
var upstreamHits atomic.Int32
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamHits.Add(1)
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, err := newReverseProxy(upstream.URL, "supervise")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/province/supervise/data", bytes.NewReader([]byte(cipher)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Ca-Signature", "sig")
|
||||
req.Header.Set("Requestbody", cipher)
|
||||
req.Header.Set("Cookie", "junk")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handleForward(rec, req, "supervise", proxy, upstream.URL, "", "", false)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstreamHits.Load() != 0 {
|
||||
t.Fatal("dry run must not call upstream")
|
||||
}
|
||||
|
||||
var resp dryRunResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resp.DryRun || resp.Kind != "supervise" || resp.TargetURL != upstream.URL {
|
||||
t.Fatalf("unexpected dry run meta: %+v", resp)
|
||||
}
|
||||
if resp.Headers["X-Ca-Signature"][0] != "sig" {
|
||||
t.Fatalf("signature header missing: %v", resp.Headers)
|
||||
}
|
||||
if _, ok := resp.Headers["Cookie"]; ok {
|
||||
t.Fatal("cookie must be stripped in dry run headers")
|
||||
}
|
||||
if resp.Body != cipher {
|
||||
t.Fatalf("plain body mismatch: %q", resp.Body)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(resp.BodyBase64)
|
||||
if err != nil || string(decoded) != cipher {
|
||||
t.Fatalf("base64 body mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForwardDryRunFileOmitsPlainBody(t *testing.T) {
|
||||
proxy, err := newReverseProxy("http://example.com/upload", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/mng/file/auth/upload", bytes.NewReader([]byte("binary")))
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=abc")
|
||||
req.Header.Set("X-Authorization", "tok")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handleForward(rec, req, "file", proxy, "http://example.com/upload", "", "", false)
|
||||
|
||||
var resp dryRunResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Body != "" {
|
||||
t.Fatalf("file channel should not include plain body, got %q", resp.Body)
|
||||
}
|
||||
if resp.BodyBase64 == "" {
|
||||
t.Fatal("body_base64 required")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package forward 内网双通道透明转发(监管 JSON + 处方 PDF)。
|
||||
// Package forward 内网双通道透明转发(监管 JSON + 处方 PDF),支持真实转发与回显模式。
|
||||
package forward
|
||||
|
||||
import (
|
||||
@@ -9,21 +9,27 @@ import (
|
||||
|
||||
// Run 启动 forward 全部 HTTP 服务:/health、/province/supervise/data、/mng/file/auth/upload。
|
||||
func Run() {
|
||||
// 步骤 1:从 CWD / 程序目录加载 .env(不覆盖已有 OS 环境变量)
|
||||
LoadEnvFiles()
|
||||
// 步骤 2:初始化按日滚动的应用日志
|
||||
if err := initAppLog(); err != nil {
|
||||
log.Printf("应用日志初始化失败: %v", err)
|
||||
}
|
||||
|
||||
// 步骤 3:读取监听地址、访问控制、是否真实转发
|
||||
listen := env("LISTEN_ADDR", ":16001")
|
||||
allowIPs := env("ALLOW_IPS", "")
|
||||
forwardSecret := env("FORWARD_SHARED_SECRET", "")
|
||||
enableForward := envBool("ENABLE_FORWARD", true)
|
||||
|
||||
// 步骤 4:解析两条通道的上游 URL
|
||||
superviseTarget := env("SUPERVISE_TARGET_URL", "")
|
||||
if superviseTarget == "" {
|
||||
superviseTarget = env("TARGET_URL", DefaultSuperviseTarget)
|
||||
}
|
||||
fileTarget := env("FILE_TARGET_URL", DefaultFileTarget)
|
||||
|
||||
// 步骤 5:为两条通道各创建一个 ReverseProxy(回显模式时不会调用 ServeHTTP,但 URL 校验仍在此完成)
|
||||
superviseProxy, err := newReverseProxy(superviseTarget, "supervise")
|
||||
if err != nil {
|
||||
log.Fatalf("SUPERVISE_TARGET_URL 无效: %v", err)
|
||||
@@ -35,20 +41,25 @@ func Run() {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// 步骤 6:健康检查
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// 步骤 7:监管业务 JSON 通道
|
||||
mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleForward(w, r, "supervise", superviseProxy, superviseTarget, allowIPs, forwardSecret)
|
||||
handleForward(w, r, "supervise", superviseProxy, superviseTarget, allowIPs, forwardSecret, enableForward)
|
||||
})
|
||||
|
||||
// 步骤 8:处方 PDF 上传通道
|
||||
mux.HandleFunc("/mng/file/auth/upload", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleForward(w, r, "file", fileProxy, fileTarget, allowIPs, forwardSecret)
|
||||
handleForward(w, r, "file", fileProxy, fileTarget, allowIPs, forwardSecret, enableForward)
|
||||
})
|
||||
|
||||
msg := fmt.Sprintf("xk-hy-forward-go 监听 %s | supervise=%s | file=%s", listen, superviseTarget, fileTarget)
|
||||
// 步骤 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 {
|
||||
|
||||
Reference in New Issue
Block a user