更新
This commit is contained in:
@@ -10,8 +10,11 @@ FILE_TARGET_URL=https://59.202.52.129:28211/mng/file/auth/upload
|
||||
# 兼容旧配置:仅当未设置 SUPERVISE_TARGET_URL 时生效
|
||||
# TARGET_URL=https://59.202.52.129:28212/province/supervise/data
|
||||
|
||||
# 逗号分隔允许来源 IP,空表示不限制(建议填 transit 外网机出口 IP)
|
||||
ALLOW_IPS=
|
||||
# 逗号分隔允许来源 IP,空表示不限制(生产建议填 transit 外网机出口 IP,示例:192.168.1.20)
|
||||
ALLOW_IPS=192.168.1.20
|
||||
|
||||
# 与 transit-go FORWARD_SHARED_SECRET 一致;非空时要求 Header X-Forward-Token
|
||||
# FORWARD_SHARED_SECRET=change-me-in-production
|
||||
|
||||
# 应用日志根目录(默认 {程序目录}/../log/forward/)
|
||||
# LOG_DIR=D:\worker\code\log
|
||||
|
||||
57
README.md
57
README.md
@@ -1,6 +1,6 @@
|
||||
# xk-hy-forward-go
|
||||
|
||||
内网双通道透明转发:外网 `xk-hy-transit-go` 只访问本服务,由本机转发至浙江省政务云(`59.202.52.129`)。
|
||||
内网双通道转发:外网 `xk-hy-transit-go` 只访问本服务,由本机转发至浙江省政务云(`59.202.52.129`)。
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -10,36 +10,75 @@
|
||||
| `POST /mng/file/auth/upload` | `FILE_TARGET_URL`(28211) | 处方 PDF multipart 上传 |
|
||||
| `GET /health` | 本地 | 健康检查 |
|
||||
|
||||
请求头与 body **原样透传**(含 `X-Authorization` uploadToken、multipart boundary)。
|
||||
转发规则:
|
||||
|
||||
- **URL**:由环境变量固定(Scheme/Host/Path),不使用客户端 URL。
|
||||
- **Body**:原样透传。
|
||||
- **Header**:仅白名单内的业务头透传到政务云;`User-Agent`、`Cookie`、`X-Forwarded-*`、`X-Forward-Token` 等不会带上游。
|
||||
- 监管白名单含 `requestBody`、`X-Ca-Signature` 等(与 transit `hy.BuildUpload` 一致)。
|
||||
|
||||
## 环境配置
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env 后,建议在项目根目录启动(见下方「启动与 .env 加载」)
|
||||
```
|
||||
|
||||
### 启动与 .env 加载
|
||||
|
||||
程序启动时会按以下顺序查找并加载 **第一个存在的** `.env` 文件:
|
||||
|
||||
1. 当前工作目录(`os.Getwd()`)
|
||||
2. 可执行文件所在目录
|
||||
3. 可执行文件上级目录
|
||||
|
||||
加载成功时控制台会输出:`[forward] 已加载 /path/to/.env`。若未找到任何 `.env`,会提示使用环境变量或代码默认值。
|
||||
|
||||
**注意:**
|
||||
|
||||
- 请使用 **`#` 作为注释**(不要用 `;`,godotenv 不会把 `;` 行当作注释)。
|
||||
- 从 IDE / 服务方式启动时,请把 **Working Directory** 设为 `xk-hy-forward-go` 项目根,或把 `.env` 放在 **exe 同目录**。
|
||||
- 核对启动日志中的 `supervise=`、`file=` 是否为你在 `.env` 里配置的 URL。
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `LISTEN_ADDR` | `:8080` | 监听地址 |
|
||||
| `SUPERVISE_TARGET_URL` | `https://59.202.52.129:28212/province/supervise/data` | 监管业务上报 |
|
||||
| `FILE_TARGET_URL` | `https://59.202.52.129:28211/mng/file/auth/upload` | 处方 PDF 上传 |
|
||||
| `TARGET_URL` | (兼容) | 未设 `SUPERVISE_TARGET_URL` 时等同旧版 |
|
||||
| `ALLOW_IPS` | 空 | 逗号分隔来源 IP 白名单 |
|
||||
| `LISTEN_ADDR` | `:16001` | 监听地址(建议绑内网 IP) |
|
||||
| `SUPERVISE_TARGET_URL` | 政务云 28212 | 监管业务上报 |
|
||||
| `FILE_TARGET_URL` | 政务云 28211 | 处方 PDF 上传 |
|
||||
| `TARGET_URL` | (兼容) | 未设 `SUPERVISE_TARGET_URL` 时生效 |
|
||||
| `ALLOW_IPS` | 空 | 逗号分隔来源 IP;**生产建议填 transit 出口 IP** |
|
||||
| `FORWARD_SHARED_SECRET` | 空 | 非空时要求请求头 `X-Forward-Token` 一致(与 transit `.env` 同值) |
|
||||
| `LOG_DIR` | 空 | 日志根目录;默认 `{程序目录}/../log/forward/` |
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
go run .
|
||||
cd xk-hy-forward-go
|
||||
go run main.go
|
||||
go test ./...
|
||||
```
|
||||
|
||||
`main.go` 仅作入口,业务逻辑在 `internal/forward` 包;`go run main.go` 会编译入口及其依赖,启动全部路由(`/health`、监管、文件上传)。等价方式:`go run .`、`go build -o forward.exe .`。
|
||||
|
||||
Windows 也可双击或执行项目根目录下的 [`run.bat`](d:/worker/code/xk-hy-forward-go/run.bat)(内部为 `go run main.go`)。
|
||||
|
||||
IDE 调试:Working Directory 设为项目根,运行文件选 `main.go` 即可。
|
||||
|
||||
本地联调 mock 上游时,在 `.env` 中设置:
|
||||
|
||||
```env
|
||||
SUPERVISE_TARGET_URL=http://127.0.0.1:18001/api/
|
||||
FILE_TARGET_URL=http://127.0.0.1:18001/api/u
|
||||
```
|
||||
|
||||
未配置 `SUPERVISE_TARGET_URL` / `FILE_TARGET_URL` 时,代码默认指向政务云 `59.202.52.129`(与 `.env.example` 一致)。
|
||||
|
||||
## 应用日志
|
||||
|
||||
每条转发写入 `app-YYYY-MM-DD.log`(同时输出控制台),格式:
|
||||
|
||||
```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 | file | http=200 | 80ms | https://59.202.52.129:28211/...
|
||||
```
|
||||
|
||||
不记录完整 body 与 uploadToken。
|
||||
|
||||
64
internal/forward/applog.go
Normal file
64
internal/forward/applog.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
logMu sync.Mutex
|
||||
appLogW io.Writer
|
||||
)
|
||||
|
||||
func initAppLog() error {
|
||||
root := resolveLogRoot("forward")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
day := time.Now().Format("2006-01-02")
|
||||
f, err := os.OpenFile(filepath.Join(root, "app-"+day+".log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appLogW = io.MultiWriter(os.Stdout, f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveLogRoot(service string) string {
|
||||
if v := strings.TrimSpace(os.Getenv("LOG_DIR")); v != "" {
|
||||
return filepath.Join(v, service)
|
||||
}
|
||||
base := programDir()
|
||||
return filepath.Join(base, "..", "log", service)
|
||||
}
|
||||
|
||||
func programDir() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
if strings.Contains(dir, "go-build") {
|
||||
if wd, err := os.Getwd(); err == nil && wd != "" {
|
||||
return wd
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil && wd != "" {
|
||||
return wd
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
func appLogf(format string, args ...any) {
|
||||
logMu.Lock()
|
||||
w := appLogW
|
||||
logMu.Unlock()
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
log.New(w, "[forward] ", log.LstdFlags).Printf(format, args...)
|
||||
}
|
||||
18
internal/forward/config.go
Normal file
18
internal/forward/config.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSuperviseTarget = "https://59.202.52.129:28212/province/supervise/data"
|
||||
DefaultFileTarget = "https://59.202.52.129:28211/mng/file/auth/upload"
|
||||
)
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
48
internal/forward/env.go
Normal file
48
internal/forward/env.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// LoadEnvFiles 从多个候选路径加载 .env(不覆盖已存在的 OS 环境变量)。
|
||||
// 查找顺序:CWD → programDir → programDir/..
|
||||
func LoadEnvFiles() bool {
|
||||
candidates := envFileCandidates()
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := godotenv.Load(p); err != nil {
|
||||
log.Printf("[forward] 加载 %s 失败: %v", p, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[forward] 已加载 %s", p)
|
||||
return true
|
||||
}
|
||||
log.Print("[forward] 未找到 .env,将使用环境变量或代码默认值;请复制 .env.example 为 .env 并配置 SUPERVISE_TARGET_URL / FILE_TARGET_URL")
|
||||
return false
|
||||
}
|
||||
|
||||
func envFileCandidates() []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
add := func(p string) {
|
||||
p = filepath.Clean(p)
|
||||
if _, ok := seen[p]; ok {
|
||||
return
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil && wd != "" {
|
||||
add(filepath.Join(wd, ".env"))
|
||||
}
|
||||
base := programDir()
|
||||
add(filepath.Join(base, ".env"))
|
||||
add(filepath.Join(base, "..", ".env"))
|
||||
return out
|
||||
}
|
||||
27
internal/forward/env_test.go
Normal file
27
internal/forward/env_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvFileCandidates_includesCWDAndProgramDir(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cands := envFileCandidates()
|
||||
if len(cands) < 2 {
|
||||
t.Fatalf("expected multiple candidates, got %v", cands)
|
||||
}
|
||||
foundCWD := false
|
||||
for _, p := range cands {
|
||||
if p == filepath.Join(wd, ".env") {
|
||||
foundCWD = true
|
||||
}
|
||||
}
|
||||
if !foundCWD {
|
||||
t.Fatalf("CWD .env not in candidates: %v", cands)
|
||||
}
|
||||
}
|
||||
44
internal/forward/headers.go
Normal file
44
internal/forward/headers.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package forward
|
||||
|
||||
import "net/http"
|
||||
|
||||
var superviseAllowedHeaders = []string{
|
||||
"Content-Type",
|
||||
"X-Ca-Appkey",
|
||||
"X-Ca-Encryption",
|
||||
"X-Ca-Nonce",
|
||||
"X-Ca-Timestamp",
|
||||
"X-Service-Id",
|
||||
"X-Service-Method",
|
||||
"X-Ca-Signature",
|
||||
"requestBody",
|
||||
}
|
||||
|
||||
var fileAllowedHeaders = []string{
|
||||
"Content-Type",
|
||||
"X-Authorization",
|
||||
}
|
||||
|
||||
func allowedHeaderSet(kind string) map[string]struct{} {
|
||||
list := superviseAllowedHeaders
|
||||
if kind == "file" {
|
||||
list = fileAllowedHeaders
|
||||
}
|
||||
set := make(map[string]struct{}, len(list))
|
||||
for _, name := range list {
|
||||
set[http.CanonicalHeaderKey(name)] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func copyAllowedHeaders(dst, src http.Header, kind string) {
|
||||
allow := allowedHeaderSet(kind)
|
||||
for key, values := range src {
|
||||
if _, ok := allow[http.CanonicalHeaderKey(key)]; !ok {
|
||||
continue
|
||||
}
|
||||
for _, v := range values {
|
||||
dst.Add(key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
49
internal/forward/headers_test.go
Normal file
49
internal/forward/headers_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyAllowedHeaders_supervise(t *testing.T) {
|
||||
src := http.Header{}
|
||||
src.Set("Content-Type", "application/json")
|
||||
src.Set("X-Ca-Signature", "sig123")
|
||||
src.Set("requestBody", "cipher")
|
||||
src.Set("Cookie", "junk")
|
||||
src.Set("User-Agent", "Go-http-client/1.1")
|
||||
src.Set("X-Forwarded-For", "1.2.3.4")
|
||||
|
||||
dst := make(http.Header)
|
||||
copyAllowedHeaders(dst, src, "supervise")
|
||||
|
||||
if dst.Get("Cookie") != "" || dst.Get("User-Agent") != "" || dst.Get("X-Forwarded-For") != "" {
|
||||
t.Fatalf("junk headers must be stripped: %v", dst)
|
||||
}
|
||||
if dst.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("Content-Type: %v", dst)
|
||||
}
|
||||
if dst.Get("X-Ca-Signature") != "sig123" {
|
||||
t.Fatalf("X-Ca-Signature: %v", dst)
|
||||
}
|
||||
if dst.Get("Requestbody") != "cipher" {
|
||||
t.Fatalf("requestBody: %v", dst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAllowedHeaders_file(t *testing.T) {
|
||||
src := http.Header{}
|
||||
src.Set("Content-Type", "multipart/form-data; boundary=abc")
|
||||
src.Set("X-Authorization", "token")
|
||||
src.Set("Cookie", "x")
|
||||
|
||||
dst := make(http.Header)
|
||||
copyAllowedHeaders(dst, src, "file")
|
||||
|
||||
if dst.Get("Cookie") != "" {
|
||||
t.Fatal("cookie must be stripped")
|
||||
}
|
||||
if dst.Get("Content-Type") == "" || dst.Get("X-Authorization") == "" {
|
||||
t.Fatalf("missing required headers: %v", dst)
|
||||
}
|
||||
}
|
||||
88
internal/forward/proxy.go
Normal file
88
internal/forward/proxy.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newReverseProxy(target, kind string) (*httputil.ReverseProxy, error) {
|
||||
targetURL, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
proxy.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书
|
||||
}
|
||||
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
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *httputil.ReverseProxy, target, allowIPs, forwardSecret string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
start := time.Now()
|
||||
sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
proxy.ServeHTTP(sw, r)
|
||||
ms := time.Since(start).Milliseconds()
|
||||
line := fmt.Sprintf("%s | %s | %s | http=%d | %dms | %s", time.Now().Format(time.RFC3339), ip, kind, sw.status, ms, target)
|
||||
appLogf(line)
|
||||
log.Printf("forward %s %s http=%d %dms", ip, kind, sw.status, ms)
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusRecorder) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func ipAllowed(ip, allow string) bool {
|
||||
for _, part := range strings.Split(allow, ",") {
|
||||
if strings.TrimSpace(part) == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
112
internal/forward/proxy_test.go
Normal file
112
internal/forward/proxy_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSuperviseProxyUpstreamHeadersAndBody(t *testing.T) {
|
||||
const cipher = "BASE64_CIPHER_EXAMPLE"
|
||||
var upstreamBody []byte
|
||||
var upstreamHdr http.Header
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamBody, _ = io.ReadAll(r.Body)
|
||||
upstreamHdr = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"code":200}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, err := newReverseProxy(upstream.URL, "supervise")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(cipher)
|
||||
req := httptest.NewRequest(http.MethodPost, "http://forward.local/province/supervise/data", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Ca-Signature", "sig")
|
||||
req.Header.Set("requestBody", cipher)
|
||||
req.Header.Set("X-Ca-Appkey", "key")
|
||||
req.Header.Set("X-Ca-Encryption", "AES")
|
||||
req.Header.Set("X-Ca-Nonce", "n")
|
||||
req.Header.Set("X-Ca-Timestamp", "1716200000")
|
||||
req.Header.Set("X-Service-Id", "his.provinceDataUploadService")
|
||||
req.Header.Set("X-Service-Method", "uploadConsultIndicators")
|
||||
req.Header.Set("Cookie", "junk")
|
||||
req.Header.Set("User-Agent", "test-agent")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upstream status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if string(upstreamBody) != cipher {
|
||||
t.Fatalf("body mismatch: got %q want %q", upstreamBody, cipher)
|
||||
}
|
||||
if upstreamHdr.Get("Requestbody") != cipher {
|
||||
t.Fatalf("requestBody header missing or wrong: %v", upstreamHdr)
|
||||
}
|
||||
if upstreamHdr.Get("X-Ca-Signature") != "sig" {
|
||||
t.Fatal("X-Ca-Signature stripped")
|
||||
}
|
||||
if upstreamHdr.Get("Cookie") != "" || upstreamHdr.Get("User-Agent") != "" {
|
||||
t.Fatalf("junk headers leaked: %v", upstreamHdr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileProxyStripsJunkKeepsMultipart(t *testing.T) {
|
||||
var upstreamHdr http.Header
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamHdr = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy, err := newReverseProxy(upstream.URL, "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "http://forward.local/mng/file/auth/upload", bytes.NewReader([]byte("x")))
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=abc")
|
||||
req.Header.Set("X-Authorization", "tok")
|
||||
req.Header.Set("Cookie", "c")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
if upstreamHdr.Get("Content-Type") == "" || upstreamHdr.Get("X-Authorization") != "tok" {
|
||||
t.Fatalf("required headers missing: %v", upstreamHdr)
|
||||
}
|
||||
if upstreamHdr.Get("Cookie") != "" {
|
||||
t.Fatal("cookie must not reach upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForwardRejectsBadForwardToken(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
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("x")))
|
||||
req.Header.Set("X-Forward-Token", "wrong")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handleForward(rec, req, "supervise", proxy, upstream.URL, "", "expected-secret")
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
57
internal/forward/run.go
Normal file
57
internal/forward/run.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Package forward 内网双通道透明转发(监管 JSON + 处方 PDF)。
|
||||
package forward
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Run 启动 forward 全部 HTTP 服务:/health、/province/supervise/data、/mng/file/auth/upload。
|
||||
func Run() {
|
||||
LoadEnvFiles()
|
||||
if err := initAppLog(); err != nil {
|
||||
log.Printf("应用日志初始化失败: %v", err)
|
||||
}
|
||||
|
||||
listen := env("LISTEN_ADDR", ":16001")
|
||||
allowIPs := env("ALLOW_IPS", "")
|
||||
forwardSecret := env("FORWARD_SHARED_SECRET", "")
|
||||
|
||||
superviseTarget := env("SUPERVISE_TARGET_URL", "")
|
||||
if superviseTarget == "" {
|
||||
superviseTarget = env("TARGET_URL", DefaultSuperviseTarget)
|
||||
}
|
||||
fileTarget := env("FILE_TARGET_URL", DefaultFileTarget)
|
||||
|
||||
superviseProxy, err := newReverseProxy(superviseTarget, "supervise")
|
||||
if err != nil {
|
||||
log.Fatalf("SUPERVISE_TARGET_URL 无效: %v", err)
|
||||
}
|
||||
fileProxy, err := newReverseProxy(fileTarget, "file")
|
||||
if err != nil {
|
||||
log.Fatalf("FILE_TARGET_URL 无效: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleForward(w, r, "supervise", superviseProxy, superviseTarget, allowIPs, forwardSecret)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/mng/file/auth/upload", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleForward(w, r, "file", fileProxy, fileTarget, allowIPs, forwardSecret)
|
||||
})
|
||||
|
||||
msg := fmt.Sprintf("xk-hy-forward-go 监听 %s | supervise=%s | file=%s", listen, superviseTarget, fileTarget)
|
||||
appLogf(msg)
|
||||
log.Print(msg)
|
||||
if err := http.ListenAndServe(listen, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
224
main.go
224
main.go
@@ -1,226 +1,8 @@
|
||||
// xk-hy-forward-go:内网双通道透明转发服务。
|
||||
//
|
||||
// 部署在内网(可访问政务云 59.202.52.129),外网 xk-hy-transit-go 只访问本服务,不直连政务云。
|
||||
//
|
||||
// 通道一 — 业务监管数据(28212):
|
||||
//
|
||||
// POST /province/supervise/data → SUPERVISE_TARGET_URL
|
||||
// 请求体为 transit 加密后的 JSON,Header 原样透传。
|
||||
//
|
||||
// 通道二 — 处方 PDF 文件上传(28211):
|
||||
//
|
||||
// POST /mng/file/auth/upload → FILE_TARGET_URL
|
||||
// multipart 表单与 X-Authorization(uploadToken)原样透传。
|
||||
//
|
||||
// 环境变量见 .env.example;日志默认 ../log/forward/app-YYYY-MM-DD.log(LOG_DIR 可覆盖)。
|
||||
// xk-hy-forward-go 入口。启动:go run main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
//const (
|
||||
// defaultSuperviseTarget = "https://59.202.52.129:28212/province/supervise/data"
|
||||
// defaultFileTarget = "https://59.202.52.129:28211/mng/file/auth/upload"
|
||||
//)
|
||||
|
||||
const (
|
||||
defaultSuperviseTarget = "http://127.0.0.1:18001/api/"
|
||||
defaultFileTarget = "http://127.0.0.1:18001/api/u"
|
||||
)
|
||||
|
||||
var (
|
||||
logMu sync.Mutex
|
||||
appLogW io.Writer
|
||||
)
|
||||
import "xk-hy-forward-go/internal/forward"
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
if err := initAppLog(); err != nil {
|
||||
log.Printf("应用日志初始化失败: %v", err)
|
||||
}
|
||||
|
||||
listen := env("LISTEN_ADDR", ":16001")
|
||||
allowIPs := env("ALLOW_IPS", "")
|
||||
|
||||
// SUPERVISE_TARGET_URL 优先;未配置时兼容旧变量 TARGET_URL
|
||||
superviseTarget := env("SUPERVISE_TARGET_URL", "")
|
||||
if superviseTarget == "" {
|
||||
superviseTarget = env("TARGET_URL", defaultSuperviseTarget)
|
||||
}
|
||||
fileTarget := env("FILE_TARGET_URL", defaultFileTarget)
|
||||
|
||||
superviseProxy, err := newReverseProxy(superviseTarget)
|
||||
if err != nil {
|
||||
log.Fatalf("SUPERVISE_TARGET_URL 无效: %v", err)
|
||||
}
|
||||
fileProxy, err := newReverseProxy(fileTarget)
|
||||
if err != nil {
|
||||
log.Fatalf("FILE_TARGET_URL 无效: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// 健康检查:供运维与 transit 部署前探测 forward 是否存活
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// 通道一:监管业务上报(加密 JSON)
|
||||
mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleForward(w, r, "supervise", superviseProxy, superviseTarget, allowIPs)
|
||||
})
|
||||
|
||||
// 通道二:处方 PDF 上传(multipart + uploadToken)
|
||||
mux.HandleFunc("/mng/file/auth/upload", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleForward(w, r, "file", fileProxy, fileTarget, allowIPs)
|
||||
})
|
||||
|
||||
msg := fmt.Sprintf("xk-hy-forward-go 监听 %s | supervise=%s | file=%s", listen, superviseTarget, fileTarget)
|
||||
appLogf(msg)
|
||||
log.Print(msg)
|
||||
if err := http.ListenAndServe(listen, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// newReverseProxy 创建指向政务云目标的反向代理:仅替换 Scheme/Host/Path,不修改 body 与业务 Header。
|
||||
func newReverseProxy(target string) (*httputil.ReverseProxy, error) {
|
||||
targetURL, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
proxy.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书
|
||||
}
|
||||
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
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// handleForward 统一处理 POST 转发:白名单校验 → 反向代理 → 记录耗时与 HTTP 状态码。
|
||||
func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *httputil.ReverseProxy, target, allowIPs string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
clientIP := clientIP(r)
|
||||
if allowIPs != "" && !ipAllowed(clientIP, allowIPs) {
|
||||
appLogf("%s | %s | %s | forbidden | not in ALLOW_IPS", time.Now().Format(time.RFC3339), clientIP, kind)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
proxy.ServeHTTP(sw, r)
|
||||
ms := time.Since(start).Milliseconds()
|
||||
line := fmt.Sprintf("%s | %s | %s | http=%d | %dms | %s", time.Now().Format(time.RFC3339), clientIP, kind, sw.status, ms, target)
|
||||
appLogf(line)
|
||||
log.Printf("forward %s %s http=%d %dms", clientIP, kind, sw.status, ms)
|
||||
}
|
||||
|
||||
// statusRecorder 包装 ResponseWriter,用于在代理完成后记录实际 HTTP 状态码。
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusRecorder) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func initAppLog() error {
|
||||
root := resolveLogRoot("forward")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
day := time.Now().Format("2006-01-02")
|
||||
f, err := os.OpenFile(filepath.Join(root, "app-"+day+".log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appLogW = io.MultiWriter(os.Stdout, f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveLogRoot(service string) string {
|
||||
if v := strings.TrimSpace(os.Getenv("LOG_DIR")); v != "" {
|
||||
return filepath.Join(v, service)
|
||||
}
|
||||
base := programDir()
|
||||
return filepath.Join(base, "..", "log", service)
|
||||
}
|
||||
|
||||
func programDir() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
if strings.Contains(dir, "go-build") {
|
||||
if wd, err := os.Getwd(); err == nil && wd != "" {
|
||||
return wd
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil && wd != "" {
|
||||
return wd
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
func appLogf(format string, args ...any) {
|
||||
logMu.Lock()
|
||||
w := appLogW
|
||||
logMu.Unlock()
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
log.New(w, "[forward] ", log.LstdFlags).Printf(format, args...)
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// ipAllowed 判断来源 IP 是否在 ALLOW_IPS 白名单(逗号分隔);空表示不限制。
|
||||
func ipAllowed(ip, allow string) bool {
|
||||
for _, part := range strings.Split(allow, ",") {
|
||||
if strings.TrimSpace(part) == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
forward.Run()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user