Files
webui4frpc/internal/install/install.go
jianf c1936887a2 webui4frpc: 独立可用的可视化 frpc 控制器 (M0)
- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus)
- 画布多对多连线,渲染 tcp/udp/http/https frpc 配置
- worker 进程管理:自愈、日志轮转、崩溃退避重启
- frpc 一键安装 (GitHub Releases) + 手动指定路径
- 三页 UI:状态(默认)/连接配置/设置
- 状态页实时节点/转发状态,节点可增删改启停
- 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存
- backend 单测覆盖 store/render/process/httpapi/install
- plan.md + FRPC_FEATURES_AUDIT.md 文档
2026-08-17 11:00:26 +08:00

204 lines
4.9 KiB
Go

// 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
}