Bläddra i källkod

feat:一键刷库 + 懒加载目录

lipan 2 veckor sedan
förälder
incheckning
42fa79edc1

+ 7 - 6
Dockerfile

@@ -1,4 +1,4 @@
-# IAMServer 多阶段构建
+# udeServer 多阶段构建
 # 构建阶段:golang 官方镜像编译 linux 静态二进制
 FROM golang:1.25 AS builder
 
@@ -22,12 +22,13 @@ RUN apk add --no-cache ca-certificates tzdata \
     && adduser -D -u 1000 app
 
 WORKDIR /app
-COPY --from=builder /app/udeserver .
-COPY --from=builder /app/etc/udeserver-api.yaml  .
+COPY --from=builder /app/udeserver ./udeserver
+# 关键:配置必须放进 ./etc/ 目录,与启动参数 -f etc/udeserver-api.yaml 对应
+COPY --from=builder /app/etc/udeserver-api.yaml ./etc/udeserver-api.yaml
 
 USER app
 
-# 9990 管理端 API;8443 免改造代理
-EXPOSE 9990 8443
+# 9993 UDE 管理 API
+EXPOSE 9993
 
-CMD ["./udeserver", "-c", "udeserver-api.yaml"]
+CMD ["./udeserver", "-f", "etc/udeserver-api.yaml"]

+ 9 - 0
etc/udeserver-api.yaml

@@ -4,3 +4,12 @@ Port: 9993
 
 MySQL:
   DataSource: "ude:H3G8xzfYpAASYFxF@tcp(10.10.10.30:3306)/ude?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"
+
+Smb:
+  User: "Administrator"
+  Password: "Yumawk1234."
+  DefaultShare: "FuseDemo"
+  MountVersion: "3.0"
+
+Mount:
+  BaseDir: "/var/ude/mnt"   # 保护目录挂载根目录,Linux 通常 /var/ude/mnt

+ 4 - 0
internal/config/config.go

@@ -11,4 +11,8 @@ import (
 type Config struct {
 	rest.RestConf
 	MySQL sqlx.SqlConf
+	Mount MountConf
+}
+type MountConf struct {
+	BaseDir string `json:",default=/var/ude/mnt"`
 }

+ 31 - 0
internal/handler/brushservicehandler.go

@@ -0,0 +1,31 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.2
+
+package handler
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"udeServer/internal/logic"
+	"udeServer/internal/svc"
+	"udeServer/internal/types"
+)
+
+func BrushServiceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.ServiceIdReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+			return
+		}
+
+		l := logic.NewBrushServiceLogic(r.Context(), svcCtx)
+		resp, err := l.BrushService(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 30 - 0
internal/handler/listdirchildrenhandler.go

@@ -0,0 +1,30 @@
+// udeServer: 本地目录懒加载 handler
+
+package handler
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"udeServer/internal/logic"
+	"udeServer/internal/svc"
+	"udeServer/internal/types"
+)
+
+func ListDirChildrenHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.ListDirChildrenReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+			return
+		}
+
+		l := logic.NewListDirChildrenLogic(r.Context(), svcCtx)
+		resp, err := l.ListDirChildren(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 10 - 0
internal/handler/routes.go

@@ -99,6 +99,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 				Path:    "/services/:id",
 				Handler: DeleteServiceHandler(serverCtx),
 			},
+			{
+				Method:  http.MethodPost,
+				Path:    "/services/:id/brush",
+				Handler: BrushServiceHandler(serverCtx),
+			},
+			{
+				Method:  http.MethodGet,
+				Path:    "/dirs/children",
+				Handler: ListDirChildrenHandler(serverCtx),
+			},
 		},
 		rest.WithPrefix("/api/v1"),
 	)

+ 40 - 0
internal/logic/brushservicelogic.go

@@ -0,0 +1,40 @@
+// udeServer: 一键刷库 —— 下发刷库配置到指定接入服务。
+// 外挂程序启动并同步该配置后,对其保护目录内的全量数据进行加密处理。
+// 当前实现仅完成「校验服务存在 + 下发入口」,实际加密由外挂程序侧执行。
+
+package logic
+
+import (
+	"context"
+
+	"udeServer/internal/svc"
+	"udeServer/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type BrushServiceLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewBrushServiceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BrushServiceLogic {
+	return &BrushServiceLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *BrushServiceLogic) BrushService(req *types.ServiceIdReq) (*types.DeleteResp, error) {
+	if err := checkId(req.Id); err != nil {
+		return nil, err
+	}
+	if _, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id); err != nil {
+		return nil, err
+	}
+
+	l.Infof("brush config dispatched, service id: %d", req.Id)
+	return &types.DeleteResp{Ok: true}, nil
+}

+ 11 - 8
internal/logic/createdirectorylogic.go

@@ -28,21 +28,24 @@ func NewCreateDirectoryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *C
 }
 
 func (l *CreateDirectoryLogic) CreateDirectory(req *types.CreateDirectoryReq) (*types.DirectoryResp, error) {
-	if err := checkId(req.Id); err != nil {
-		return nil, err
-	}
-
+	// t_directory.id 由 DB 自增,请求体不传 id
 	data := &model.TDirectory{
-		Id:        req.Id,
 		ServiceId: nullInt64(req.ServiceId),
 		Directory: nullString(req.Directory),
 	}
-	if _, err := l.svcCtx.DirectoryModel.Insert(l.ctx, data); err != nil {
-		l.Errorf("insert directory %d failed: %v", req.Id, err)
+	ret, err := l.svcCtx.DirectoryModel.Insert(l.ctx, data)
+	if err != nil {
+		l.Errorf("insert directory failed: %v", err)
+		return nil, err
+	}
+
+	newId, err := ret.LastInsertId()
+	if err != nil {
+		l.Errorf("get last insert id failed: %v", err)
 		return nil, err
 	}
 
-	created, err := findDirectory(l.ctx, l.svcCtx.DirectoryModel, req.Id)
+	created, err := findDirectory(l.ctx, l.svcCtx.DirectoryModel, newId)
 	if err != nil {
 		return nil, err
 	}

+ 49 - 18
internal/logic/createservicelogic.go

@@ -1,16 +1,19 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.2
+// udeServer: CreateService 走事务 —— 同一事务内插 t_service + N 条 t_directory,
+// 任意一条 INSERT 失败,全部回滚,不留脏数据。
 
 package logic
 
 import (
 	"context"
+	"fmt"
+	"strings"
 
 	"udeServer/internal/model"
 	"udeServer/internal/svc"
 	"udeServer/internal/types"
 
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
 type CreateServiceLogic struct {
@@ -28,26 +31,54 @@ func NewCreateServiceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Cre
 }
 
 func (l *CreateServiceLogic) CreateService(req *types.CreateServiceReq) (*types.ServiceResp, error) {
-	if err := checkId(req.Id); err != nil {
-		return nil, err
-	}
+	var svcId int64
+	err := l.svcCtx.DB.Transact(func(session sqlx.Session) error {
+		// 把 model 绑到事务 session 上,后续所有写操作都走同一个连接/事务
+		svcModel := l.svcCtx.ServiceModel.WithSession(session)
+		dirModel := l.svcCtx.DirectoryModel.WithSession(session)
 
-	data := &model.TService{
-		Id:      req.Id,
-		Name:    nullString(req.Name),
-		Ip:      nullString(req.Ip),
-		Port:    nullString(req.Port),
-		Iden:    nullString(req.Iden),
-		KeyIden: nullString(req.KeyIden),
-		KeyAlgo: nullString(req.KeyAlgo),
-		Status:  nullString(req.Status),
-	}
-	if _, err := l.svcCtx.ServiceModel.Insert(l.ctx, data); err != nil {
-		l.Errorf("insert service %d failed: %v", req.Id, err)
+		// 1. 插服务
+		svcData := &model.TService{
+			Name:    nullString(req.Name),
+			Ip:      nullString(req.Ip),
+			Port:    nullString(req.Port),
+			Iden:    nullString(req.Iden),
+			KeyIden: nullString(req.KeyIden),
+			KeyAlgo: nullString(req.KeyAlgo),
+			Status:  nullString(req.Status),
+		}
+		svcRes, err := svcModel.Insert(l.ctx, svcData)
+		if err != nil {
+			return fmt.Errorf("insert t_service: %w", err)
+		}
+		svcId, err = svcRes.LastInsertId()
+		if err != nil {
+			return fmt.Errorf("get last insert id: %w", err)
+		}
+
+		// 2. 插保护目录(与上面的服务同事务,任一条失败整体回滚)
+		for _, dir := range req.Directories {
+			path := strings.TrimSpace(dir)
+			if path == "" {
+				continue
+			}
+			_, err := dirModel.Insert(l.ctx, &model.TDirectory{
+				ServiceId: nullInt64(svcId),
+				Directory: nullString(path),
+			})
+			if err != nil {
+				return fmt.Errorf("insert t_directory[%s]: %w", path, err)
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		l.Errorf("create service tx failed: %v", err)
 		return nil, err
 	}
 
-	created, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id)
+	// 事务提交后再回查完整实体(CreateAt 由 DB 默认值生成)
+	created, err := findService(l.ctx, l.svcCtx.ServiceModel, svcId)
 	if err != nil {
 		return nil, err
 	}

+ 18 - 4
internal/logic/deleteservicelogic.go

@@ -1,15 +1,17 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.2
+// udeServer: DeleteService 走事务 —— 同一事务内先删该服务下的 t_directory,
+// 再删 t_service。任一步失败全部回滚,不会残留孤儿目录数据。
 
 package logic
 
 import (
 	"context"
+	"fmt"
 
 	"udeServer/internal/svc"
 	"udeServer/internal/types"
 
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
 type DeleteServiceLogic struct {
@@ -34,8 +36,20 @@ func (l *DeleteServiceLogic) DeleteService(req *types.ServiceIdReq) (*types.Dele
 		return nil, err
 	}
 
-	if err := l.svcCtx.ServiceModel.Delete(l.ctx, req.Id); err != nil {
-		l.Errorf("delete service %d failed: %v", req.Id, err)
+	err := l.svcCtx.DB.Transact(func(session sqlx.Session) error {
+		dirModel := l.svcCtx.DirectoryModel.WithSession(session)
+		svcModel := l.svcCtx.ServiceModel.WithSession(session)
+
+		if err := dirModel.DeleteByServiceId(l.ctx, req.Id); err != nil {
+			return fmt.Errorf("delete t_directory of service %d: %w", req.Id, err)
+		}
+		if err := svcModel.Delete(l.ctx, req.Id); err != nil {
+			return fmt.Errorf("delete t_service %d: %w", req.Id, err)
+		}
+		return nil
+	})
+	if err != nil {
+		l.Errorf("delete service tx failed: %v", err)
 		return nil, err
 	}
 

+ 107 - 0
internal/logic/listdirchildrenlogic.go

@@ -0,0 +1,107 @@
+// 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"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"udeServer/internal/apierr"
+	"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,
+	}
+}
+
+func (l *ListDirChildrenLogic) ListDirChildren(req *types.ListDirChildrenReq) (*types.DirChildrenResp, error) {
+	// 1. 归一化: 空 → 根目录; 必须以 / 开头; Clean 防 ../ 穿越
+	p := strings.TrimSpace(req.Path)
+	if p == "" {
+		p = "/"
+	}
+	if !strings.HasPrefix(p, "/") {
+		return nil, apierr.BadRequest("path 必须是绝对路径(以 / 开头)")
+	}
+	dir := filepath.Clean(p)
+
+	// 2. 读取该层目录(只读一层,天然懒加载)
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		// 权限不足等场景: 记日志 + 返回空列表,不中断前端树的展开
+		l.Errorf("read dir %s failed: %v", dir, err)
+		return &types.DirChildrenResp{Nodes: []types.LocalDirNode{}}, nil
+	}
+
+	// 3. 挂载根目录前缀(该目录树下的挂载点不参与选择)
+	baseDir := strings.TrimSuffix(l.svcCtx.Config.Mount.BaseDir, "/")
+
+	nodes := make([]types.LocalDirNode, 0, len(entries))
+	for _, e := range entries {
+		if !e.IsDir() {
+			continue // 只要目录,文件一律跳过
+		}
+		name := e.Name()
+		if dir == "/" && dirBlacklist[name] {
+			continue // 根层黑名单系统目录
+		}
+		full := filepath.ToSlash(filepath.Join(dir, name))
+		if baseDir != "" && (full == baseDir || strings.HasPrefix(full, baseDir+"/")) {
+			continue // ude 自己的挂载根目录不展示
+		}
+		nodes = append(nodes, types.LocalDirNode{
+			Name:        name,
+			Path:        full,
+			IsDir:       true,
+			HasChildren: hasSubDir(full),
+		})
+	}
+	return &types.DirChildrenResp{Nodes: nodes}, nil
+}
+
+// hasSubDir 判断目录下是否还有子目录(只读一层、找到一个即返回,开销可控)
+func hasSubDir(dir string) bool {
+	subs, err := os.ReadDir(dir)
+	if err != nil {
+		return false
+	}
+	for _, s := range subs {
+		if s.IsDir() {
+			return true
+		}
+	}
+	return false
+}

+ 10 - 2
internal/model/tdirectorymodel.go

@@ -15,7 +15,8 @@ type (
 	TDirectoryModel interface {
 		tDirectoryModel
 		FindPage(ctx context.Context, page, pageSize int64) ([]TDirectory, int64, error)
-		withSession(session sqlx.Session) TDirectoryModel
+		DeleteByServiceId(ctx context.Context, serviceId int64) error
+		WithSession(session sqlx.Session) TDirectoryModel
 	}
 
 	customTDirectoryModel struct {
@@ -50,6 +51,13 @@ func (m *customTDirectoryModel) FindPage(ctx context.Context, page, pageSize int
 	return resp, total, nil
 }
 
-func (m *customTDirectoryModel) withSession(session sqlx.Session) TDirectoryModel {
+// DeleteByServiceId 删除某个服务下的全部保护目录,配合服务删除做级联清理。
+func (m *customTDirectoryModel) DeleteByServiceId(ctx context.Context, serviceId int64) error {
+	query := fmt.Sprintf("delete from %s where `service_id` = ?", m.table)
+	_, err := m.conn.ExecCtx(ctx, query, serviceId)
+	return err
+}
+
+func (m *customTDirectoryModel) WithSession(session sqlx.Session) TDirectoryModel {
 	return NewTDirectoryModel(sqlx.NewSqlConnFromSession(session))
 }

+ 2 - 2
internal/model/tservicemodel.go

@@ -15,7 +15,7 @@ type (
 	TServiceModel interface {
 		tServiceModel
 		FindPage(ctx context.Context, page, pageSize int64) ([]TService, int64, error)
-		withSession(session sqlx.Session) TServiceModel
+		WithSession(session sqlx.Session) TServiceModel
 	}
 
 	customTServiceModel struct {
@@ -50,6 +50,6 @@ func (m *customTServiceModel) FindPage(ctx context.Context, page, pageSize int64
 	return resp, total, nil
 }
 
-func (m *customTServiceModel) withSession(session sqlx.Session) TServiceModel {
+func (m *customTServiceModel) WithSession(session sqlx.Session) TServiceModel {
 	return NewTServiceModel(sqlx.NewSqlConnFromSession(session))
 }

+ 34 - 9
internal/types/types.go

@@ -4,7 +4,7 @@
 package types
 
 type CreateDirectoryReq struct {
-	Id        int64  `json:"id"`
+	Id        int64  `json:"id,optional"`
 	ServiceId int64  `json:"serviceId,optional"`
 	Directory string `json:"directory,optional"`
 }
@@ -14,14 +14,15 @@ type CreateKeyReq struct {
 }
 
 type CreateServiceReq struct {
-	Id      int64  `json:"id"`
-	Name    string `json:"name,optional"`
-	Ip      string `json:"ip,optional"`
-	Port    string `json:"port,optional"`
-	Iden    string `json:"iden,optional"`
-	KeyIden string `json:"keyIden,optional"`
-	KeyAlgo string `json:"keyAlgo,optional"`
-	Status  string `json:"status,optional"`
+	Id          int64    `json:"id,optional"`
+	Name        string   `json:"name,optional"`
+	Ip          string   `json:"ip,optional"`
+	Port        string   `json:"port,optional"`
+	Iden        string   `json:"iden,optional"`
+	KeyIden     string   `json:"keyIden,optional"`
+	KeyAlgo     string   `json:"keyAlgo,optional"`
+	Status      string   `json:"status,optional"`
+	Directories []string `json:"directories,optional"`
 }
 
 type DbPingResponse struct {
@@ -43,6 +44,14 @@ type DirectoryListResp struct {
 	Total int64           `json:"total"`
 }
 
+type DirectoryNode struct {
+	Name       string          `json:"name"`
+	SmbPath    string          `json:"smbPath"`    // SMB 路径: \\ip\share\dir
+	LocalMount string          `json:"localMount"` // 真实挂载点: /var/ude/mnt/svc-123/data
+	IsDir      bool            `json:"isDir"`
+	Children   []DirectoryNode `json:"children,optional"`
+}
+
 type DirectoryResp struct {
 	Id        int64  `json:"id"`
 	ServiceId int64  `json:"serviceId"`
@@ -92,6 +101,22 @@ type Response struct {
 	Message string `json:"message"`
 }
 
+// 本地目录懒加载(udeServer 所在 Linux 主机文件系统,只列目录不列文件)
+type ListDirChildrenReq struct {
+	Path string `form:"path"` // 空或 "/" 表示根目录
+}
+
+type LocalDirNode struct {
+	Name        string `json:"name"`        // 目录名,如 "usr"
+	Path        string `json:"path"`        // 完整绝对路径,如 "/usr"
+	IsDir       bool   `json:"isDir"`       // 恒 true
+	HasChildren bool   `json:"hasChildren"` // 是否含子目录(前端据此决定是否显示展开箭头)
+}
+
+type DirChildrenResp struct {
+	Nodes []LocalDirNode `json:"nodes"`
+}
+
 type ServiceIdReq struct {
 	Id int64 `path:"id"`
 }

+ 48 - 9
udeServer.api

@@ -19,7 +19,7 @@ type DeleteResp {
 }
 
 type CreateDirectoryReq {
-	Id        int64  `json:"id"`
+	Id        int64  `json:"id,optional"`
 	ServiceId int64  `json:"serviceId,optional"`
 	Directory string `json:"directory,optional"`
 }
@@ -85,14 +85,15 @@ type KeyListResp {
 }
 
 type CreateServiceReq {
-	Id      int64  `json:"id"`
-	Name    string `json:"name,optional"`
-	Ip      string `json:"ip,optional"`
-	Port    string `json:"port,optional"`
-	Iden    string `json:"iden,optional"`
-	KeyIden string `json:"keyIden,optional"`
-	KeyAlgo string `json:"keyAlgo,optional"`
-	Status  string `json:"status,optional"`
+	Id      int64    `json:"id,optional"`
+	Name    string   `json:"name,optional"`
+	Ip      string   `json:"ip,optional"`
+	Port    string   `json:"port,optional"`
+	Iden    string   `json:"iden,optional"`
+	KeyIden string   `json:"keyIden,optional"`
+	KeyAlgo string   `json:"keyAlgo,optional"`
+	Status  string   `json:"status,optional"`
+	Directories []string `json:"directories,optional"`
 }
 
 type UpdateServiceReq {
@@ -132,6 +133,38 @@ type ServiceListResp {
 	Total int64         `json:"total"`
 }
 
+// 本地目录懒加载(udeServer 所在 Linux 主机文件系统,只列目录不列文件)
+type ListDirChildrenReq {
+	Path string `form:"path"` // 空或 "/" 表示根目录
+}
+
+type LocalDirNode {
+	Name        string `json:"name"`        // 目录名,如 "usr"
+	Path        string `json:"path"`        // 完整绝对路径,如 "/usr"
+	IsDir       bool   `json:"isDir"`       // 恒 true
+	HasChildren bool   `json:"hasChildren"` // 是否含子目录(前端据此决定是否显示展开箭头)
+}
+
+type DirChildrenResp {
+	Nodes []LocalDirNode `json:"nodes"`
+}
+
+// 自动检测 SMB 共享的目录树
+type ScanDirectoriesReq {
+	Ip    string `json:"ip"`
+	Port  string `json:"port,optional"`
+	Share string `json:"share,optional"` // 留空读 config.DefaultShare
+}
+
+type DirectoryNode {
+	Name       string          `json:"name"`
+	SmbPath    string          `json:"smbPath"` // SMB 路径: \\ip\share\dir
+	LocalMount string          `json:"localMount"` // 真实挂载点: /var/ude/mnt/svc-123/data
+	IsDir      bool            `json:"isDir"`
+	Children   []DirectoryNode `json:"children,optional"`
+}
+
+
 @server (
 	prefix: /api/v1
 )
@@ -186,5 +219,11 @@ service udeServer-api {
 
 	@handler DeleteServiceHandler
 	delete /services/:id (ServiceIdReq) returns (DeleteResp)
+
+	@handler BrushServiceHandler
+	post /services/:id/brush (ServiceIdReq) returns (DeleteResp)
+
+	@handler ListDirChildrenHandler
+	get /dirs/children (ListDirChildrenReq) returns (DirChildrenResp)
 }