| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589 |
- package main
- // 远程存储(docs/design/21-knowledge-sources.md §6):每个资料库一个 rclone 子进程,只读挂到 mounter/remote/<id>(内部,只有本用户能访问),
- // 再由本进程的文件系统(隐藏文件过滤、只读)提供给 knowledge/mnt/<id>。
- // 连接参数和凭据通过子进程的环境变量传给 rclone,不放在命令行里(命令行在进程列表中所有人可见)。
- import (
- "bytes"
- "context"
- "fmt"
- "log"
- "net/url"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "sort"
- "strings"
- "sync"
- "syscall"
- "time"
- )
- type remoteSpec struct {
- Type string `json:"type"`
- Params map[string]string `json:"params"`
- Secrets map[string]string `json:"secrets"`
- }
- // allowLocalRemote 仅测试使用:rclone 的 local 后端,不需要网络
- var allowLocalRemote = false
- var (
- bucketPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$`)
- s3Providers = map[string]bool{"Minio": true, "AWS": true, "Alibaba": true, "TencentCOS": true, "HuaweiOBS": true, "Qiniu": true, "Ceph": true, "Cloudflare": true, "Other": true}
- webdavVendors = map[string]bool{"other": true, "nextcloud": true, "owncloud": true, "sharepoint": true, "sharepoint-ntlm": true}
- )
- type rcloneEnv struct {
- env []string // RCLONE_CONFIG_KB_*
- path string // kb:bucket/prefix
- }
- func clean(v string) (string, error) {
- v = strings.TrimSpace(v)
- if strings.ContainsAny(v, "\x00\r\n") {
- return "", fmt.Errorf("参数中不能有换行")
- }
- return v, nil
- }
- func httpURL(v, name string) (string, error) {
- u, err := url.Parse(v)
- if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
- return "", fmt.Errorf("%s必须是 http 或 https 地址", name)
- }
- if u.User != nil {
- return "", fmt.Errorf("%s中不能包含用户名或密码", name)
- }
- return v, nil
- }
- // buildRemote 按存储类型生成 rclone 的环境变量与远程路径
- func buildRemote(r remoteSpec, rclone string) (*rcloneEnv, error) {
- p := map[string]string{}
- for k, v := range r.Params {
- c, err := clean(v)
- if err != nil {
- return nil, err
- }
- p[k] = c
- }
- sec := func(k string) (string, error) {
- v := r.Secrets[k]
- if strings.ContainsAny(v, "\x00\r\n") {
- return "", fmt.Errorf("凭据中不能有换行")
- }
- return v, nil
- }
- out := &rcloneEnv{}
- add := func(k, v string) { out.env = append(out.env, "RCLONE_CONFIG_KB_"+k+"="+v) }
- switch r.Type {
- case "s3":
- provider := p["provider"]
- if provider == "" {
- provider = "Other"
- }
- if !s3Providers[provider] {
- return nil, fmt.Errorf("不支持的 S3 服务商:%s", provider)
- }
- if p["endpoint"] == "" && provider != "AWS" {
- return nil, fmt.Errorf("请填写服务地址(endpoint)")
- }
- if p["endpoint"] != "" {
- if _, err := httpURL(p["endpoint"], "服务地址"); err != nil {
- return nil, err
- }
- }
- if !bucketPattern.MatchString(p["bucket"]) {
- return nil, fmt.Errorf("存储桶名称不正确")
- }
- ak, err := sec("accessKeyId")
- if err != nil {
- return nil, err
- }
- sk, err := sec("secretAccessKey")
- if err != nil {
- return nil, err
- }
- if ak == "" || sk == "" {
- return nil, fmt.Errorf("请填写 Access Key 和 Secret Key")
- }
- add("TYPE", "s3")
- add("PROVIDER", provider)
- add("ENV_AUTH", "false")
- add("ACCESS_KEY_ID", ak)
- add("SECRET_ACCESS_KEY", sk)
- add("NO_CHECK_BUCKET", "true")
- if p["endpoint"] != "" {
- add("ENDPOINT", p["endpoint"])
- }
- if p["region"] != "" {
- add("REGION", p["region"])
- }
- prefix := strings.Trim(p["prefix"], "/")
- if strings.Contains("/"+prefix+"/", "/../") {
- return nil, fmt.Errorf("路径前缀不能包含 ..")
- }
- out.path = "kb:" + p["bucket"]
- if prefix != "" {
- out.path += "/" + prefix
- }
- case "webdav":
- u, err := httpURL(p["url"], "地址")
- if err != nil {
- return nil, err
- }
- vendor := p["vendor"]
- if vendor == "" {
- vendor = "other"
- }
- if !webdavVendors[vendor] {
- return nil, fmt.Errorf("不支持的 WebDAV 服务类型:%s", vendor)
- }
- add("TYPE", "webdav")
- add("URL", u)
- add("VENDOR", vendor)
- if p["user"] != "" {
- add("USER", p["user"])
- }
- pass, err := sec("password")
- if err != nil {
- return nil, err
- }
- if pass != "" {
- ob, err := obscure(rclone, pass)
- if err != nil {
- return nil, err
- }
- add("PASS", ob)
- }
- out.path = "kb:"
- case "local":
- if !allowLocalRemote {
- return nil, fmt.Errorf("不支持的存储类型:local")
- }
- add("TYPE", "local")
- out.path = "kb:" + p["path"]
- default:
- return nil, fmt.Errorf("不支持的存储类型:%s", r.Type)
- }
- return out, nil
- }
- // obscure rclone 的 WebDAV 密码需要先混淆;明文从标准输入传入,不出现在命令行
- func obscure(rclone, pass string) (string, error) {
- cmd := exec.Command(rclone, "obscure", "-")
- cmd.Env = []string{"PATH=/usr/bin:/bin"}
- cmd.Stdin = strings.NewReader(pass)
- out, err := cmd.Output()
- if err != nil {
- return "", fmt.Errorf("处理密码失败:%v", err)
- }
- return strings.TrimSpace(string(out)), nil
- }
- // classify 根据 rclone 的错误输出判断状态
- func classify(stderr string) (state, msg string) {
- low := strings.ToLower(stderr)
- detail := lastError(stderr)
- switch {
- case containsAny(low, "signaturedoesnotmatch", "invalidaccesskeyid", "accessdenied", "access denied", "statuscode: 401", "statuscode: 403", "401 unauthorized", "403 forbidden", "authentication", "unauthorized"):
- return "auth_failed", "认证失败或没有权限:" + detail
- case containsAny(low, "nosuchbucket", "bucket does not exist", "directory not found", "404 not found", "statuscode: 404"):
- return "path_missing", "存储桶或路径不存在:" + detail
- case containsAny(low, "connection refused", "no such host", "i/o timeout", "timeout", "network is unreachable", "tls:", "x509", "connection reset", "unexpected eof"):
- return "unreachable", "无法连接:" + detail
- }
- if detail == "" {
- detail = "未知错误"
- }
- return "unreachable", detail
- }
- var (
- apiErr = regexp.MustCompile(`api error ([A-Za-z]+): ([^\n]+)`)
- netErr = regexp.MustCompile(`(dial tcp [^\n]*|lookup [^\n]*|tls: [^\n]*|x509: [^\n]*)`)
- )
- func lastError(s string) string {
- if m := apiErr.FindAllStringSubmatch(s, -1); len(m) > 0 {
- last := m[len(m)-1]
- return strings.TrimSpace(last[1] + ":" + last[2])
- }
- if m := netErr.FindAllString(s, -1); len(m) > 0 {
- return strings.TrimSpace(m[len(m)-1])
- }
- lines := strings.Split(strings.TrimSpace(s), "\n")
- for i := len(lines) - 1; i >= 0; i-- {
- l := lines[i]
- if strings.Contains(l, "ERROR") || strings.Contains(l, "CRITICAL") || strings.Contains(l, "Failed") {
- if j := strings.Index(l, ": "); j >= 0 && strings.Contains(l[:j], "/") {
- l = l[j+2:]
- }
- if len(l) > 300 {
- l = l[:300] + "…"
- }
- return strings.TrimSpace(l)
- }
- }
- return ""
- }
- func containsAny(s string, subs ...string) bool {
- for _, x := range subs {
- if strings.Contains(s, x) {
- return true
- }
- }
- return false
- }
- // rcloneRunner 运行 rclone 的公共参数
- type rcloneRunner struct {
- bin string
- config string // 空的配置文件,避免读取部署用户自己的 rclone.conf
- home string
- }
- func (r *rcloneRunner) command(ctx context.Context, env *rcloneEnv, args ...string) *exec.Cmd {
- cmd := exec.CommandContext(ctx, r.bin, append(args, "--config", r.config)...)
- cmd.Env = append([]string{"PATH=/usr/bin:/bin", "HOME=" + r.home}, env.env...)
- return cmd
- }
- type probeResult struct {
- State string `json:"state"`
- Message string `json:"message"`
- Entries []string `json:"entries"`
- }
- // probe 列出远程根目录(带超时),用于"测试连接"与状态检查
- func (r *rcloneRunner) probe(parent context.Context, env *rcloneEnv, timeout time.Duration) probeResult {
- ctx, cancel := context.WithTimeout(parent, timeout)
- defer cancel()
- cmd := r.command(ctx, env, "lsf", env.path, "--max-depth", "1", "--low-level-retries", "1", "--retries", "1", "--contimeout", "5s", "--timeout", "10s", "--log-level", "ERROR")
- var out, errb bytes.Buffer
- cmd.Stdout, cmd.Stderr = &limitWriter{w: &out, n: 64 << 10}, &limitWriter{w: &errb, n: 64 << 10}
- err := cmd.Run()
- if ctx.Err() == context.DeadlineExceeded {
- return probeResult{State: "unreachable", Message: "连接超时"}
- }
- if ctx.Err() != nil {
- return probeResult{State: "pending", Message: "已取消"}
- }
- // rclone lsf 出错时退出码可能为 0,以错误输出为准
- if err != nil || strings.Contains(errb.String(), "ERROR") {
- st, msg := classify(errb.String())
- return probeResult{State: st, Message: msg}
- }
- var entries []string
- for _, l := range strings.Split(strings.TrimSpace(out.String()), "\n") {
- if l != "" && !strings.HasPrefix(l, ".") && len(entries) < 50 {
- entries = append(entries, l)
- }
- }
- return probeResult{State: "ok", Entries: entries}
- }
- type limitWriter struct {
- w *bytes.Buffer
- n int
- }
- func (l *limitWriter) Write(p []byte) (int, error) {
- if rest := l.n - l.w.Len(); rest > 0 {
- if len(p) > rest {
- l.w.Write(p[:rest])
- } else {
- l.w.Write(p)
- }
- }
- return len(p), nil
- }
- // ringLog 保留 rclone 最近的错误输出
- type ringLog struct {
- mu sync.Mutex
- buf []string
- }
- func (r *ringLog) Write(p []byte) (int, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- for _, l := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
- if l == "" {
- continue
- }
- r.buf = append(r.buf, l)
- if len(r.buf) > 50 {
- r.buf = r.buf[len(r.buf)-50:]
- }
- }
- return len(p), nil
- }
- func (r *ringLog) String() string {
- r.mu.Lock()
- defer r.mu.Unlock()
- return strings.Join(r.buf, "\n")
- }
- func (r *ringLog) reset() {
- r.mu.Lock()
- r.buf = nil
- r.mu.Unlock()
- }
- // remoteProc 一个远程存储的 rclone 挂载及其状态
- type remoteProc struct {
- id string
- point string // 内部挂载点
- cache string
- runner *rcloneRunner
- sys system
- env *rcloneEnv
- key string // 配置的指纹,变化时重启
- mu sync.Mutex
- mounted bool
- state string
- message string
- probedAt time.Time
- ready chan struct{} // 首次得出状态后关闭
- readyOnce sync.Once
- stop chan struct{}
- done chan struct{}
- cmd *exec.Cmd
- logs ringLog
- onChange func()
- }
- func (p *remoteProc) status() (mounted bool, state, msg string) {
- p.mu.Lock()
- defer p.mu.Unlock()
- return p.mounted, p.state, p.message
- }
- func (p *remoteProc) set(mounted bool, state, msg string) {
- p.mu.Lock()
- changed := p.mounted != mounted || p.state != state || p.message != msg
- p.mounted, p.state, p.message = mounted, state, msg
- p.mu.Unlock()
- if state != "pending" {
- p.readyOnce.Do(func() { close(p.ready) })
- }
- if changed && p.onChange != nil {
- p.onChange()
- }
- }
- // isMounted 内部挂载点上确实是 rclone 的挂载(而不是下面的空目录)
- func (p *remoteProc) isMounted() bool {
- for _, e := range p.sys.mountinfo() {
- if e.Point == p.point && strings.HasPrefix(e.FSType, "fuse.rclone") {
- return true
- }
- }
- return false
- }
- // run 启动并看护 rclone:退出时按退避间隔重启
- func (p *remoteProc) run() {
- defer close(p.done)
- backoff := 5 * time.Second
- for {
- started := time.Now()
- p.runOnce()
- select {
- case <-p.stop:
- return
- default:
- }
- if time.Since(started) > time.Minute {
- backoff = 5 * time.Second
- }
- select {
- case <-p.stop:
- return
- case <-time.After(backoff):
- }
- if backoff < 5*time.Minute {
- backoff *= 2
- }
- }
- }
- func (p *remoteProc) runOnce() {
- _ = exec.Command("fusermount3", "-uz", p.point).Run()
- if err := os.MkdirAll(p.point, 0o700); err != nil {
- p.set(false, "mount_failed", "创建内部挂载点失败:"+err.Error())
- return
- }
- _ = os.MkdirAll(p.cache, 0o700)
- p.logs.reset()
- cmd := p.runner.command(context.Background(), p.env, "mount", p.env.path, p.point,
- "--read-only", "--vfs-cache-mode", "full", "--cache-dir", p.cache,
- "--vfs-cache-max-size", "1G", "--vfs-cache-max-age", "1h", "--dir-cache-time", "1m",
- "--low-level-retries", "3", "--contimeout", "10s", "--timeout", "60s", "--log-level", "NOTICE")
- cmd.Stdout, cmd.Stderr = &p.logs, &p.logs
- cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
- if err := cmd.Start(); err != nil {
- p.set(false, "mount_failed", "启动 rclone 失败:"+err.Error())
- return
- }
- p.mu.Lock()
- p.cmd = cmd
- p.mu.Unlock()
- exited := make(chan error, 1)
- go func() { exited <- cmd.Wait() }()
- // 等挂载出现
- deadline := time.After(30 * time.Second)
- tick := time.NewTicker(200 * time.Millisecond)
- defer tick.Stop()
- wait:
- for {
- select {
- case err := <-exited:
- st, msg := classify(p.logs.String())
- if msg == "未知错误" || msg == "" {
- msg = fmt.Sprintf("rclone 退出:%v", err)
- }
- log.Printf("%s:rclone 退出:%s", p.id, lastError(p.logs.String()))
- p.set(false, st, msg)
- return
- case <-p.stop:
- p.terminate(exited)
- return
- case <-deadline:
- log.Printf("%s:rclone 30 秒内没有完成挂载", p.id)
- p.set(false, "unreachable", "挂载超时")
- p.terminate(exited)
- return
- case <-tick.C:
- if p.isMounted() {
- break wait
- }
- }
- }
- log.Printf("%s:rclone 已挂载 %s", p.id, p.env.path)
- p.set(true, "pending", "正在连接")
- p.checkNow()
- // 运行期间:未连通时每分钟检查一次,正常时每 5 分钟
- for {
- _, state, _ := p.status()
- every := 5 * time.Minute
- if state != "ok" {
- every = time.Minute
- }
- select {
- case err := <-exited:
- st, msg := "unreachable", fmt.Sprintf("rclone 意外退出(%v),稍后自动重启", err)
- if detail := lastError(p.logs.String()); detail != "" {
- st, msg = classify(p.logs.String())
- }
- log.Printf("%s:rclone 意外退出(%v):%s", p.id, err, lastError(p.logs.String()))
- p.set(false, st, msg)
- _ = exec.Command("fusermount3", "-uz", p.point).Run()
- return
- case <-p.stop:
- p.terminate(exited)
- return
- case <-time.After(every):
- p.checkNow()
- }
- }
- }
- // checkNow 列远程根目录,得出连接状态
- func (p *remoteProc) checkNow() {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- go func() {
- select {
- case <-p.stop:
- cancel()
- case <-ctx.Done():
- }
- }()
- r := p.runner.probe(ctx, p.env, 20*time.Second)
- if r.State == "pending" {
- return
- }
- if r.State == "ok" {
- // 之前的错误已经过去,rclone 之后退出时不再据此判断
- p.logs.reset()
- }
- p.mu.Lock()
- p.probedAt = time.Now()
- mounted := p.mounted
- p.mu.Unlock()
- p.set(mounted, r.State, r.Message)
- }
- // checkIfStale 状态查询时:超过 30 秒没检查过的,在后台重新检查
- func (p *remoteProc) checkIfStale() {
- p.mu.Lock()
- stale := p.mounted && time.Since(p.probedAt) > 30*time.Second
- if stale {
- p.probedAt = time.Now()
- }
- p.mu.Unlock()
- if stale {
- go p.checkNow()
- }
- }
- func (p *remoteProc) terminate(exited chan error) {
- p.mu.Lock()
- cmd := p.cmd
- p.mu.Unlock()
- if cmd != nil && cmd.Process != nil {
- _ = cmd.Process.Signal(syscall.SIGTERM)
- select {
- case <-exited:
- case <-time.After(5 * time.Second):
- _ = cmd.Process.Kill()
- <-exited
- }
- }
- _ = exec.Command("fusermount3", "-uz", p.point).Run()
- }
- func newRemoteProc(id, remoteDir, cacheDir string, runner *rcloneRunner, sys system, env *rcloneEnv, key string, onChange func()) *remoteProc {
- return &remoteProc{
- id: id, point: filepath.Join(remoteDir, id), cache: filepath.Join(cacheDir, id), runner: runner, sys: sys, env: env, key: key,
- state: "pending", message: "正在连接", ready: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}), onChange: onChange,
- }
- }
- func (p *remoteProc) start() { go p.run() }
- // shutdown 停止 rclone、卸载、删除缓存
- func (p *remoteProc) shutdown() {
- close(p.stop)
- <-p.done
- _ = os.Remove(p.point)
- _ = os.RemoveAll(p.cache)
- }
- // remoteKey 配置指纹(含凭据),用于判断是否需要重启 rclone
- func remoteKey(r *remoteSpec) string {
- var b strings.Builder
- b.WriteString(r.Type)
- for _, m := range []map[string]string{r.Params, r.Secrets} {
- keys := make([]string, 0, len(m))
- for k := range m {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- for _, k := range keys {
- fmt.Fprintf(&b, "\x00%s=%s", k, m[k])
- }
- b.WriteString("\x01")
- }
- return b.String()
- }
|