更新
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
# 监听地址(内网机,供外网 transit-go 访问)
|
# 监听地址(内网机,供外网 transit-go 访问)
|
||||||
LISTEN_ADDR=:16001
|
LISTEN_ADDR=:16001
|
||||||
|
|
||||||
|
# 是否真正转发到政务云:true/1/yes(默认);false/0/no 时仅回显将要转发的请求头与请求体给 transit-go
|
||||||
|
ENABLE_FORWARD=true
|
||||||
|
|
||||||
# 通道一:业务监管数据上报(28212)
|
# 通道一:业务监管数据上报(28212)
|
||||||
SUPERVISE_TARGET_URL=https://59.202.52.129:28212/province/supervise/data
|
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 不会把 `;` 行当作注释)。
|
- 请使用 **`#` 作为注释**(不要用 `;`,godotenv 不会把 `;` 行当作注释)。
|
||||||
- 从 IDE / 服务方式启动时,请把 **Working Directory** 设为 `xk-hy-forward-go` 项目根,或把 `.env` 放在 **exe 同目录**。
|
- 从 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` 时生效 |
|
| `TARGET_URL` | (兼容) | 未设 `SUPERVISE_TARGET_URL` 时生效 |
|
||||||
| `ALLOW_IPS` | 空 | 逗号分隔来源 IP;**生产建议填 transit 出口 IP** |
|
| `ALLOW_IPS` | 空 | 逗号分隔来源 IP;**生产建议填 transit 出口 IP** |
|
||||||
| `FORWARD_SHARED_SECRET` | 空 | 非空时要求请求头 `X-Forward-Token` 一致(与 transit `.env` 同值) |
|
| `FORWARD_SHARED_SECRET` | 空 | 非空时要求请求头 `X-Forward-Token` 一致(与 transit `.env` 同值) |
|
||||||
|
| `ENABLE_FORWARD` | `true` | 是否真正转发到政务云;`false`/`0`/`no` 时进入回显模式(见下) |
|
||||||
| `LOG_DIR` | 空 | 日志根目录;默认 `{程序目录}/../log/forward/` |
|
| `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
|
```bash
|
||||||
@@ -79,6 +109,7 @@ FILE_TARGET_URL=http://127.0.0.1:18001/api/u
|
|||||||
|
|
||||||
```text
|
```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: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。
|
不记录完整 body 与 uploadToken。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var (
|
|||||||
appLogW io.Writer
|
appLogW io.Writer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// initAppLog 创建日志目录并打开当日日志文件 app-YYYY-MM-DD.log,同时输出到控制台。
|
||||||
func initAppLog() error {
|
func initAppLog() error {
|
||||||
root := resolveLogRoot("forward")
|
root := resolveLogRoot("forward")
|
||||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
@@ -29,6 +30,7 @@ func initAppLog() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLogRoot 解析日志根目录:优先 LOG_DIR/forward,否则 {程序目录}/../log/forward/。
|
||||||
func resolveLogRoot(service string) string {
|
func resolveLogRoot(service string) string {
|
||||||
if v := strings.TrimSpace(os.Getenv("LOG_DIR")); v != "" {
|
if v := strings.TrimSpace(os.Getenv("LOG_DIR")); v != "" {
|
||||||
return filepath.Join(v, service)
|
return filepath.Join(v, service)
|
||||||
@@ -37,6 +39,7 @@ func resolveLogRoot(service string) string {
|
|||||||
return filepath.Join(base, "..", "log", service)
|
return filepath.Join(base, "..", "log", service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// programDir 返回程序所在目录;go run 临时目录时回退为当前工作目录。
|
||||||
func programDir() string {
|
func programDir() string {
|
||||||
if exe, err := os.Executable(); err == nil {
|
if exe, err := os.Executable(); err == nil {
|
||||||
dir := filepath.Dir(exe)
|
dir := filepath.Dir(exe)
|
||||||
@@ -53,6 +56,7 @@ func programDir() string {
|
|||||||
return "."
|
return "."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appLogf 线程安全地写入应用日志(带 [forward] 前缀)。
|
||||||
func appLogf(format string, args ...any) {
|
func appLogf(format string, args ...any) {
|
||||||
logMu.Lock()
|
logMu.Lock()
|
||||||
w := appLogW
|
w := appLogW
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// 配置读取:环境变量、默认上游地址、布尔开关解析。
|
||||||
package forward
|
package forward
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,14 +6,31 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 政务云默认上游地址(未配置 SUPERVISE_TARGET_URL / FILE_TARGET_URL 时使用)。
|
||||||
const (
|
const (
|
||||||
DefaultSuperviseTarget = "https://59.202.52.129:28212/province/supervise/data"
|
DefaultSuperviseTarget = "https://59.202.52.129:28212/province/supervise/data"
|
||||||
DefaultFileTarget = "https://59.202.52.129:28211/mng/file/auth/upload"
|
DefaultFileTarget = "https://59.202.52.129:28211/mng/file/auth/upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// env 读取字符串环境变量;空或仅空白时返回默认值 def。
|
||||||
func env(key, def string) string {
|
func env(key, def string) string {
|
||||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return def
|
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 环境变量)。
|
// LoadEnvFiles 从多个候选路径加载 .env(不覆盖已存在的 OS 环境变量)。
|
||||||
// 查找顺序:CWD → programDir → programDir/..
|
// 查找顺序:当前工作目录 → 可执行文件目录 → 可执行文件上级目录。
|
||||||
func LoadEnvFiles() bool {
|
func LoadEnvFiles() bool {
|
||||||
candidates := envFileCandidates()
|
candidates := envFileCandidates()
|
||||||
for _, p := range candidates {
|
for _, p := range candidates {
|
||||||
|
// 步骤 1:路径不存在则尝试下一个候选
|
||||||
if _, err := os.Stat(p); err != nil {
|
if _, err := os.Stat(p); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 步骤 2:加载成功即返回(godotenv 不覆盖已有环境变量)
|
||||||
if err := godotenv.Load(p); err != nil {
|
if err := godotenv.Load(p); err != nil {
|
||||||
log.Printf("[forward] 加载 %s 失败: %v", p, err)
|
log.Printf("[forward] 加载 %s 失败: %v", p, err)
|
||||||
continue
|
continue
|
||||||
@@ -27,6 +29,7 @@ func LoadEnvFiles() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envFileCandidates 生成 .env 候选路径列表(去重)。
|
||||||
func envFileCandidates() []string {
|
func envFileCandidates() []string {
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
var out []string
|
var out []string
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package forward
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
|
// superviseAllowedHeaders 监管通道(28212)允许透传到政务云的 Header 白名单。
|
||||||
|
// 与 transit-go hy.BuildUpload 组包一致;其余头(Cookie、User-Agent、X-Forward-Token 等)会被剥离。
|
||||||
var superviseAllowedHeaders = []string{
|
var superviseAllowedHeaders = []string{
|
||||||
"Content-Type",
|
"Content-Type",
|
||||||
"X-Ca-Appkey",
|
"X-Ca-Appkey",
|
||||||
@@ -14,11 +16,13 @@ var superviseAllowedHeaders = []string{
|
|||||||
"requestBody",
|
"requestBody",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fileAllowedHeaders 文件上传通道(28211)允许透传的 Header 白名单。
|
||||||
var fileAllowedHeaders = []string{
|
var fileAllowedHeaders = []string{
|
||||||
"Content-Type",
|
"Content-Type",
|
||||||
"X-Authorization",
|
"X-Authorization",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allowedHeaderSet 按通道 kind 返回允许头的 CanonicalKey 集合,供 O(1) 查找。
|
||||||
func allowedHeaderSet(kind string) map[string]struct{} {
|
func allowedHeaderSet(kind string) map[string]struct{} {
|
||||||
list := superviseAllowedHeaders
|
list := superviseAllowedHeaders
|
||||||
if kind == "file" {
|
if kind == "file" {
|
||||||
@@ -31,6 +35,7 @@ func allowedHeaderSet(kind string) map[string]struct{} {
|
|||||||
return set
|
return set
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyAllowedHeaders 将 src 中位于白名单内的头复制到 dst(真实转发与回显模式共用)。
|
||||||
func copyAllowedHeaders(dst, src http.Header, kind string) {
|
func copyAllowedHeaders(dst, src http.Header, kind string) {
|
||||||
allow := allowedHeaderSet(kind)
|
allow := allowedHeaderSet(kind)
|
||||||
for key, values := range src {
|
for key, values := range src {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package forward
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,47 +13,102 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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) {
|
func newReverseProxy(target, kind string) (*httputil.ReverseProxy, error) {
|
||||||
|
// 步骤 1:解析上游固定地址
|
||||||
targetURL, err := url.Parse(target)
|
targetURL, err := url.Parse(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||||
|
// 步骤 2:政务网自签证书,TLS 跳过校验(与现网一致)
|
||||||
proxy.Transport = &http.Transport{
|
proxy.Transport = &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书
|
||||||
}
|
}
|
||||||
|
// 步骤 3:每次转发前改写出站 URL 与 Header(Body 由 ReverseProxy 原样流式透传)
|
||||||
proxy.Director = func(req *http.Request) {
|
proxy.Director = func(req *http.Request) {
|
||||||
req.URL.Scheme = targetURL.Scheme
|
applyOutboundTarget(req, targetURL, kind)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return proxy, nil
|
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 {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := clientIP(r)
|
ip := clientIP(r)
|
||||||
|
// 步骤 2:来源 IP 白名单(ALLOW_IPS 非空时生效)
|
||||||
if allowIPs != "" && !ipAllowed(ip, allowIPs) {
|
if allowIPs != "" && !ipAllowed(ip, allowIPs) {
|
||||||
appLogf("%s | %s | %s | forbidden | not in ALLOW_IPS", time.Now().Format(time.RFC3339), ip, kind)
|
appLogf("%s | %s | %s | forbidden | not in ALLOW_IPS", time.Now().Format(time.RFC3339), ip, kind)
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 步骤 3:内网共享密钥(FORWARD_SHARED_SECRET 非空时校验 X-Forward-Token)
|
||||||
if forwardSecret != "" && r.Header.Get("X-Forward-Token") != forwardSecret {
|
if forwardSecret != "" && r.Header.Get("X-Forward-Token") != forwardSecret {
|
||||||
appLogf("%s | %s | %s | forbidden | bad forward token", time.Now().Format(time.RFC3339), ip, kind)
|
appLogf("%s | %s | %s | forbidden | bad forward token", time.Now().Format(time.RFC3339), ip, kind)
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 步骤 4:未开启真实转发时,回显将要发往政务云的请求头与请求体
|
||||||
|
if !enableForward {
|
||||||
|
handleDryRun(w, r, kind, target, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 步骤 5:ReverseProxy 转发至政务云,记录耗时与上游 HTTP 状态
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
proxy.ServeHTTP(sw, r)
|
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)
|
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 {
|
type statusRecorder struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
status int
|
status int
|
||||||
@@ -70,6 +179,7 @@ func (w *statusRecorder) WriteHeader(code int) {
|
|||||||
w.ResponseWriter.WriteHeader(code)
|
w.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientIP 从 RemoteAddr 解析客户端 IP(去掉端口)。
|
||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,6 +188,7 @@ func clientIP(r *http.Request) string {
|
|||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ipAllowed 判断 ip 是否在逗号分隔的 ALLOW_IPS 列表中(精确匹配)。
|
||||||
func ipAllowed(ip, allow string) bool {
|
func ipAllowed(ip, allow string) bool {
|
||||||
for _, part := range strings.Split(allow, ",") {
|
for _, part := range strings.Split(allow, ",") {
|
||||||
if strings.TrimSpace(part) == ip {
|
if strings.TrimSpace(part) == ip {
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package forward
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,9 +107,88 @@ func TestHandleForwardRejectsBadForwardToken(t *testing.T) {
|
|||||||
req.Header.Set("X-Forward-Token", "wrong")
|
req.Header.Set("X-Forward-Token", "wrong")
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
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 {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("want 403 got %d", rec.Code)
|
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
|
package forward
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,21 +9,27 @@ import (
|
|||||||
|
|
||||||
// Run 启动 forward 全部 HTTP 服务:/health、/province/supervise/data、/mng/file/auth/upload。
|
// Run 启动 forward 全部 HTTP 服务:/health、/province/supervise/data、/mng/file/auth/upload。
|
||||||
func Run() {
|
func Run() {
|
||||||
|
// 步骤 1:从 CWD / 程序目录加载 .env(不覆盖已有 OS 环境变量)
|
||||||
LoadEnvFiles()
|
LoadEnvFiles()
|
||||||
|
// 步骤 2:初始化按日滚动的应用日志
|
||||||
if err := initAppLog(); err != nil {
|
if err := initAppLog(); err != nil {
|
||||||
log.Printf("应用日志初始化失败: %v", err)
|
log.Printf("应用日志初始化失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 步骤 3:读取监听地址、访问控制、是否真实转发
|
||||||
listen := env("LISTEN_ADDR", ":16001")
|
listen := env("LISTEN_ADDR", ":16001")
|
||||||
allowIPs := env("ALLOW_IPS", "")
|
allowIPs := env("ALLOW_IPS", "")
|
||||||
forwardSecret := env("FORWARD_SHARED_SECRET", "")
|
forwardSecret := env("FORWARD_SHARED_SECRET", "")
|
||||||
|
enableForward := envBool("ENABLE_FORWARD", true)
|
||||||
|
|
||||||
|
// 步骤 4:解析两条通道的上游 URL
|
||||||
superviseTarget := env("SUPERVISE_TARGET_URL", "")
|
superviseTarget := env("SUPERVISE_TARGET_URL", "")
|
||||||
if superviseTarget == "" {
|
if superviseTarget == "" {
|
||||||
superviseTarget = env("TARGET_URL", DefaultSuperviseTarget)
|
superviseTarget = env("TARGET_URL", DefaultSuperviseTarget)
|
||||||
}
|
}
|
||||||
fileTarget := env("FILE_TARGET_URL", DefaultFileTarget)
|
fileTarget := env("FILE_TARGET_URL", DefaultFileTarget)
|
||||||
|
|
||||||
|
// 步骤 5:为两条通道各创建一个 ReverseProxy(回显模式时不会调用 ServeHTTP,但 URL 校验仍在此完成)
|
||||||
superviseProxy, err := newReverseProxy(superviseTarget, "supervise")
|
superviseProxy, err := newReverseProxy(superviseTarget, "supervise")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("SUPERVISE_TARGET_URL 无效: %v", err)
|
log.Fatalf("SUPERVISE_TARGET_URL 无效: %v", err)
|
||||||
@@ -35,20 +41,25 @@ func Run() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// 步骤 6:健康检查
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 步骤 7:监管业务 JSON 通道
|
||||||
mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) {
|
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) {
|
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)
|
appLogf(msg)
|
||||||
log.Print(msg)
|
log.Print(msg)
|
||||||
if err := http.ListenAndServe(listen, mux); err != nil {
|
if err := http.ListenAndServe(listen, mux); err != nil {
|
||||||
|
|||||||
4
main.go
4
main.go
@@ -1,4 +1,6 @@
|
|||||||
// xk-hy-forward-go 入口。启动:go run main.go
|
// xk-hy-forward-go 程序入口。
|
||||||
|
// 职责:启动 internal/forward 包中的 HTTP 服务(监管转发、文件上传转发、健康检查)。
|
||||||
|
// 启动方式:在项目根目录执行 go run main.go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "xk-hy-forward-go/internal/forward"
|
import "xk-hy-forward-go/internal/forward"
|
||||||
|
|||||||
Reference in New Issue
Block a user