// Package install downloads official frpc binaries from GitHub releases. package install import ( "archive/tar" "compress/gzip" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) const ( releasesBase = "https://github.com/fatedier/frp/releases/download" // maxArchiveFileSize caps a single extracted file against decompression bombs. maxArchiveFileSize = 256 << 20 // 256 MiB ) // latestAPI is a var so tests can point it at a local server. var latestAPI = "https://api.github.com/repos/fatedier/frp/releases/latest" // Install downloads the frpc release for the requested version (empty = latest) // into binDir, verifies it runs, and returns the path and installed version. func Install(ctx context.Context, binDir, version string) (path, installedVersion string, err error) { if version == "" { version, err = latestVersion() if err != nil { return "", "", fmt.Errorf("resolve latest version: %w", err) } } version = strings.TrimPrefix(version, "v") platform := platformName() pkgName := fmt.Sprintf("frp_%s_%s.tar.gz", version, platform) url := fmt.Sprintf("%s/v%s/%s", releasesBase, version, pkgName) if err := os.MkdirAll(binDir, 0o755); err != nil { return "", "", err } tmpArchive := filepath.Join(binDir, pkgName+".download") if err := download(ctx, url, tmpArchive); err != nil { return "", "", fmt.Errorf("download %s: %w", url, err) } defer os.Remove(tmpArchive) extractDir := filepath.Join(binDir, "frpc-"+version) if err := os.RemoveAll(extractDir); err != nil { return "", "", err } if err := extractTarGz(tmpArchive, extractDir); err != nil { return "", "", fmt.Errorf("extract: %w", err) } // Locate the frpc executable in the package's top-level directory. src, err := findFrpc(extractDir) if err != nil { _ = os.RemoveAll(extractDir) return "", "", err } dst := filepath.Join(extractDir, "frpc") if err := os.Rename(src, dst); err != nil { _ = os.RemoveAll(extractDir) return "", "", err } _ = os.Chmod(dst, 0o755) if out, err := exec.Command(dst, "--version").Output(); err != nil { _ = os.RemoveAll(extractDir) return "", "", fmt.Errorf("downloaded binary not runnable: %w", err) } else if ver := strings.TrimSpace(string(out)); ver != "" { version = ver } return dst, version, nil } // latestVersion returns the newest release tag from the GitHub API. func latestVersion() (string, error) { c := &http.Client{Timeout: 30 * time.Second} resp, err := c.Get(latestAPI) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("unexpected status %d", resp.StatusCode) } var rel struct { TagName string `json:"tag_name"` } if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { return "", err } return rel.TagName, nil } func download(ctx context.Context, url, path string) error { c := &http.Client{Timeout: 90 * time.Second} req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := c.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, resp.Body) return fmt.Errorf("unexpected status %d", resp.StatusCode) } f, err := os.Create(path) if err != nil { return err } defer f.Close() _, err = io.Copy(f, resp.Body) return err } func platformName() string { arch := runtime.GOARCH if arch == "x86_64" { arch = "amd64" } return runtime.GOOS + "_" + arch } func extractTarGz(archive, dest string) error { f, err := os.Open(archive) if err != nil { return err } defer f.Close() gz, err := gzip.NewReader(f) if err != nil { return err } defer gz.Close() tr := tar.NewReader(gz) for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return err } name := filepath.Clean(hdr.Name) if filepath.IsAbs(name) || strings.HasPrefix(name, "..") { continue // path traversal guard } target := filepath.Join(dest, name) switch hdr.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, 0o755); err != nil { return err } case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)) if err != nil { return err } if _, err := io.Copy(out, io.LimitReader(tr, maxArchiveFileSize)); err != nil { _ = out.Close() return err } _ = out.Close() } } return nil } func findFrpc(dir string) (string, error) { var found string err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() && d.Name() == "frpc" { found = path return io.EOF } return nil }) if err != nil && !errors.Is(err, io.EOF) { return "", err } if found == "" { return "", fmt.Errorf("no frpc file under %s", dir) } return found, nil }