mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-21 01:17:56 +00:00
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 文档
This commit is contained in:
203
internal/install/install.go
Normal file
203
internal/install/install.go
Normal file
@ -0,0 +1,203 @@
|
||||
// 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
|
||||
}
|
||||
33
internal/install/install_test.go
Normal file
33
internal/install/install_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLatestVersion(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v0.71.0"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
old := latestAPI
|
||||
latestAPI = ts.URL
|
||||
defer func() { latestAPI = old }()
|
||||
|
||||
v, err := latestVersion()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v != "v0.71.0" {
|
||||
t.Fatalf("version = %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformName(t *testing.T) {
|
||||
p := platformName()
|
||||
if p == "" || len(p) < 5 {
|
||||
t.Fatalf("platform = %q", p)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user