初始化
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.exe
|
||||||
|
.env
|
||||||
|
../log/
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
11
.idea/go.imports.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
9
.idea/xk-hy-forward-go.iml
generated
Normal 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
45
README.md
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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
226
main.go
Normal 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 加密后的 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user