gcli2api / internal /syncer /state.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
7.26 kB
package syncer
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
// 分类常量:决定用哪套合并规则(凭证看 expiresAt,普通数据看 mtime)。
const (
catAuth = "auth"
catData = "data"
)
// FileState 是一个文件在某一端的可比较状态。
//
// Content 只在需要时填充(本地端天然有;远端端按需下载)。比较优先用
// SHA256,避免为了判断"是否相同"而多跑一次下载。
type FileState struct {
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Mtime time.Time `json:"mtime"`
// Content 是文件原始字节,**绝不能被序列化出去**。
//
// 它只用于本地合并与上传;而 Action 会被 `wb2api-sync -json` 整个打印出来,
// 一旦让 Content 进 JSON,`status -json` 就会把 accessToken / refreshToken
// 明文(base64)灌进终端、CI 日志或 issue 附件里——等于自己泄露凭证。
Content []byte `json:"-"`
// 仅 auth 文件填充:这三项是"谁的凭证更新"的判据。
IsAuth bool `json:"is_auth,omitempty"`
UID string `json:"uid,omitempty"`
Nickname string `json:"nickname,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
HasRefresh bool `json:"has_refresh,omitempty"`
Realm string `json:"realm,omitempty"`
}
// hasContent 报告该状态是否已带内容。
func (f *FileState) hasContent() bool { return f.Content != nil }
// scanLocal 扫描本地待同步文件,key 为桶内相对路径(如 auths/workbuddy-x.json)。
//
// 只扫顶层 *.json:
// - auths/ —— 与网关 auth.LoadDir 的口径一致(它也只解析一层);
// - data/ —— 只取 IncludeData 白名单。
//
// 子目录(如 data/request-logs/)一律不扫:请求归档是本机观测数据,
// 互相搬运既无意义又会把桶撑大。
func (e *Engine) scanLocal() (map[string]*FileState, error) {
out := map[string]*FileState{}
if e.cfg.AuthDir != "" {
files, err := readTopLevelJSON(e.cfg.AuthDir)
if err != nil {
return nil, err
}
for _, name := range files {
fp := filepath.Join(e.cfg.AuthDir, name)
st, err := statLocalFile(fp, "auths/"+name, catAuth)
if err != nil {
// 单个坏文件不阻断整体同步(与 auth.LoadDir 的容错口径一致)。
continue
}
out[st.Path] = st
}
}
for _, name := range e.cfg.IncludeData {
if e.cfg.DataDir == "" || name == "" || strings.ContainsAny(name, `/\`) {
continue
}
fp := filepath.Join(e.cfg.DataDir, name)
st, err := statLocalFile(fp, "data/"+name, catData)
if err != nil {
continue // 本地还没有这个文件是正常态(首跑)
}
out[st.Path] = st
}
return out, nil
}
// statLocalFile 读取并按 category 解析一个本地文件。
func statLocalFile(fp, relPath, category string) (*FileState, error) {
raw, err := os.ReadFile(fp)
if err != nil {
return nil, err
}
info, err := os.Stat(fp)
if err != nil {
return nil, err
}
st := newFileState(relPath, raw, info.ModTime(), category)
return st, nil
}
// newFileState 由内容 + mtime + 分类构造 FileState。
func newFileState(relPath string, raw []byte, mtime time.Time, category string) *FileState {
sum := sha256.Sum256(raw)
st := &FileState{
Path: relPath,
Size: int64(len(raw)),
SHA256: hex.EncodeToString(sum[:]),
Mtime: mtime,
Content: raw,
}
if category == catAuth {
annotateAuth(st, raw)
}
return st
}
// annotateAuth 给 auth 文件标注比较维度(uid / expiresAt / refreshToken 有无)。
//
// 用自带的容错解析而不是网关的 auth.Parse:启动器要做成独立 exe,不能和网关
// 内部包绑死(详见 cred.go 的说明)。解析不出来就退化成纯字节比较,不是错误。
func annotateAuth(st *FileState, raw []byte) {
c, ok := parseCredential(raw)
if !ok {
return
}
st.IsAuth = true
st.UID = c.UID
st.Nickname = c.Nickname
st.ExpiresAt = c.ExpiresAt
st.HasRefresh = c.HasRefresh
st.Realm = c.Realm
}
// readTopLevelJSON 返回 dir 下顶层的 *.json 文件名(升序,结果稳定)。
// dir 不存在返回空集而非错误——本地首次运行时 auths/ 还没建。
func readTopLevelJSON(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, ent := range entries {
if ent.IsDir() || !strings.HasSuffix(strings.ToLower(ent.Name()), ".json") {
continue
}
out = append(out, ent.Name())
}
sort.Strings(out)
return out, nil
}
// ---------------------------------------------------------------------------
// 写入
// ---------------------------------------------------------------------------
// writeLocalAtomic 原子写入本地文件(tmp + rename),与网关自身的落盘约定一致。
//
// 权限 0600:auths/ 里是明文 refreshToken,不能给组/其他用户读。
func writeLocalAtomic(fp string, content []byte, mtime time.Time) error {
dir := filepath.Dir(fp)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", dir, err)
}
tmp := fp + ".tmp"
if err := os.WriteFile(tmp, content, 0o600); err != nil {
return fmt.Errorf("写入 %s 失败: %w", tmp, err)
}
if err := os.Rename(tmp, fp); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("替换 %s 失败: %w", fp, err)
}
// 保留远端 mtime:下一轮比较靠它判断谁更新,丢了会导致对端反复重传。
if !mtime.IsZero() {
_ = os.Chtimes(fp, mtime, mtime)
}
return nil
}
// localPath 把桶内相对路径映射为本地绝对路径。
func (e *Engine) localPath(relPath string) (string, error) {
clean := path.Clean("/" + strings.ReplaceAll(relPath, `\`, "/"))
switch {
case strings.HasPrefix(clean, "/auths/"):
name := path.Base(clean)
if name == "" || name == "." || name == "/" {
return "", fmt.Errorf("非法 auths 路径: %q", relPath)
}
return filepath.Join(e.cfg.AuthDir, name), nil
case strings.HasPrefix(clean, "/data/"):
name := path.Base(clean)
if name == "" || name == "." || name == "/" {
return "", fmt.Errorf("非法 data 路径: %q", relPath)
}
return filepath.Join(e.cfg.DataDir, name), nil
default:
return "", fmt.Errorf("同步路径不在白名单内: %q(只允许 auths/ 与 data/ 顶层 json)", relPath)
}
}
// PathAllowed 报告一个桶内相对路径是否属于同步白名单。
// 面板接口与本地写入都过这一关,杜绝 ../ 穿越与任意路径写盘。
func PathAllowed(relPath string, includeData []string) bool {
p := strings.ReplaceAll(relPath, `\`, "/")
if strings.Contains(p, "..") {
return false
}
clean := path.Clean("/" + p)
if strings.HasPrefix(clean, "/auths/") {
name := path.Base(clean)
return name != "." && name != "/" && strings.HasSuffix(strings.ToLower(name), ".json") &&
path.Dir(clean) == "/auths"
}
if strings.HasPrefix(clean, "/data/") {
if path.Dir(clean) != "/data" {
return false
}
name := path.Base(clean)
for _, allowed := range includeData {
if name == allowed {
return true
}
}
return false
}
return false
}