初始化

This commit is contained in:
李琦
2026-05-22 08:05:05 +08:00
commit fef6a17622
11 changed files with 342 additions and 0 deletions

17
.env.example Normal file
View File

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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.exe
.env
../log/

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/xk-hy-forward-go.iml" filepath="$PROJECT_DIR$/.idea/xk-hy-forward-go.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

9
.idea/xk-hy-forward-go.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

45
README.md Normal file
View File

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

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module xk-hy-forward-go
go 1.22
require github.com/joho/godotenv v1.5.1

2
go.sum Normal file
View File

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

226
main.go Normal file
View File

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