瀏覽代碼

feat:加入X-TOKEN鉴权头部参数

lipan 1 周之前
父節點
當前提交
edd2ae370d

+ 27 - 6
internal/logic/brushservicelogic.go

@@ -1,11 +1,12 @@
-// udeServer: 一键刷库 —— 下发刷库配置到指定接入服务。
-// 外挂程序启动并同步该配置后,对其保护目录内的全量数据进行加密处理。
-// 当前实现仅完成「校验服务存在 + 下发入口」,实际加密由外挂程序侧执行。
+// udeServer: 一键刷库(手动补刷) —— 按服务配置的 IP/端口/密钥/保护目录,
+// 调外挂程序的 /api/migrate 接口,把保护目录存量明文加密进 <目录>-cipher 并清空明文。
+// 与创建流程的自动刷库(createservicelogic.notifyFuseBrush)共用 dispatchFuseBrush。
 
 package logic
 
 import (
 	"context"
+	"fmt"
 
 	"udeServer/internal/svc"
 	"udeServer/internal/types"
@@ -31,10 +32,30 @@ func (l *BrushServiceLogic) BrushService(req *types.ServiceIdReq) (*types.Delete
 	if err := checkId(req.Id); err != nil {
 		return nil, err
 	}
-	if _, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id); err != nil {
+	svc, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id)
+	if err != nil {
 		return nil, err
 	}
-
-	l.Infof("brush config dispatched, service id: %d", req.Id)
+	// 挂载中拒绝刷库(第2道防线):挂载后保护目录是FUSE明文视图,历史文件已被隐藏,
+	// 此时刷库的"清空明文"动作会穿透挂载删除底层密文,数据全失。
+	// 前端按钮点击时已有同款拦截(第1道防线),后端这道防的是绕过前端直接调接口;
+	// 外挂侧 rejectIfMounted 查挂载注册表兜底(第3道防线,防库状态过时)。
+	if svc.MountStatus.String == "mounted" {
+		return nil, fmt.Errorf("历史文件已被挂载隐藏,请先卸掉挂载再执行刷库操作")
+	}
+	// 查该服务的全部保护目录(目录表按 service_id 过滤)
+	dirs, err := l.svcCtx.DirectoryModel.FindDirsByServiceId(l.ctx, req.Id)
+	if err != nil {
+		return nil, fmt.Errorf("查询保护目录失败: %w", err)
+	}
+	if len(dirs) == 0 {
+		return nil, fmt.Errorf("该服务未配置保护目录,无库可刷")
+	}
+	// 下发刷库:手动补刷是显式操作,失败直接返回错误,前端红色提示(与创建流程的软处理不同)
+	if err := dispatchFuseBrush(l.ctx, l.svcCtx, svc, dirs); err != nil {
+		l.Errorf("brush dispatch failed, service id: %d, err: %v", req.Id, err)
+		return nil, err
+	}
+	l.Infof("brush dispatch ok, service id: %d, dirs: %v", req.Id, dirs)
 	return &types.DeleteResp{Ok: true}, nil
 }

+ 93 - 12
internal/logic/createservicelogic.go

@@ -132,25 +132,106 @@ func (l *CreateServiceLogic) CreateService(req *types.CreateServiceReq) (*types.
 	return resp, nil
 }
 
-// notifyFuseBrush 下发一键刷库到外挂程序(svc.Ip:svc.Port)。
+// notifyFuseBrush 下发一键刷库到外挂程序(svc.Ip:svc.Port)的 /api/migrate 接口。
 // 刷库语义:外挂读取保护目录存量明文 → 按服务密钥加密写入 <保护目录>-cipher
-// → 清空保护目录原明文(数据已转移到密文目录),为挂载的空目录校验扫清障碍。
-// 调用时机:创建流程中、挂载之前(目录非空时挂载会被空目录校验拒绝)。
-// TODO: 外挂程序侧的刷库接口(建议 POST /api/brush,请求体 {mount,ikey} 与挂载同源)
-// 开发完成后,在此填入真实下发实现(HTTP 客户端复用包级 fuseMountClient)。
-// 当前返回固定 error,由调用方软处理成黄色警告,绝不阻塞服务创建。
+// → 校验通过后清空保护目录原明文(数据已转移到密文目录,磁盘上只留密文一份),
+// 完成加密语义闭环,同时为挂载的空目录校验扫清障碍。
+// 调用时机:创建流程中、挂载之前(先刷后挂编排,见 CreateService 第4步)。
+// 实际下发逻辑抽成包级 dispatchFuseBrush,与手动补刷(BrushServiceLogic)共用。
 func (l *CreateServiceLogic) notifyFuseBrush(svc *model.TService, dirs []string) error {
+	return dispatchFuseBrush(l.ctx, l.svcCtx, svc, dirs)
+}
+
+// doFuseRequest 发送发往外挂程序的 HTTP 请求,统一携带逐服务的 X-TOKEN 鉴权头。
+// X-TOKEN 的值 = 服务表 t_service 的 iden(接入标识)字段;外挂程序拿它与
+// 自己环境变量 TOKEN 配的值恒定时间比对(见 FuseDemo 的 NewAuthInterceptor),
+// 因此每台外挂机的 TOKEN 要配成"接在该机上的服务"的 iden 值。
+// client 按场景传:挂载/卸载/目录浏览用 fuseMountClient(10s),刷库用 fuseBrushClient(10min)。
+// 之所以不用 client.Post/client.Get 快捷方式:它们构造的请求没法逐个加 Header,
+// 必须先 http.NewRequest 再 client.Do。
+func doFuseRequest(client *http.Client, iden, method, url string, body io.Reader, contentType string) (*http.Response, error) {
+	req, err := http.NewRequest(method, url, body)
+	if err != nil {
+		return nil, err
+	}
+	if contentType != "" {
+		req.Header.Set("Content-Type", contentType)
+	}
+	req.Header.Set("X-TOKEN", iden)
+	return client.Do(req)
+}
+
+// fuseBrushClient 刷库专用HTTP客户端:刷库是重操作(全量加密可能数分钟),
+// 不能复用挂载的10s短超时,单独放宽到10分钟。
+var fuseBrushClient = &http.Client{Timeout: 10 * time.Minute}
+
+// dispatchFuseBrush 下发刷库到外挂(包级函数,创建流程与手动补刷共用同一实现)。
+// 逐保护目录调用 POST /api/migrate,请求体:
+//
+//	{ "backing": "<目录>-cipher", "mount": ["<目录>"], "overwrite": true, "ikey": "<密钥内容>" }
+//
+// ikey 取自 t_key.content(按服务 keyIden 查询),与挂载下发同源,保证刷库密钥=挂载密钥。
+// 外挂约定:部分文件失败也返回 HTTP 200,成败靠响应体的 failed 计数判断。
+// 注:创建流程的自动刷库无需挂载中校验——创建时序是先刷后挂,刷库时必然未挂载;
+// 手动补刷的挂载中拦截由调用方 BrushServiceLogic 负责。
+func dispatchFuseBrush(ctx context.Context, svcCtx *svc.ServiceContext, svc *model.TService, dirs []string) error {
 	if len(dirs) == 0 {
-		return nil // 没配保护目录,无库可刷
+		return fmt.Errorf("没配保护目录,无库可刷")
 	}
-	if svc.Ip.String == "" || svc.Port == 0 { // port 已是 int64,0 视为未配置
+	ip, port := svc.Ip.String, svc.Port // port 是 int64
+	if ip == "" || port == 0 {
 		return fmt.Errorf("外挂服务 IP/端口为空,无法下发刷库")
 	}
-	// TODO: 逐目录调用外挂程序刷库接口(与 notifyFuseMount 同款循环模式)
-	return fmt.Errorf("外挂程序刷库接口尚未实装,本轮未执行刷库(存量数据场景挂载会被空目录校验拒绝)")
+	keyIden := svc.KeyIden.String
+	if keyIden == "" {
+		return fmt.Errorf("服务未配置密钥标识(keyIden),无法下发刷库")
+	}
+	key, err := svcCtx.KeyModel.FindOneByIden(ctx, keyIden)
+	if err != nil {
+		return fmt.Errorf("查询密钥(%s)失败: %w", keyIden, err)
+	}
+	ikey := key.Content.String
+	if ikey == "" {
+		return fmt.Errorf("密钥(%s)内容为空,无法下发刷库", keyIden)
+	}
+	baseURL := fmt.Sprintf("http://%s:%d/api/migrate", ip, port)
+	for _, dir := range dirs {
+		dirLinuxPath := path.Clean(dir)
+		payload := map[string]interface{}{
+			"backing":   dirLinuxPath + "-cipher", // 密文输出目录
+			"mount":     []string{dirLinuxPath},   // 明文源目录(接口定义是数组,单目录传单元素)
+			"overwrite": true,                     // 补刷时目标已存在则覆盖,保证刷库可重复执行
+			"ikey":      ikey,
+		}
+		body, err := json.Marshal(payload)
+		if err != nil {
+			return fmt.Errorf("序列化刷库请求失败: %w", err)
+		}
+		resp, err := doFuseRequest(fuseBrushClient, svc.Iden.String, http.MethodPost, baseURL, bytes.NewReader(body), "application/json")
+		if err != nil {
+			return fmt.Errorf("请求 %s 失败(外挂程序未启动?): %w", baseURL, err)
+		}
+		// 先把响应体整体读出再Close,保证Keep-Alive连接可复用
+		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+		resp.Body.Close()
+		if resp.StatusCode >= http.StatusBadRequest {
+			return fmt.Errorf("刷库目录 %s 失败,外挂程序返回 HTTP %d: %s", dir, resp.StatusCode, string(respBody))
+		}
+		var result struct { // 匿名结构体:只用一次的临时JSON模型,不必单独定义类型
+			Total  int `json:"total"`
+			Failed int `json:"failed"`
+		}
+		if err := json.Unmarshal(respBody, &result); err != nil {
+			return fmt.Errorf("解析刷库响应失败(目录 %s): %w", dir, err)
+		}
+		if result.Failed > 0 {
+			return fmt.Errorf("刷库目录 %s: %d/%d 个文件失败", dir, result.Failed, result.Total)
+		}
+	}
+	return nil
 }
 
-// fuseMountClient 复用的 HTTP 客户端,10s 超时防止外挂程序无响应拖死创建接口
+// fuseMountClient 复用的 HTTP 客户端,10s 超时防止外挂程序无响应拖死创建接口。
 var fuseMountClient = &http.Client{Timeout: 10 * time.Second}
 
 // notifyFuseMount 落库成功后回调外挂程序(svc.Ip:svc.Port)的 /api/fuseMount 接口,
@@ -197,7 +278,7 @@ func (l *CreateServiceLogic) notifyFuseMount(svc *model.TService, dirs []string)
 			return fmt.Errorf("序列化挂载请求失败: %w", err)
 		}
 
-		resp, err := fuseMountClient.Post(baseURL, "application/json", bytes.NewReader(body))
+		resp, err := doFuseRequest(fuseMountClient, svc.Iden.String, http.MethodPost, baseURL, bytes.NewReader(body), "application/json")
 		if err != nil {
 			return fmt.Errorf("请求 %s 失败(外挂程序未启动?): %w", baseURL, err)
 		}

+ 2 - 1
internal/logic/deleteservicelogic.go

@@ -103,7 +103,8 @@ func (l *DeleteServiceLogic) notifyFuseUnmount(svc *model.TService) string {
 		q.Set("mount", cleanMount)
 		reqURL.RawQuery = q.Encode()
 		l.Infof("fuse unmount forward: GET %s", reqURL.String()) // 打出转发 URL 便于排查
-		resp, rerr := fuseMountClient.Get(reqURL.String())       // 复用挂载逻辑里的 HTTP 客户端(同包变量)
+		// X-TOKEN = 该服务的接入标识 iden(外挂侧与自己环境变量 TOKEN 配的值比对)
+		resp, rerr := doFuseRequest(fuseMountClient, svc.Iden.String, http.MethodGet, reqURL.String(), nil, "")
 		if rerr != nil {
 			msgs = append(msgs, fmt.Sprintf("卸载目录 %s 失败(外挂程序未启动?): %v", cleanMount, rerr))
 			continue

+ 4 - 3
internal/logic/listdirchildrenlogic.go

@@ -12,6 +12,7 @@ package logic
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"io"
 	"net/http"
 	"net/url"
@@ -79,7 +80,7 @@ func (l *ListDirChildrenLogic) ListDirChildren(req *types.ListDirChildrenReq) (*
 	// path.Clean 而非 filepath.Clean: 该路径发给远程 Linux 外挂程序,
 	// 必须保持 / 分隔符;filepath 在 Windows 上跑 udeServer 时会把 / 转成 \
 	dir := path.Clean(p)
-	return l.notifyListDirChildren(ip, port, dir)
+	return l.notifyListDirChildren(ip, port, dir, req.Iden)
 }
 
 // notifyFuseMount 落库成功后回调外挂程序(svc.Ip:svc.Port)的 /api/fuseMount 接口,
@@ -89,7 +90,7 @@ func (l *ListDirChildrenLogic) ListDirChildren(req *types.ListDirChildrenReq) (*
 //	{ "backing": "<保护目录>-cipher", "mount": "<保护目录>", "ikey": "<密钥内容>" }
 //
 // ikey 取自 t_key.content(按服务配置的密钥标识 keyIden 查询)。
-func (l *ListDirChildrenLogic) notifyListDirChildren(ip, port, dirs string) (*types.DirChildrenResp, error) {
+func (l *ListDirChildrenLogic) notifyListDirChildren(ip, port, dirs, iden string) (*types.DirChildrenResp, error) {
 	if ip == "" || port == "" {
 		return nil, fmt.Errorf("外挂服务 IP/端口为空,无法下发挂载")
 	}
@@ -111,7 +112,7 @@ func (l *ListDirChildrenLogic) notifyListDirChildren(ip, port, dirs string) (*ty
 	// 发起 GET 请求
 	finalURL := reqURL.String()
 	l.Infof("URL,方便排查参数/编码问题list dirs forward: GET %s", finalURL) // 打出转发 URL,方便排查参数/编码问题
-	resp, err := fuseMountClient.Get(finalURL)
+	resp, err := doFuseRequest(fuseMountClient, iden, http.MethodGet, finalURL, nil, "")
 	if err != nil {
 		return nil, fmt.Errorf("请求 %s 失败: %w", baseURL, err)
 	}

+ 7 - 6
internal/logic/updateservicelogic.go

@@ -164,7 +164,8 @@ func (l *UpdateServiceLogic) applyMountChanges(svc *model.TService, oldDirs, new
 			msgs = append(msgs, "原外挂 IP/端口为空,无法联动卸载,旧目录可能仍处于挂载状态")
 		} else {
 			for _, dir := range toUnmount {
-				if w := l.fuseUnmountOne(oldIp, oldPort, dir); w != "" {
+				// X-TOKEN 用【旧】iden:卸载发往旧机器,旧机器 TOKEN 认的还是改前的接入标识
+				if w := l.fuseUnmountOne(oldIp, oldPort, dir, svc.Iden.String); w != "" {
 					msgs = append(msgs, w)
 				}
 			}
@@ -181,7 +182,7 @@ func (l *UpdateServiceLogic) applyMountChanges(svc *model.TService, oldDirs, new
 				msgs = append(msgs, fmt.Sprintf("联动挂载失败: %v", err))
 			} else {
 				for _, dir := range newDirs {
-					if w := l.fuseMountOne(req.Ip, req.Port, dir, ikey); w != "" {
+					if w := l.fuseMountOne(req.Ip, req.Port, dir, ikey, req.Iden); w != "" {
 						msgs = append(msgs, w)
 					}
 				}
@@ -208,7 +209,7 @@ func (l *UpdateServiceLogic) applyMountChanges(svc *model.TService, oldDirs, new
 
 // fuseMountOne 通知外挂程序挂载单个目录。成功返回空串,失败返回警告文案。
 // 请求体与新增服务完全同款:{backing: 目录+"-cipher", mount: 目录, ikey: 密钥内容}
-func (l *UpdateServiceLogic) fuseMountOne(ip string, port int64, mount, ikey string) string {
+func (l *UpdateServiceLogic) fuseMountOne(ip string, port int64, mount, ikey, iden string) string {
 	// path.Clean(非 filepath!):路径要发给远程 Linux 外挂程序,必须保持 / 分隔符
 	dirLinuxPath := path.Clean(mount)
 	payload := map[string]interface{}{
@@ -221,7 +222,7 @@ func (l *UpdateServiceLogic) fuseMountOne(ip string, port int64, mount, ikey str
 		return fmt.Sprintf("序列化挂载请求失败: %v", err)
 	}
 	baseURL := fmt.Sprintf("http://%s:%d/api/fuseMount", ip, port) // %d:port 是 int64
-	resp, err := fuseMountClient.Post(baseURL, "application/json", bytes.NewReader(body))
+	resp, err := doFuseRequest(fuseMountClient, iden, http.MethodPost, baseURL, bytes.NewReader(body), "application/json")
 	if err != nil {
 		return fmt.Sprintf("挂载目录 %s 失败(外挂程序未启动?): %v", dirLinuxPath, err)
 	}
@@ -238,7 +239,7 @@ func (l *UpdateServiceLogic) fuseMountOne(ip string, port int64, mount, ikey str
 
 // fuseUnmountOne 通知外挂程序卸载单个挂载点。成功返回空串,失败返回警告文案。
 // 卸载接口幂等:挂载点不在外挂注册表中也返回成功,所以重存/多卸都零成本。
-func (l *UpdateServiceLogic) fuseUnmountOne(ip string, port int64, mount string) string {
+func (l *UpdateServiceLogic) fuseUnmountOne(ip string, port int64, mount, iden string) string {
 	cleanMount := path.Clean(mount)                                  // 同上,发给 Linux 必须保持 / 分隔符
 	baseURL := fmt.Sprintf("http://%s:%d/api/fuseUnMount", ip, port) // %d:port 是 int64
 	reqURL, uerr := url.Parse(baseURL)
@@ -248,7 +249,7 @@ func (l *UpdateServiceLogic) fuseUnmountOne(ip string, port int64, mount string)
 	q := reqURL.Query()
 	q.Set("mount", cleanMount) // q.Encode() 自动做 URL 编码(/ → %2F),外挂侧 Query().Get 自动解码还原
 	reqURL.RawQuery = q.Encode()
-	resp, err := fuseMountClient.Get(reqURL.String()) // 复用挂载逻辑的 HTTP 客户端(同包包级变量)
+	resp, err := doFuseRequest(fuseMountClient, iden, http.MethodGet, reqURL.String(), nil, "") // 复用挂载逻辑的 HTTP 客户端(同包包级变量)
 	if err != nil {
 		return fmt.Sprintf("卸载目录 %s 失败(外挂程序未启动?): %v", cleanMount, err)
 	}

+ 1 - 0
internal/types/types.go

@@ -114,6 +114,7 @@ type ListDirChildrenReq struct {
 	Ip   string `form:"ip"` // 目标 Linux 主机 IP
 	Port string `form:"port"`
 	Path string `form:"path"` // 空或 "/" 表示根目录
+	Iden string `form:"iden"` // 接入标识:转发外挂时作为 X-TOKEN 头的值(外挂侧与环境变量 TOKEN 比对)
 }
 
 type LocalDirNode struct {