mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
- 零 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 文档
204 lines
4.9 KiB
Go
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
|
|
}
|