From b1a1aaa2f4bb6125661020ebbadcde9d7a117479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Fri, 22 May 2026 08:42:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 +- README.md | 57 ++++++-- internal/forward/applog.go | 64 +++++++++ internal/forward/config.go | 18 +++ internal/forward/env.go | 48 +++++++ internal/forward/env_test.go | 27 ++++ internal/forward/headers.go | 44 ++++++ internal/forward/headers_test.go | 49 +++++++ internal/forward/proxy.go | 88 ++++++++++++ internal/forward/proxy_test.go | 112 ++++++++++++++++ internal/forward/run.go | 57 ++++++++ main.go | 224 +------------------------------ run.bat | 4 + 13 files changed, 567 insertions(+), 232 deletions(-) create mode 100644 internal/forward/applog.go create mode 100644 internal/forward/config.go create mode 100644 internal/forward/env.go create mode 100644 internal/forward/env_test.go create mode 100644 internal/forward/headers.go create mode 100644 internal/forward/headers_test.go create mode 100644 internal/forward/proxy.go create mode 100644 internal/forward/proxy_test.go create mode 100644 internal/forward/run.go create mode 100644 run.bat diff --git a/.env.example b/.env.example index 0eaf393..7081165 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 3f0420f..ff76f0d 100644 --- a/README.md +++ b/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。 diff --git a/internal/forward/applog.go b/internal/forward/applog.go new file mode 100644 index 0000000..c5167b0 --- /dev/null +++ b/internal/forward/applog.go @@ -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...) +} diff --git a/internal/forward/config.go b/internal/forward/config.go new file mode 100644 index 0000000..8943296 --- /dev/null +++ b/internal/forward/config.go @@ -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 +} diff --git a/internal/forward/env.go b/internal/forward/env.go new file mode 100644 index 0000000..f91845a --- /dev/null +++ b/internal/forward/env.go @@ -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 +} diff --git a/internal/forward/env_test.go b/internal/forward/env_test.go new file mode 100644 index 0000000..4f93877 --- /dev/null +++ b/internal/forward/env_test.go @@ -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) + } +} diff --git a/internal/forward/headers.go b/internal/forward/headers.go new file mode 100644 index 0000000..3d7d87f --- /dev/null +++ b/internal/forward/headers.go @@ -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) + } + } +} diff --git a/internal/forward/headers_test.go b/internal/forward/headers_test.go new file mode 100644 index 0000000..0e34e6b --- /dev/null +++ b/internal/forward/headers_test.go @@ -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) + } +} diff --git a/internal/forward/proxy.go b/internal/forward/proxy.go new file mode 100644 index 0000000..2447afb --- /dev/null +++ b/internal/forward/proxy.go @@ -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 +} diff --git a/internal/forward/proxy_test.go b/internal/forward/proxy_test.go new file mode 100644 index 0000000..69ac8f5 --- /dev/null +++ b/internal/forward/proxy_test.go @@ -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) + } +} diff --git a/internal/forward/run.go b/internal/forward/run.go new file mode 100644 index 0000000..19de60d --- /dev/null +++ b/internal/forward/run.go @@ -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) + } +} diff --git a/main.go b/main.go index 2a9225f..2993f8c 100644 --- a/main.go +++ b/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() } diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..db43bff --- /dev/null +++ b/run.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +go run main.go +if errorlevel 1 pause