This commit is contained in:
李琦
2026-05-22 08:42:27 +08:00
parent fef6a17622
commit b1a1aaa2f4
13 changed files with 567 additions and 232 deletions

View File

@@ -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

View File

@@ -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。

View 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...)
}

View 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
View 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
}

View 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)
}
}

View 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)
}
}
}

View 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
View 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
}

View 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
View 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
View File

@@ -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 加密后的 JSONHeader 原样透传。
//
// 通道二 — 处方 PDF 文件上传28211
//
// POST /mng/file/auth/upload → FILE_TARGET_URL
// multipart 表单与 X-AuthorizationuploadToken原样透传。
//
// 环境变量见 .env.example日志默认 ../log/forward/app-YYYY-MM-DD.logLOG_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()
}

4
run.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
go run main.go
if errorlevel 1 pause