From 5ae932f5db5d5a0aa4d3e35105253f6cf9720a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scout=E5=BC=80=E5=8F=91=E8=80=85?= Date: Sun, 30 Aug 2026 21:45:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9B=E5=BB=BAGo=E5=8C=85=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/scout-client/main.go | 222 +++++++++++++++++++++++++ cmd/scout-server/main.go | 35 ++++ go.mod | 3 + internal/httpapi/server.go | 187 +++++++++++++++++++++ internal/registry/registry.go | 295 ++++++++++++++++++++++++++++++++++ 5 files changed, 742 insertions(+) create mode 100644 cmd/scout-client/main.go create mode 100644 cmd/scout-server/main.go create mode 100644 go.mod create mode 100644 internal/httpapi/server.go create mode 100644 internal/registry/registry.go diff --git a/cmd/scout-client/main.go b/cmd/scout-client/main.go new file mode 100644 index 0000000..d4308f9 --- /dev/null +++ b/cmd/scout-client/main.go @@ -0,0 +1,222 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "scout/internal/registry" +) + +type packageInfo struct { + Name string `json:"name"` + Version string `json:"version"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type updateEvent struct { + Package packageInfo `json:"package"` +} + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(2) + } + switch os.Args[1] { + case "check": + checkCommand(os.Args[2:]) + case "download": + downloadCommand(os.Args[2:]) + case "watch": + watchCommand(os.Args[2:]) + default: + printUsage() + os.Exit(2) + } +} + +func checkCommand(args []string) { + flags := flag.NewFlagSet("check", flag.ExitOnError) + server := flags.String("server", "http://localhost:8080", "服务端地址") + name := flags.String("package", "", "包名") + current := flags.String("current", "", "当前版本") + flags.Parse(args) + requirePackage(*name) + var latest packageInfo + requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest) + if registry.CompareVersions(latest.Version, *current) > 0 { + fmt.Printf("发现更新:%s %s -> %s\n", latest.Name, *current, latest.Version) + return + } + fmt.Printf("当前已是最新版本:%s\n", latest.Version) +} + +func downloadCommand(args []string) { + flags := flag.NewFlagSet("download", flag.ExitOnError) + server := flags.String("server", "http://localhost:8080", "服务端地址") + name := flags.String("package", "", "包名") + version := flags.String("version", "", "版本;为空时下载最新版本") + output := flags.String("output", "", "保存路径") + flags.Parse(args) + requirePackage(*name) + if *version == "" { + var latest packageInfo + requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest) + *version = latest.Version + } + if *output == "" { + *output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, *version)) + } + url := fmt.Sprintf("%s/api/v1/packages/%s/%s/download", strings.TrimRight(*server, "/"), *name, *version) + response, err := http.Get(url) + if err != nil { + fatal("请求下载失败: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + fatalResponse(response) + } + file, err := os.Create(*output) + if err != nil { + fatal("创建输出文件失败: %v", err) + } + _, copyErr := io.Copy(file, response.Body) + closeErr := file.Close() + if copyErr != nil || closeErr != nil { + fatal("保存包文件失败") + } + digest, err := fileSHA256(*output) + if err != nil { + fatal("校验包文件失败: %v", err) + } + if expected := response.Header.Get("X-Scout-SHA256"); expected != "" && digest != expected { + _ = os.Remove(*output) + fatal("包文件校验失败,文件已删除") + } + fmt.Printf("下载完成:%s\nSHA-256:%s\n", *output, digest) +} + +func watchCommand(args []string) { + flags := flag.NewFlagSet("watch", flag.ExitOnError) + server := flags.String("server", "http://localhost:8080", "服务端地址") + packages := flags.String("packages", "", "要监听的包名,多个包用逗号分隔") + flags.Parse(args) + url := strings.TrimRight(*server, "/") + "/api/v1/updates/stream" + if *packages != "" { + url += "?packages=" + *packages + } + response, err := http.Get(url) + if err != nil { + fatal("连接更新通知失败: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + fatalResponse(response) + } + fmt.Println("已连接更新通知,等待新包……") + readSSE(response.Body) +} + +func requestJSON(method, url string, body io.Reader, token string, target any) { + request, err := http.NewRequest(method, url, body) + if err != nil { + fatal("创建请求失败: %v", err) + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + fatal("请求失败: %v", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + fatalResponse(response) + } + if err := json.NewDecoder(response.Body).Decode(target); err != nil { + fatal("解析服务端响应失败: %v", err) + } +} + +func readSSE(reader io.Reader) { + dataLines := make([]string, 0, 1) + buffer := make([]byte, 4096) + pending := "" + for { + count, err := reader.Read(buffer) + if count > 0 { + pending += string(buffer[:count]) + for { + lineEnd := strings.IndexByte(pending, '\n') + if lineEnd < 0 { + break + } + line := strings.TrimSuffix(pending[:lineEnd], "\r") + pending = pending[lineEnd+1:] + if line == "" { + if len(dataLines) > 0 { + var event updateEvent + if json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &event) == nil { + fmt.Printf("收到更新:%s %s\n", event.Package.Name, event.Package.Version) + } + } + dataLines = dataLines[:0] + continue + } + if strings.HasPrefix(line, "data: ") { + dataLines = append(dataLines, strings.TrimPrefix(line, "data: ")) + } + } + } + if err != nil { + if !errors.Is(err, io.EOF) { + fatal("读取更新通知失败: %v", err) + } + return + } + } +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func fatalResponse(response *http.Response) { + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + fatal("服务端返回 %s:%s", response.Status, strings.TrimSpace(string(body))) +} + +func requirePackage(name string) { + if name == "" { + fatal("必须指定 --package") + } +} + +func fatal(format string, args ...any) { + fmt.Printf("错误:"+format+"\n", args...) + os.Exit(1) +} + +func printUsage() { + fmt.Println("Scout 轻量化包管理客户端") + fmt.Println("用法:scout-client [参数]") +} diff --git a/cmd/scout-server/main.go b/cmd/scout-server/main.go new file mode 100644 index 0000000..eb5e703 --- /dev/null +++ b/cmd/scout-server/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + "net/http" + "os" + + "scout/internal/httpapi" + "scout/internal/registry" +) + +func main() { + address := envOr("SCOUT_ADDR", ":8080") + dataDir := envOr("SCOUT_DATA_DIR", "data") + token := os.Getenv("SCOUT_DEVELOPER_TOKEN") + if token == "" { + log.Fatal("请设置 SCOUT_DEVELOPER_TOKEN 后再启动服务端") + } + store, err := registry.New(dataDir) + if err != nil { + log.Fatal(err) + } + server := &httpapi.Server{Registry: store, DeveloperToken: token} + log.Printf("Scout 包服务端正在监听 %s,数据目录为 %s", address, dataDir) + if err := http.ListenAndServe(address, server.Handler()); err != nil { + log.Fatal(err) + } +} + +func envOr(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..555304d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module scout + +go 1.26 diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..ed0a688 --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,187 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "scout/internal/registry" +) + +type Server struct { + Registry *registry.Registry + DeveloperToken string + MaxUploadBytes int64 +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.health) + mux.HandleFunc("POST /api/v1/packages/upload", s.upload) + mux.HandleFunc("GET /api/v1/updates/stream", s.stream) + mux.HandleFunc("GET /api/v1/packages/", s.packageRoute) + return logging(mux) +} + +func (s *Server) health(writer http.ResponseWriter, _ *http.Request) { + writeJSON(writer, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) upload(writer http.ResponseWriter, request *http.Request) { + if !s.authorized(request) { + writeError(writer, http.StatusUnauthorized, "开发者令牌无效") + return + } + maxBytes := s.MaxUploadBytes + if maxBytes <= 0 { + maxBytes = 512 << 20 + } + request.Body = http.MaxBytesReader(writer, request.Body, maxBytes) + if err := request.ParseMultipartForm(32 << 20); err != nil { + writeError(writer, http.StatusBadRequest, "上传表单无效: "+err.Error()) + return + } + file, _, err := request.FormFile("file") + if err != nil { + writeError(writer, http.StatusBadRequest, "缺少包文件字段 file") + return + } + defer file.Close() + + item, err := s.Registry.AddPackage(registry.Package{ + Name: request.FormValue("name"), + Version: request.FormValue("version"), + Description: request.FormValue("description"), + Platform: request.FormValue("platform"), + Architecture: request.FormValue("architecture"), + }, file) + if err != nil { + status := http.StatusBadRequest + if strings.Contains(err.Error(), "已经存在") { + status = http.StatusConflict + } + writeError(writer, status, err.Error()) + return + } + writeJSON(writer, http.StatusCreated, item) +} + +func (s *Server) packageRoute(writer http.ResponseWriter, request *http.Request) { + path := strings.TrimPrefix(request.URL.Path, "/api/v1/packages/") + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 1 && parts[0] != "" { + item, ok := s.Registry.Latest(parts[0]) + if !ok { + writeError(writer, http.StatusNotFound, "包不存在") + return + } + writeJSON(writer, http.StatusOK, item) + return + } + if len(parts) == 2 && parts[0] != "" { + if parts[1] == "versions" { + writeJSON(writer, http.StatusOK, map[string]any{"name": parts[0], "versions": s.Registry.Versions(parts[0])}) + return + } + writeError(writer, http.StatusNotFound, "接口不存在") + return + } + if len(parts) == 3 && parts[2] == "download" { + s.download(writer, request, parts[0], parts[1]) + return + } + writeError(writer, http.StatusNotFound, "接口不存在") +} + +func (s *Server) download(writer http.ResponseWriter, request *http.Request, name, version string) { + file, item, err := s.Registry.OpenPackage(name, version) + if err != nil { + writeError(writer, http.StatusNotFound, "包版本不存在") + return + } + defer file.Close() + writer.Header().Set("Content-Type", "application/octet-stream") + writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%s.pkg"`, name, version)) + writer.Header().Set("X-Scout-SHA256", item.SHA256) + writer.Header().Set("Content-Length", strconv.FormatInt(item.Size, 10)) + if request.Method == http.MethodHead { + return + } + if _, err := io.Copy(writer, file); err != nil { + return + } +} + +func (s *Server) stream(writer http.ResponseWriter, request *http.Request) { + flusher, ok := writer.(http.Flusher) + if !ok { + writeError(writer, http.StatusInternalServerError, "当前服务器不支持实时通知") + return + } + wanted := make(map[string]bool) + for _, name := range strings.Split(request.URL.Query().Get("packages"), ",") { + if name = strings.TrimSpace(name); name != "" { + wanted[name] = true + } + } + updates, unsubscribe := s.Registry.Subscribe() + defer unsubscribe() + writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + writer.Header().Set("Cache-Control", "no-cache") + writer.Header().Set("Connection", "keep-alive") + fmt.Fprint(writer, ": 已连接\n\n") + flusher.Flush() + for { + select { + case <-request.Context().Done(): + return + case update, ok := <-updates: + if !ok { + return + } + if len(wanted) > 0 && !wanted[update.Package.Name] { + continue + } + payload, _ := json.Marshal(update) + fmt.Fprintf(writer, "event: package_updated\ndata: %s\n\n", payload) + flusher.Flush() + } + } +} + +func (s *Server) authorized(request *http.Request) bool { + if s.DeveloperToken == "" { + return false + } + value := request.Header.Get("Authorization") + if strings.HasPrefix(value, "Bearer ") { + return strings.TrimPrefix(value, "Bearer ") == s.DeveloperToken + } + return request.Header.Get("X-Developer-Token") == s.DeveloperToken +} + +func writeJSON(writer http.ResponseWriter, status int, value any) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(value) +} + +func writeError(writer http.ResponseWriter, status int, message string) { + writeJSON(writer, status, map[string]string{"error": message}) +} + +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodOptions { + writer.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(writer, request) + }) +} + +var _ = errors.New diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..fd7fce1 --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,295 @@ +package registry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +var ( + packageNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + versionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$`) +) + +type Package struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Platform string `json:"platform,omitempty"` + Architecture string `json:"architecture,omitempty"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + UploadedAt time.Time `json:"uploaded_at"` +} + +type Update struct { + Package Package `json:"package"` +} + +type Registry struct { + root string + mu sync.RWMutex + packages map[string]map[string]Package + subs map[int]chan Update + nextSub int +} + +func New(root string) (*Registry, error) { + if root == "" { + return nil, errors.New("存储目录不能为空") + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("创建存储目录失败: %w", err) + } + registry := &Registry{ + root: root, + packages: make(map[string]map[string]Package), + subs: make(map[int]chan Update), + } + if err := registry.load(); err != nil { + return nil, err + } + return registry, nil +} + +func ValidatePackage(name, version string) error { + if !packageNamePattern.MatchString(name) { + return errors.New("包名只能包含字母、数字、点、下划线和短横线,长度为 1 到 64") + } + if !versionPattern.MatchString(version) { + return errors.New("版本只能包含字母、数字、点、加号、下划线和短横线,长度为 1 到 64") + } + return nil +} + +func (r *Registry) AddPackage(input Package, data io.Reader) (Package, error) { + if err := ValidatePackage(input.Name, input.Version); err != nil { + return Package{}, err + } + if data == nil { + return Package{}, errors.New("包文件不能为空") + } + + packageDir := filepath.Join(r.root, input.Name) + if err := os.MkdirAll(packageDir, 0o755); err != nil { + return Package{}, fmt.Errorf("创建包目录失败: %w", err) + } + temp, err := os.CreateTemp(packageDir, ".upload-*") + if err != nil { + return Package{}, fmt.Errorf("创建临时文件失败: %w", err) + } + tempName := temp.Name() + defer os.Remove(tempName) + + hash := sha256.New() + size, err := io.Copy(io.MultiWriter(temp, hash), data) + if err != nil { + temp.Close() + return Package{}, fmt.Errorf("写入包文件失败: %w", err) + } + if err := temp.Close(); err != nil { + return Package{}, fmt.Errorf("关闭包文件失败: %w", err) + } + + input.Size = size + input.SHA256 = hex.EncodeToString(hash.Sum(nil)) + input.UploadedAt = time.Now().UTC() + + versionDir := filepath.Join(packageDir, input.Version) + if err := os.MkdirAll(versionDir, 0o755); err != nil { + return Package{}, fmt.Errorf("创建版本目录失败: %w", err) + } + packagePath := filepath.Join(versionDir, "package.bin") + metadataPath := filepath.Join(versionDir, "metadata.json") + if _, err := os.Stat(packagePath); err == nil { + return Package{}, errors.New("该包版本已经存在") + } else if !os.IsNotExist(err) { + return Package{}, fmt.Errorf("检查包版本失败: %w", err) + } + if err := os.Rename(tempName, packagePath); err != nil { + return Package{}, fmt.Errorf("保存包文件失败: %w", err) + } + metadata, err := json.MarshalIndent(input, "", " ") + if err != nil { + return Package{}, fmt.Errorf("编码包元数据失败: %w", err) + } + if err := os.WriteFile(metadataPath, metadata, 0o644); err != nil { + os.Remove(packagePath) + return Package{}, fmt.Errorf("保存包元数据失败: %w", err) + } + + r.mu.Lock() + if r.packages[input.Name] == nil { + r.packages[input.Name] = make(map[string]Package) + } + r.packages[input.Name][input.Version] = input + subs := make([]chan Update, 0, len(r.subs)) + for _, subscriber := range r.subs { + subs = append(subs, subscriber) + } + r.mu.Unlock() + + update := Update{Package: input} + for _, subscriber := range subs { + select { + case subscriber <- update: + default: + } + } + return input, nil +} + +func (r *Registry) Latest(name string) (Package, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + versions := r.packages[name] + if len(versions) == 0 { + return Package{}, false + } + keys := make([]string, 0, len(versions)) + for version := range versions { + keys = append(keys, version) + } + sort.Slice(keys, func(i, j int) bool { return CompareVersions(keys[i], keys[j]) > 0 }) + return versions[keys[0]], true +} + +func (r *Registry) Versions(name string) []Package { + r.mu.RLock() + defer r.mu.RUnlock() + versions := r.packages[name] + result := make([]Package, 0, len(versions)) + for _, item := range versions { + result = append(result, item) + } + sort.Slice(result, func(i, j int) bool { return CompareVersions(result[i].Version, result[j].Version) > 0 }) + return result +} + +func (r *Registry) Get(name, version string) (Package, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + item, ok := r.packages[name][version] + return item, ok +} + +func (r *Registry) OpenPackage(name, version string) (*os.File, Package, error) { + item, ok := r.Get(name, version) + if !ok { + return nil, Package{}, os.ErrNotExist + } + file, err := os.Open(filepath.Join(r.root, name, version, "package.bin")) + if err != nil { + return nil, Package{}, err + } + return file, item, nil +} + +func (r *Registry) Subscribe() (<-chan Update, func()) { + r.mu.Lock() + defer r.mu.Unlock() + id := r.nextSub + r.nextSub++ + channel := make(chan Update, 16) + r.subs[id] = channel + return channel, func() { + r.mu.Lock() + defer r.mu.Unlock() + if existing, ok := r.subs[id]; ok { + delete(r.subs, id) + close(existing) + } + } +} + +func (r *Registry) load() error { + entries, err := os.ReadDir(r.root) + if err != nil { + return fmt.Errorf("读取存储目录失败: %w", err) + } + for _, packageEntry := range entries { + if !packageEntry.IsDir() || !packageNamePattern.MatchString(packageEntry.Name()) { + continue + } + versions, err := os.ReadDir(filepath.Join(r.root, packageEntry.Name())) + if err != nil { + return fmt.Errorf("读取包目录失败: %w", err) + } + for _, versionEntry := range versions { + if !versionEntry.IsDir() || !versionPattern.MatchString(versionEntry.Name()) { + continue + } + metadataPath := filepath.Join(r.root, packageEntry.Name(), versionEntry.Name(), "metadata.json") + metadata, err := os.ReadFile(metadataPath) + if err != nil { + continue + } + var item Package + if json.Unmarshal(metadata, &item) == nil { + if r.packages[item.Name] == nil { + r.packages[item.Name] = make(map[string]Package) + } + r.packages[item.Name][item.Version] = item + } + } + } + return nil +} + +func CompareVersions(left, right string) int { + leftParts := splitVersion(left) + rightParts := splitVersion(right) + for index := 0; index < len(leftParts) || index < len(rightParts); index++ { + if index >= len(leftParts) { + return -1 + } + if index >= len(rightParts) { + return 1 + } + leftNumber, leftOK := numericPart(leftParts[index]) + rightNumber, rightOK := numericPart(rightParts[index]) + if leftOK && rightOK { + if leftNumber != rightNumber { + if leftNumber > rightNumber { + return 1 + } + return -1 + } + continue + } + if leftParts[index] != rightParts[index] { + return strings.Compare(leftParts[index], rightParts[index]) + } + } + return 0 +} + +func splitVersion(version string) []string { + version = strings.TrimPrefix(version, "v") + version = strings.SplitN(version, "+", 2)[0] + return strings.FieldsFunc(version, func(r rune) bool { return r == '.' || r == '-' || r == '_' }) +} + +func numericPart(value string) (int64, bool) { + var number int64 + if value == "" { + return 0, false + } + for _, char := range value { + if char < '0' || char > '9' { + return 0, false + } + number = number*10 + int64(char-'0') + } + return number, true +}