227 lines
6.3 KiB
Go
227 lines
6.3 KiB
Go
// 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
|
||
}
|