commit fef6a176226b85b1b21a2a1eb91c17ee2baa4be5 Author: 李琦 Date: Fri May 22 08:05:05 2026 +0800 初始化 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0eaf393 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# 监听地址(内网机,供外网 transit-go 访问) +LISTEN_ADDR=:16001 + +# 通道一:业务监管数据上报(28212) +SUPERVISE_TARGET_URL=https://59.202.52.129:28212/province/supervise/data + +# 通道二:处方 PDF 文件上传(28211) +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= + +# 应用日志根目录(默认 {程序目录}/../log/forward/) +# LOG_DIR=D:\worker\code\log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edede67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.exe +.env +../log/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f03e0e8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/xk-hy-forward-go.iml b/.idea/xk-hy-forward-go.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/xk-hy-forward-go.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f0420f --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# xk-hy-forward-go + +内网双通道透明转发:外网 `xk-hy-transit-go` 只访问本服务,由本机转发至浙江省政务云(`59.202.52.129`)。 + +## 架构 + +| 监听路径 | 转发目标 | 用途 | +|----------|----------|------| +| `POST /province/supervise/data` | `SUPERVISE_TARGET_URL`(28212) | 加密后的监管业务 JSON | +| `POST /mng/file/auth/upload` | `FILE_TARGET_URL`(28211) | 处方 PDF multipart 上传 | +| `GET /health` | 本地 | 健康检查 | + +请求头与 body **原样透传**(含 `X-Authorization` uploadToken、multipart boundary)。 + +## 环境配置 + +```bash +cp .env.example .env +``` + +| 变量 | 默认 | 说明 | +|------|------|------| +| `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 白名单 | +| `LOG_DIR` | 空 | 日志根目录;默认 `{程序目录}/../log/forward/` | + +## 运行 + +```bash +go run . +``` + +## 应用日志 + +每条转发写入 `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。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5dae7f7 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module xk-hy-forward-go + +go 1.22 + +require github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2a9225f --- /dev/null +++ b/main.go @@ -0,0 +1,226 @@ +// 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 可覆盖)。 +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 +) + +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 +}