// udeServer: 本地目录懒加载 —— 浏览 udeServer 所在 Linux 主机的文件系统。 // 只列目录、不列文件;从根目录 "/" 开始,前端每展开一个节点请求一次该层子目录。 // 安全边界: // 1. 路径归一化: 必须是绝对路径,filepath.Clean 防穿越 // 2. 黑名单: /proc /sys /dev /run /boot 等系统目录不展示 // 3. 挂载根目录(Mount.BaseDir, 如 /var/ude/mnt)不展示,避免把已挂载点当普通目录选 // 4. 符号链接: DirEntry.IsDir() 对 symlink 返回 false,天然跳过,不跟随 // 5. 权限不足目录: 返回空列表而非报错,树展开不中断 package logic import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "path" "strings" "udeServer/internal/apierr" "udeServer/internal/audit" "udeServer/internal/svc" "udeServer/internal/types" "github.com/zeromicro/go-zero/core/logx" ) // 根层直接屏蔽的系统/虚拟目录(仅在这些目录位于 "/" 下时过滤) var dirBlacklist = map[string]bool{ "proc": true, "sys": true, "dev": true, "run": true, "boot": true, "lost+found": true, "snap": true, } type ListDirChildrenLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewListDirChildrenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListDirChildrenLogic { return &ListDirChildrenLogic{ Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } // --- 远程 /list 响应结构 ------------------------------------------------- type remoteListResp struct { Path string `json:"path"` Total int `json:"total"` Entries []remoteEntry `json:"entries"` } type remoteEntry struct { Name string `json:"name"` Dir bool `json:"dir"` Size int64 `json:"size"` } func (l *ListDirChildrenLogic) ListDirChildren(req *types.ListDirChildrenReq) (*types.DirChildrenResp, error) { ip, port := req.Ip, req.Port if ip == "" || port == "" { return nil, fmt.Errorf("外挂服务 IP/端口为空,无法下发挂载") } // 1. 归一化: 空 → 根目录; 必须以 / 开头; Clean 防 ../ 穿越 p := strings.TrimSpace(req.Path) if p == "" { p = "/" } if !strings.HasPrefix(p, "/") { return nil, apierr.BadRequest("path 必须是绝对路径(以 / 开头)") } // path.Clean 而非 filepath.Clean: 该路径发给远程 Linux 外挂程序, // 必须保持 / 分隔符;filepath 在 Windows 上跑 udeServer 时会把 / 转成 \ dir := path.Clean(p) return l.notifyListDirChildren(ip, port, dir, req.Iden) } // notifyFuseMount 落库成功后回调外挂程序(svc.Ip:svc.Port)的 /api/fuseMount 接口, // 由外挂程序对保护目录执行 fuse 挂载。一个目录一次调用,请求体: // // POST http://{ip}:{port}/api/fuseMount // { "backing": "<保护目录>-cipher", "mount": "<保护目录>", "ikey": "<密钥内容>" } // // ikey 取自 t_key.content(按服务配置的密钥标识 keyIden 查询)。 func (l *ListDirChildrenLogic) notifyListDirChildren(ip, port, dirs, iden string) (*types.DirChildrenResp, error) { if ip == "" || port == "" { return nil, fmt.Errorf("外挂服务 IP/端口为空,无法下发挂载") } if len(dirs) == 0 { return nil, fmt.Errorf("没有配置保护目录,无需通知挂载") } baseURL := fmt.Sprintf("http://%s:%s/path/lists", ip, port) // 规范化路径(path.Clean 保持 / 分隔符,与 Linux 外挂程序对齐) cleanPath := path.Clean(dirs) // 构造带参数的 GET 请求 URL reqURL, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("解析 URL 失败: %w", err) } // 核心修改:往 URL 中添加 ?path=xxx q := reqURL.Query() q.Set("path", cleanPath) reqURL.RawQuery = q.Encode() // 自动处理 URL 编码,生成如 ?path=%2Fdata%2Ftest // 发起 GET 请求(DoSimple 记录 type=2 接口日志,父 Span 指向本次入站请求) finalURL := reqURL.String() l.Infof("URL,方便排查参数/编码问题list dirs forward: GET %s", finalURL) // 打出转发 URL,方便排查参数/编码问题 reqDesc := audit.BuildRemoteGetRequest(http.MethodGet, finalURL) result, callErr := audit.DoSimple(l.svcCtx.SpanRecorder, l.ctx, "ListDirChildren", finalURL, reqDesc, func() (*types.DirChildrenResp, int64, error) { // doFuseRequest 注入 X-TOKEN(按 iden 换算的 apiSecret);外挂 /path/lists 已免鉴权,带上无害,统一收口避免遗漏 resp, rerr := doFuseRequest(l.ctx, l.svcCtx, fuseMountClient, iden, http.MethodGet, finalURL, nil, "") if rerr != nil { return nil, 0, fmt.Errorf("请求 %s 失败: %w", baseURL, rerr) } defer resp.Body.Close() // 确保无论如何都能释放连接 status := int64(resp.StatusCode) // 检查 HTTP 状态码 if resp.StatusCode != http.StatusOK { // 把外挂程序返回的错误内容带出来(前 256 字节),便于定位 400 的具体原因 snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) return nil, status, fmt.Errorf("获取目录失败, HTTP 状态码: %d, 外挂程序返回: %s", resp.StatusCode, string(snippet)) } // 解析 JSON 响应 var parsed *types.DirChildrenResp if perr := json.NewDecoder(resp.Body).Decode(&parsed); perr != nil { return nil, status, fmt.Errorf("解析JSON响应失败: %w", perr) } return parsed, status, nil }) if callErr != nil { return nil, callErr } l.Infof("fuse 请求成功, path: %s", cleanPath) return result, nil }