diff --git a/.env.example b/.env.example index 7081165..c2d2a81 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # 监听地址(内网机,供外网 transit-go 访问) LISTEN_ADDR=:16001 +# 是否真正转发到政务云:true/1/yes(默认);false/0/no 时仅回显将要转发的请求头与请求体给 transit-go +ENABLE_FORWARD=true + # 通道一:业务监管数据上报(28212) SUPERVISE_TARGET_URL=https://59.202.52.129:28212/province/supervise/data diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..2b95591 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index ff76f0d..05f13e7 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ cp .env.example .env - 请使用 **`#` 作为注释**(不要用 `;`,godotenv 不会把 `;` 行当作注释)。 - 从 IDE / 服务方式启动时,请把 **Working Directory** 设为 `xk-hy-forward-go` 项目根,或把 `.env` 放在 **exe 同目录**。 -- 核对启动日志中的 `supervise=`、`file=` 是否为你在 `.env` 里配置的 URL。 +- 核对启动日志中的 `forward_enabled=`、`supervise=`、`file=` 是否为你在 `.env` 里配置的项。 | 变量 | 默认 | 说明 | |------|------|------| @@ -48,8 +48,38 @@ cp .env.example .env | `TARGET_URL` | (兼容) | 未设 `SUPERVISE_TARGET_URL` 时生效 | | `ALLOW_IPS` | 空 | 逗号分隔来源 IP;**生产建议填 transit 出口 IP** | | `FORWARD_SHARED_SECRET` | 空 | 非空时要求请求头 `X-Forward-Token` 一致(与 transit `.env` 同值) | +| `ENABLE_FORWARD` | `true` | 是否真正转发到政务云;`false`/`0`/`no` 时进入回显模式(见下) | | `LOG_DIR` | 空 | 日志根目录;默认 `{程序目录}/../log/forward/` | +### 回显模式(`ENABLE_FORWARD=false`) + +不请求政务云上游,按与真实转发**相同规则**(固定目标 URL + Header 白名单 + 原样 Body)组装出站内容,以 JSON 返回给 `xk-hy-transit-go`,便于联调核对将要发出的报文。 + +响应示例: + +```json +{ + "dry_run": true, + "kind": "supervise", + "target_url": "https://59.202.52.129:28212/province/supervise/data", + "method": "POST", + "headers": { "Content-Type": ["application/json"], "X-Ca-Signature": ["..."] }, + "body": "明文(监管 JSON 且 UTF-8 有效时)", + "body_base64": "始终提供,可还原 multipart/二进制" +} +``` + +- `headers`:过滤后将要转发的请求头。 +- `body`:明文;`kind=file` 或 `multipart` 时为空,请用 `body_base64`。 +- 启动日志含 `forward_enabled=false`;应用日志行为 `dry_run | target=... | body_len=N`。 + +本地排查示例: + +```env +ENABLE_FORWARD=false +ALLOW_IPS= +``` + ## 运行 ```bash @@ -79,6 +109,7 @@ FILE_TARGET_URL=http://127.0.0.1:18001/api/u ```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 | supervise | dry_run | target=https://... | body_len=1024 | 3ms ``` 不记录完整 body 与 uploadToken。 diff --git a/internal/forward/applog.go b/internal/forward/applog.go index c5167b0..779e686 100644 --- a/internal/forward/applog.go +++ b/internal/forward/applog.go @@ -15,6 +15,7 @@ var ( appLogW io.Writer ) +// initAppLog 创建日志目录并打开当日日志文件 app-YYYY-MM-DD.log,同时输出到控制台。 func initAppLog() error { root := resolveLogRoot("forward") if err := os.MkdirAll(root, 0o755); err != nil { @@ -29,6 +30,7 @@ func initAppLog() error { return nil } +// resolveLogRoot 解析日志根目录:优先 LOG_DIR/forward,否则 {程序目录}/../log/forward/。 func resolveLogRoot(service string) string { if v := strings.TrimSpace(os.Getenv("LOG_DIR")); v != "" { return filepath.Join(v, service) @@ -37,6 +39,7 @@ func resolveLogRoot(service string) string { return filepath.Join(base, "..", "log", service) } +// programDir 返回程序所在目录;go run 临时目录时回退为当前工作目录。 func programDir() string { if exe, err := os.Executable(); err == nil { dir := filepath.Dir(exe) @@ -53,6 +56,7 @@ func programDir() string { return "." } +// appLogf 线程安全地写入应用日志(带 [forward] 前缀)。 func appLogf(format string, args ...any) { logMu.Lock() w := appLogW diff --git a/internal/forward/config.go b/internal/forward/config.go index 8943296..8c80f6c 100644 --- a/internal/forward/config.go +++ b/internal/forward/config.go @@ -1,3 +1,4 @@ +// 配置读取:环境变量、默认上游地址、布尔开关解析。 package forward import ( @@ -5,14 +6,31 @@ import ( "strings" ) +// 政务云默认上游地址(未配置 SUPERVISE_TARGET_URL / FILE_TARGET_URL 时使用)。 const ( DefaultSuperviseTarget = "https://59.202.52.129:28212/province/supervise/data" DefaultFileTarget = "https://59.202.52.129:28211/mng/file/auth/upload" ) +// env 读取字符串环境变量;空或仅空白时返回默认值 def。 func env(key, def string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return def } + +// envBool 读取布尔环境变量。 +// 未设置或空 → 返回 def;false/0/no/off(不区分大小写)→ false;其余非空值 → true。 +func envBool(key string, def bool) bool { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + switch strings.ToLower(v) { + case "false", "0", "no", "off": + return false + default: + return true + } +} diff --git a/internal/forward/config_test.go b/internal/forward/config_test.go new file mode 100644 index 0000000..a300af9 --- /dev/null +++ b/internal/forward/config_test.go @@ -0,0 +1,42 @@ +package forward + +import ( + "os" + "testing" +) + +func TestEnvBool(t *testing.T) { + t.Setenv("TEST_ENV_BOOL", "") + if envBool("TEST_ENV_BOOL", true) != true { + t.Fatal("empty should use default true") + } + if envBool("TEST_ENV_BOOL", false) != false { + t.Fatal("empty should use default false") + } + + for _, v := range []string{"false", "FALSE", "0", "no", "NO", "off", "OFF"} { + t.Setenv("TEST_ENV_BOOL", v) + if envBool("TEST_ENV_BOOL", true) { + t.Fatalf("%q should be false", v) + } + } + + for _, v := range []string{"true", "1", "yes", "on"} { + t.Setenv("TEST_ENV_BOOL", v) + if !envBool("TEST_ENV_BOOL", false) { + t.Fatalf("%q should be true", v) + } + } +} + +func TestEnvString(t *testing.T) { + const key = "TEST_ENV_STRING_XK" + _ = os.Unsetenv(key) + if env(key, "default") != "default" { + t.Fatal("unset should return default") + } + t.Setenv(key, " value ") + if env(key, "default") != "value" { + t.Fatal("should trim spaces") + } +} diff --git a/internal/forward/env.go b/internal/forward/env.go index f91845a..8cc843a 100644 --- a/internal/forward/env.go +++ b/internal/forward/env.go @@ -9,13 +9,15 @@ import ( ) // LoadEnvFiles 从多个候选路径加载 .env(不覆盖已存在的 OS 环境变量)。 -// 查找顺序:CWD → programDir → programDir/.. +// 查找顺序:当前工作目录 → 可执行文件目录 → 可执行文件上级目录。 func LoadEnvFiles() bool { candidates := envFileCandidates() for _, p := range candidates { + // 步骤 1:路径不存在则尝试下一个候选 if _, err := os.Stat(p); err != nil { continue } + // 步骤 2:加载成功即返回(godotenv 不覆盖已有环境变量) if err := godotenv.Load(p); err != nil { log.Printf("[forward] 加载 %s 失败: %v", p, err) continue @@ -27,6 +29,7 @@ func LoadEnvFiles() bool { return false } +// envFileCandidates 生成 .env 候选路径列表(去重)。 func envFileCandidates() []string { seen := make(map[string]struct{}) var out []string diff --git a/internal/forward/headers.go b/internal/forward/headers.go index 3d7d87f..247306c 100644 --- a/internal/forward/headers.go +++ b/internal/forward/headers.go @@ -2,6 +2,8 @@ package forward import "net/http" +// superviseAllowedHeaders 监管通道(28212)允许透传到政务云的 Header 白名单。 +// 与 transit-go hy.BuildUpload 组包一致;其余头(Cookie、User-Agent、X-Forward-Token 等)会被剥离。 var superviseAllowedHeaders = []string{ "Content-Type", "X-Ca-Appkey", @@ -14,11 +16,13 @@ var superviseAllowedHeaders = []string{ "requestBody", } +// fileAllowedHeaders 文件上传通道(28211)允许透传的 Header 白名单。 var fileAllowedHeaders = []string{ "Content-Type", "X-Authorization", } +// allowedHeaderSet 按通道 kind 返回允许头的 CanonicalKey 集合,供 O(1) 查找。 func allowedHeaderSet(kind string) map[string]struct{} { list := superviseAllowedHeaders if kind == "file" { @@ -31,6 +35,7 @@ func allowedHeaderSet(kind string) map[string]struct{} { return set } +// copyAllowedHeaders 将 src 中位于白名单内的头复制到 dst(真实转发与回显模式共用)。 func copyAllowedHeaders(dst, src http.Header, kind string) { allow := allowedHeaderSet(kind) for key, values := range src { diff --git a/internal/forward/proxy.go b/internal/forward/proxy.go index 2447afb..018a79a 100644 --- a/internal/forward/proxy.go +++ b/internal/forward/proxy.go @@ -2,7 +2,10 @@ package forward import ( "crypto/tls" + "encoding/base64" + "encoding/json" "fmt" + "io" "log" "net" "net/http" @@ -10,47 +13,102 @@ import ( "net/url" "strings" "time" + "unicode/utf8" ) +// dryRunResponse 为 ENABLE_FORWARD=false 时返回给 transit-go 的 JSON 结构(回显将要转发的出站内容)。 +type dryRunResponse struct { + DryRun bool `json:"dry_run"` + Kind string `json:"kind"` + TargetURL string `json:"target_url"` + Method string `json:"method"` + Headers map[string][]string `json:"headers"` + Body string `json:"body,omitempty"` + BodyBase64 string `json:"body_base64"` +} + +// newReverseProxy 创建指向 target 的反向代理;Director 内会改写 URL 并过滤 Header 白名单。 func newReverseProxy(target, kind string) (*httputil.ReverseProxy, error) { + // 步骤 1:解析上游固定地址 targetURL, err := url.Parse(target) if err != nil { return nil, err } proxy := httputil.NewSingleHostReverseProxy(targetURL) + // 步骤 2:政务网自签证书,TLS 跳过校验(与现网一致) proxy.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书 } + // 步骤 3:每次转发前改写出站 URL 与 Header(Body 由 ReverseProxy 原样流式透传) 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 + applyOutboundTarget(req, targetURL, kind) } return proxy, nil } -func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *httputil.ReverseProxy, target, allowIPs, forwardSecret string) { +// applyOutboundTarget 将入站请求改写为指向政务云的出站形态(固定 URL + Header 白名单,不读 Body)。 +func applyOutboundTarget(req *http.Request, targetURL *url.URL, kind string) { + 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 +} + +// buildOutboundSnapshot 读取入站 Body,按与真实转发相同规则组装出站 URL/Header/Body,供回显模式使用。 +func buildOutboundSnapshot(r *http.Request, target, kind string) (targetURL string, headers map[string][]string, body []byte, err error) { + parsed, err := url.Parse(target) + if err != nil { + return "", nil, nil, err + } + body, err = io.ReadAll(r.Body) + if err != nil { + return "", nil, nil, err + } + _ = r.Body.Close() + + filtered := make(http.Header) + copyAllowedHeaders(filtered, r.Header, kind) + + hdr := make(map[string][]string, len(filtered)) + for k, vs := range filtered { + cp := make([]string, len(vs)) + copy(cp, vs) + hdr[k] = cp + } + return parsed.String(), hdr, body, nil +} + +// handleForward 处理监管/文件两条转发路由:校验 → 回显或真实转发 → 记日志。 +func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *httputil.ReverseProxy, target, allowIPs, forwardSecret string, enableForward bool) { + // 步骤 1:仅允许 POST if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } ip := clientIP(r) + // 步骤 2:来源 IP 白名单(ALLOW_IPS 非空时生效) 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 } + // 步骤 3:内网共享密钥(FORWARD_SHARED_SECRET 非空时校验 X-Forward-Token) 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 } + // 步骤 4:未开启真实转发时,回显将要发往政务云的请求头与请求体 + if !enableForward { + handleDryRun(w, r, kind, target, ip) + return + } + // 步骤 5:ReverseProxy 转发至政务云,记录耗时与上游 HTTP 状态 start := time.Now() sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} proxy.ServeHTTP(sw, r) @@ -60,6 +118,57 @@ func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *h log.Printf("forward %s %s http=%d %dms", ip, kind, sw.status, ms) } +// handleDryRun 不请求政务云,将按转发规则组装后的出站 URL、Header、Body 以 JSON 返回给 transit-go。 +func handleDryRun(w http.ResponseWriter, r *http.Request, kind, target, ip string) { + start := time.Now() + // 步骤 1:按与 Director 相同规则组装出站快照 + targetURL, headers, body, err := buildOutboundSnapshot(r, target, kind) + if err != nil { + appLogf("%s | %s | %s | dry_run_error | %v", time.Now().Format(time.RFC3339), ip, kind, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // 步骤 2:构造 JSON(body 明文仅 UTF-8 且非 multipart 时填充;body_base64 始终提供) + resp := dryRunResponse{ + DryRun: true, + Kind: kind, + TargetURL: targetURL, + Method: http.MethodPost, + Headers: headers, + BodyBase64: base64.StdEncoding.EncodeToString(body), + } + if plainBodyForDryRun(kind, headers, body) { + resp.Body = string(body) + } + // 步骤 3:写回 transit-go + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(resp); err != nil { + appLogf("%s | %s | %s | dry_run_encode_error | %v", time.Now().Format(time.RFC3339), ip, kind, err) + return + } + ms := time.Since(start).Milliseconds() + line := fmt.Sprintf("%s | %s | %s | dry_run | target=%s | body_len=%d | %dms", time.Now().Format(time.RFC3339), ip, kind, targetURL, len(body), ms) + appLogf(line) + log.Printf("dry_run %s %s body_len=%d %dms", ip, kind, len(body), ms) +} + +// plainBodyForDryRun 判断是否可在 JSON 的 body 字段中放明文(file/multipart 或非法 UTF-8 则仅 base64)。 +func plainBodyForDryRun(kind string, headers map[string][]string, body []byte) bool { + if kind == "file" { + return false + } + ct := "" + if vs, ok := headers["Content-Type"]; ok && len(vs) > 0 { + ct = strings.ToLower(vs[0]) + } + if strings.Contains(ct, "multipart/") { + return false + } + return utf8.Valid(body) +} + +// statusRecorder 包装 ResponseWriter,记录上游返回的 HTTP 状态码供日志使用。 type statusRecorder struct { http.ResponseWriter status int @@ -70,6 +179,7 @@ func (w *statusRecorder) WriteHeader(code int) { w.ResponseWriter.WriteHeader(code) } +// clientIP 从 RemoteAddr 解析客户端 IP(去掉端口)。 func clientIP(r *http.Request) string { ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { @@ -78,6 +188,7 @@ func clientIP(r *http.Request) string { return ip } +// ipAllowed 判断 ip 是否在逗号分隔的 ALLOW_IPS 列表中(精确匹配)。 func ipAllowed(ip, allow string) bool { for _, part := range strings.Split(allow, ",") { if strings.TrimSpace(part) == ip { diff --git a/internal/forward/proxy_test.go b/internal/forward/proxy_test.go index 69ac8f5..abcd8aa 100644 --- a/internal/forward/proxy_test.go +++ b/internal/forward/proxy_test.go @@ -2,9 +2,12 @@ package forward import ( "bytes" + "encoding/base64" + "encoding/json" "io" "net/http" "net/http/httptest" + "sync/atomic" "testing" ) @@ -104,9 +107,88 @@ func TestHandleForwardRejectsBadForwardToken(t *testing.T) { req.Header.Set("X-Forward-Token", "wrong") rec := httptest.NewRecorder() - handleForward(rec, req, "supervise", proxy, upstream.URL, "", "expected-secret") + handleForward(rec, req, "supervise", proxy, upstream.URL, "", "expected-secret", true) if rec.Code != http.StatusForbidden { t.Fatalf("want 403 got %d", rec.Code) } } + +func TestHandleForwardDryRunReturnsOutboundPayload(t *testing.T) { + const cipher = "BASE64_CIPHER_EXAMPLE" + var upstreamHits atomic.Int32 + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamHits.Add(1) + _, _ = io.ReadAll(r.Body) + 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(cipher))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Ca-Signature", "sig") + req.Header.Set("Requestbody", cipher) + req.Header.Set("Cookie", "junk") + + rec := httptest.NewRecorder() + handleForward(rec, req, "supervise", proxy, upstream.URL, "", "", false) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200 got %d body=%s", rec.Code, rec.Body.String()) + } + if upstreamHits.Load() != 0 { + t.Fatal("dry run must not call upstream") + } + + var resp dryRunResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if !resp.DryRun || resp.Kind != "supervise" || resp.TargetURL != upstream.URL { + t.Fatalf("unexpected dry run meta: %+v", resp) + } + if resp.Headers["X-Ca-Signature"][0] != "sig" { + t.Fatalf("signature header missing: %v", resp.Headers) + } + if _, ok := resp.Headers["Cookie"]; ok { + t.Fatal("cookie must be stripped in dry run headers") + } + if resp.Body != cipher { + t.Fatalf("plain body mismatch: %q", resp.Body) + } + decoded, err := base64.StdEncoding.DecodeString(resp.BodyBase64) + if err != nil || string(decoded) != cipher { + t.Fatalf("base64 body mismatch: %v", err) + } +} + +func TestHandleForwardDryRunFileOmitsPlainBody(t *testing.T) { + proxy, err := newReverseProxy("http://example.com/upload", "file") + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/mng/file/auth/upload", bytes.NewReader([]byte("binary"))) + req.Header.Set("Content-Type", "multipart/form-data; boundary=abc") + req.Header.Set("X-Authorization", "tok") + + rec := httptest.NewRecorder() + handleForward(rec, req, "file", proxy, "http://example.com/upload", "", "", false) + + var resp dryRunResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if resp.Body != "" { + t.Fatalf("file channel should not include plain body, got %q", resp.Body) + } + if resp.BodyBase64 == "" { + t.Fatal("body_base64 required") + } +} diff --git a/internal/forward/run.go b/internal/forward/run.go index 19de60d..186d88f 100644 --- a/internal/forward/run.go +++ b/internal/forward/run.go @@ -1,4 +1,4 @@ -// Package forward 内网双通道透明转发(监管 JSON + 处方 PDF)。 +// Package forward 内网双通道透明转发(监管 JSON + 处方 PDF),支持真实转发与回显模式。 package forward import ( @@ -9,21 +9,27 @@ import ( // Run 启动 forward 全部 HTTP 服务:/health、/province/supervise/data、/mng/file/auth/upload。 func Run() { + // 步骤 1:从 CWD / 程序目录加载 .env(不覆盖已有 OS 环境变量) LoadEnvFiles() + // 步骤 2:初始化按日滚动的应用日志 if err := initAppLog(); err != nil { log.Printf("应用日志初始化失败: %v", err) } + // 步骤 3:读取监听地址、访问控制、是否真实转发 listen := env("LISTEN_ADDR", ":16001") allowIPs := env("ALLOW_IPS", "") forwardSecret := env("FORWARD_SHARED_SECRET", "") + enableForward := envBool("ENABLE_FORWARD", true) + // 步骤 4:解析两条通道的上游 URL superviseTarget := env("SUPERVISE_TARGET_URL", "") if superviseTarget == "" { superviseTarget = env("TARGET_URL", DefaultSuperviseTarget) } fileTarget := env("FILE_TARGET_URL", DefaultFileTarget) + // 步骤 5:为两条通道各创建一个 ReverseProxy(回显模式时不会调用 ServeHTTP,但 URL 校验仍在此完成) superviseProxy, err := newReverseProxy(superviseTarget, "supervise") if err != nil { log.Fatalf("SUPERVISE_TARGET_URL 无效: %v", err) @@ -35,20 +41,25 @@ func Run() { mux := http.NewServeMux() + // 步骤 6:健康检查 mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) + // 步骤 7:监管业务 JSON 通道 mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) { - handleForward(w, r, "supervise", superviseProxy, superviseTarget, allowIPs, forwardSecret) + handleForward(w, r, "supervise", superviseProxy, superviseTarget, allowIPs, forwardSecret, enableForward) }) + // 步骤 8:处方 PDF 上传通道 mux.HandleFunc("/mng/file/auth/upload", func(w http.ResponseWriter, r *http.Request) { - handleForward(w, r, "file", fileProxy, fileTarget, allowIPs, forwardSecret) + handleForward(w, r, "file", fileProxy, fileTarget, allowIPs, forwardSecret, enableForward) }) - msg := fmt.Sprintf("xk-hy-forward-go 监听 %s | supervise=%s | file=%s", listen, superviseTarget, fileTarget) + // 步骤 9:打印启动配置并监听 + msg := fmt.Sprintf("xk-hy-forward-go 监听 %s | forward_enabled=%v | supervise=%s | file=%s", + listen, enableForward, superviseTarget, fileTarget) appLogf(msg) log.Print(msg) if err := http.ListenAndServe(listen, mux); err != nil { diff --git a/main.go b/main.go index 2993f8c..02964c2 100644 --- a/main.go +++ b/main.go @@ -1,4 +1,6 @@ -// xk-hy-forward-go 入口。启动:go run main.go +// xk-hy-forward-go 程序入口。 +// 职责:启动 internal/forward 包中的 HTTP 服务(监管转发、文件上传转发、健康检查)。 +// 启动方式:在项目根目录执行 go run main.go package main import "xk-hy-forward-go/internal/forward"