feat: Add VPN tool management functionality

Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
luckfox-eng29
2026-05-07 09:56:50 +08:00
parent 95f2b6bada
commit 7cef8baa0d
4 changed files with 1270 additions and 7 deletions

View File

@@ -1649,6 +1649,8 @@ var rpcHandlers = map[string]RPCHandler{
"getVpnToolStatus": {Func: rpcGetVpnToolStatus, Params: []string{"tool"}}, "getVpnToolStatus": {Func: rpcGetVpnToolStatus, Params: []string{"tool"}},
"listVpnToolReleases": {Func: rpcListVpnToolReleases, Params: []string{"tool"}}, "listVpnToolReleases": {Func: rpcListVpnToolReleases, Params: []string{"tool"}},
"installVpnTool": {Func: rpcInstallVpnTool, Params: []string{"tool", "version", "assetName", "downloadURL"}}, "installVpnTool": {Func: rpcInstallVpnTool, Params: []string{"tool", "version", "assetName", "downloadURL"}},
"startVpnToolInstall": {Func: rpcStartVpnToolInstall, Params: []string{"tool", "version", "assetName", "downloadURL"}},
"getVpnToolInstallTask": {Func: rpcGetVpnToolInstallTask, Params: []string{"tool"}},
"useVpnToolVersion": {Func: rpcUseVpnToolVersion, Params: []string{"tool", "version"}}, "useVpnToolVersion": {Func: rpcUseVpnToolVersion, Params: []string{"tool", "version"}},
"uninstallVpnToolVersion": {Func: rpcUninstallVpnToolVersion, Params: []string{"tool", "version"}}, "uninstallVpnToolVersion": {Func: rpcUninstallVpnToolVersion, Params: []string{"tool", "version"}},
"getStreamEncodecType": {Func: rpcGetStreamEncodecType}, "getStreamEncodecType": {Func: rpcGetStreamEncodecType},

906
tools.go Normal file
View File

@@ -0,0 +1,906 @@
package kvm
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
)
const vpnToolsRoot = "/userdata/vpn-tools"
const vntPinnedVersion = "v1.2.16"
type vpnToolSpec struct {
Name string
Repo string
Binaries []string
VersionBinary string
VersionFlags [][]string
}
var vpnToolSpecs = map[string]vpnToolSpec{
"frpc": {
Name: "frpc",
Repo: "fatedier/frp",
Binaries: []string{"frpc"},
VersionBinary: "frpc",
VersionFlags: [][]string{{"-v"}, {"--version"}, {"version"}},
},
"easytier": {
Name: "easytier",
Repo: "EasyTier/EasyTier",
Binaries: []string{"easytier-core", "easytier-cli"},
VersionBinary: "easytier-cli",
VersionFlags: [][]string{{"--version"}, {"-V"}, {"version"}},
},
"vnt": {
Name: "vnt",
Repo: "vnt-dev/vnt",
Binaries: []string{"vnt-cli"},
VersionBinary: "vnt-cli",
VersionFlags: [][]string{{}, {"--version"}, {"-V"}, {"version"}},
},
"cloudflared": {
Name: "cloudflared",
Repo: "cloudflare/cloudflared",
Binaries: []string{"cloudflared"},
VersionBinary: "cloudflared",
VersionFlags: [][]string{{"-v"}, {"version"}, {"--version"}},
},
}
type VpnToolSystemInfo struct {
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
UnameArch string `json:"uname_arch"`
ArchLabel string `json:"arch_label"`
ArchKeywords []string `json:"arch_keywords"`
}
type VpnToolStatus struct {
Tool string `json:"tool"`
Installed bool `json:"installed"`
Source string `json:"source"`
CurrentVersion string `json:"current_version"`
DetectedVersion string `json:"detected_version"`
ManagedVersions []string `json:"managed_versions"`
}
type VpnToolReleaseAsset struct {
Name string `json:"name"`
URL string `json:"url"`
ArchMatch bool `json:"arch_match"`
}
type VpnToolRelease struct {
TagName string `json:"tag_name"`
Assets []VpnToolReleaseAsset `json:"assets"`
}
type githubReleaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type githubRelease struct {
TagName string `json:"tag_name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Assets []githubReleaseAsset `json:"assets"`
}
type VpnToolInstallTask struct {
Tool string `json:"tool"`
Running bool `json:"running"`
Progress float64 `json:"progress"`
Message string `json:"message"`
Logs []string `json:"logs"`
Error string `json:"error"`
Version string `json:"version"`
UpdatedAt int64 `json:"updated_at"`
}
var (
vpnToolInstallTaskMu sync.Mutex
vpnToolInstallTasks = map[string]*VpnToolInstallTask{}
)
func getVpnToolSpec(tool string) (vpnToolSpec, error) {
spec, ok := vpnToolSpecs[strings.ToLower(strings.TrimSpace(tool))]
if !ok {
return vpnToolSpec{}, fmt.Errorf("unsupported vpn tool: %s", tool)
}
return spec, nil
}
func normalizeArchLabel(arch string) string {
switch strings.ToLower(strings.TrimSpace(arch)) {
case "x86_64", "amd64":
return "amd64"
case "aarch64", "arm64":
return "arm64"
case "armv7l", "armv7", "armhf":
return "armv7"
case "armv6l", "armv6":
return "armv6"
case "i386", "i686", "386", "x86":
return "386"
default:
return strings.ToLower(strings.TrimSpace(arch))
}
}
func archKeywords(archLabel string) []string {
switch archLabel {
case "amd64":
return []string{"amd64", "x86_64", "x64"}
case "arm64":
return []string{"arm64", "aarch64"}
case "armv7":
return []string{"armv7", "armv7l", "armhf", "arm"}
case "armv6":
return []string{"armv6", "armv6l", "arm"}
case "386":
return []string{"386", "i386", "x86"}
default:
if archLabel == "" {
return []string{}
}
return []string{archLabel}
}
}
func rpcGetVpnToolSystemInfo() (VpnToolSystemInfo, error) {
unameArch := runtime.GOARCH
if out, err := exec.Command("uname", "-m").Output(); err == nil {
unameArch = strings.TrimSpace(string(out))
}
archLabel := normalizeArchLabel(unameArch)
if archLabel == "" {
archLabel = normalizeArchLabel(runtime.GOARCH)
}
return VpnToolSystemInfo{
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
UnameArch: unameArch,
ArchLabel: archLabel,
ArchKeywords: archKeywords(archLabel),
}, nil
}
func vpnToolDir(spec vpnToolSpec) string {
return filepath.Join(vpnToolsRoot, spec.Name)
}
func vpnToolVersionsDir(spec vpnToolSpec) string {
return filepath.Join(vpnToolDir(spec), "versions")
}
func vpnToolCurrentDir(spec vpnToolSpec) string {
return filepath.Join(vpnToolDir(spec), "current")
}
func managedBinaryPath(spec vpnToolSpec, binaryName string) string {
return filepath.Join(vpnToolCurrentDir(spec), binaryName)
}
func findExecutablePath(binary string) (string, error) {
if strings.Contains(binary, "/") {
info, err := os.Stat(binary)
if err != nil {
return "", err
}
if info.Mode().IsRegular() || (info.Mode()&os.ModeSymlink) != 0 {
return binary, nil
}
return "", fmt.Errorf("not executable file: %s", binary)
}
return exec.LookPath(binary)
}
func resolveVpnToolBinary(tool, defaultBinary string) string {
spec, err := getVpnToolSpec(tool)
if err != nil {
return defaultBinary
}
managed := managedBinaryPath(spec, defaultBinary)
if _, err := os.Stat(managed); err == nil {
return managed
}
return defaultBinary
}
func detectCommandVersion(binaryPath string, flags [][]string) string {
for _, args := range flags {
cmd := exec.Command(binaryPath, args...)
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
continue
}
line := extractVersionLine(string(out))
if line != "" {
return line
}
}
return ""
}
func extractVersionLine(output string) string {
normalized := strings.ReplaceAll(output, "\r\n", "\n")
lines := strings.Split(normalized, "\n")
// Prefer explicit version lines to handle tools like vnt-cli
// where the first line is usage text.
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
lower := strings.ToLower(line)
if strings.Contains(lower, "version:") {
return line
}
}
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
lower := strings.ToLower(line)
if strings.Contains(lower, "version") || strings.HasPrefix(lower, "v") {
return line
}
}
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line != "" {
return line
}
}
return ""
}
func listManagedVersions(spec vpnToolSpec) []string {
versionsDir := vpnToolVersionsDir(spec)
entries, err := os.ReadDir(versionsDir)
if err != nil {
return []string{}
}
versions := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
versions = append(versions, e.Name())
}
}
sort.Slice(versions, func(i, j int) bool { return versions[i] > versions[j] })
return versions
}
func currentManagedVersion(spec vpnToolSpec) string {
currentDir := vpnToolCurrentDir(spec)
for _, binary := range spec.Binaries {
p := filepath.Join(currentDir, binary)
target, err := os.Readlink(p)
if err != nil {
continue
}
abs := target
if !filepath.IsAbs(target) {
abs = filepath.Clean(filepath.Join(filepath.Dir(p), target))
}
versionsRoot := vpnToolVersionsDir(spec) + string(os.PathSeparator)
if strings.HasPrefix(abs, versionsRoot) {
rel := strings.TrimPrefix(abs, versionsRoot)
parts := strings.Split(rel, string(os.PathSeparator))
if len(parts) > 0 && parts[0] != "" {
return parts[0]
}
}
}
return ""
}
func initVpnToolInstallTask(tool, version string) {
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
vpnToolInstallTasks[tool] = &VpnToolInstallTask{
Tool: tool,
Running: true,
Progress: 0,
Message: "preparing",
Logs: []string{"Preparing install task..."},
Error: "",
Version: version,
UpdatedAt: time.Now().Unix(),
}
}
func updateVpnToolInstallTask(tool string, progress float64, message string) {
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
task, ok := vpnToolInstallTasks[tool]
if !ok {
return
}
if progress < 0 {
progress = 0
}
if progress > 1 {
progress = 1
}
task.Progress = progress
if strings.TrimSpace(message) != "" {
task.Message = message
}
task.UpdatedAt = time.Now().Unix()
}
func appendVpnToolInstallLog(tool, line string) {
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
task, ok := vpnToolInstallTasks[tool]
if !ok {
return
}
line = strings.TrimSpace(line)
if line == "" {
return
}
task.Logs = append(task.Logs, line)
if len(task.Logs) > 200 {
task.Logs = task.Logs[len(task.Logs)-200:]
}
task.UpdatedAt = time.Now().Unix()
}
func finishVpnToolInstallTask(tool string, err error) {
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
task, ok := vpnToolInstallTasks[tool]
if !ok {
return
}
task.Running = false
task.UpdatedAt = time.Now().Unix()
if err != nil {
task.Error = err.Error()
task.Message = "failed"
task.Logs = append(task.Logs, "Install failed: "+err.Error())
return
}
task.Progress = 1
task.Message = "completed"
task.Error = ""
task.Logs = append(task.Logs, "Install completed.")
}
func rpcGetVpnToolInstallTask(tool string) (VpnToolInstallTask, error) {
spec, err := getVpnToolSpec(tool)
if err != nil {
return VpnToolInstallTask{}, err
}
_ = spec
vpnToolInstallTaskMu.Lock()
defer vpnToolInstallTaskMu.Unlock()
task, ok := vpnToolInstallTasks[tool]
if !ok {
return VpnToolInstallTask{
Tool: tool,
Running: false,
Progress: 0,
Message: "",
Logs: []string{},
Error: "",
Version: "",
UpdatedAt: time.Now().Unix(),
}, nil
}
cp := *task
cp.Logs = append([]string{}, task.Logs...)
return cp, nil
}
func rpcGetVpnToolStatus(tool string) (VpnToolStatus, error) {
spec, err := getVpnToolSpec(tool)
if err != nil {
return VpnToolStatus{}, err
}
status := VpnToolStatus{
Tool: spec.Name,
Installed: false,
Source: "none",
CurrentVersion: currentManagedVersion(spec),
DetectedVersion: "",
ManagedVersions: listManagedVersions(spec),
}
primaryBinary := spec.Binaries[0]
versionBinary := spec.VersionBinary
if strings.TrimSpace(versionBinary) == "" {
versionBinary = primaryBinary
}
managedPrimary := managedBinaryPath(spec, primaryBinary)
managedVersionBinary := managedBinaryPath(spec, versionBinary)
if _, err := os.Stat(managedPrimary); err == nil {
status.Installed = true
status.Source = "managed"
status.DetectedVersion = detectCommandVersion(managedVersionBinary, spec.VersionFlags)
if status.CurrentVersion == "" {
status.CurrentVersion = "managed"
}
return status, nil
}
primaryLookedUp, primaryErr := findExecutablePath(primaryBinary)
if primaryErr == nil {
status.Installed = true
status.Source = "system"
_ = primaryLookedUp
versionLookedUp, versionErr := findExecutablePath(versionBinary)
if versionErr == nil {
status.DetectedVersion = detectCommandVersion(versionLookedUp, spec.VersionFlags)
}
}
return status, nil
}
func rpcListVpnToolReleases(tool string) ([]VpnToolRelease, error) {
spec, err := getVpnToolSpec(tool)
if err != nil {
return nil, err
}
systemInfo, err := rpcGetVpnToolSystemInfo()
if err != nil {
return nil, err
}
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", spec.Repo)
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
apiURL,
nil,
)
if err != nil {
return nil, fmt.Errorf("failed to build release request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "kvm-vpn-tool-manager")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch releases: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("fetch releases failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var ghReleases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil {
return nil, fmt.Errorf("failed to parse release json: %w", err)
}
keywords := systemInfo.ArchKeywords
results := make([]VpnToolRelease, 0, len(ghReleases))
for _, rel := range ghReleases {
if rel.Draft {
continue
}
if spec.Name == "vnt" {
tag := strings.TrimSpace(rel.TagName)
if tag != vntPinnedVersion && tag != strings.TrimPrefix(vntPinnedVersion, "v") {
continue
}
}
releaseItem := VpnToolRelease{
TagName: strings.TrimSpace(rel.TagName),
Assets: make([]VpnToolReleaseAsset, 0, len(rel.Assets)),
}
for _, asset := range rel.Assets {
nameLower := strings.ToLower(strings.TrimSpace(asset.Name))
isLinux := strings.Contains(nameLower, "linux")
archMatch := false
for _, kw := range keywords {
if strings.Contains(nameLower, strings.ToLower(kw)) {
archMatch = true
break
}
}
if !isLinux {
archMatch = false
}
releaseItem.Assets = append(releaseItem.Assets, VpnToolReleaseAsset{
Name: asset.Name,
URL: strings.TrimSpace(asset.BrowserDownloadURL),
ArchMatch: archMatch,
})
}
if releaseItem.TagName != "" {
results = append(results, releaseItem)
}
}
return results, nil
}
func downloadFileToPath(url, targetPath string, onProgress func(downloaded int64, total int64)) error {
if strings.TrimSpace(url) == "" {
return fmt.Errorf("empty download url")
}
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
url,
nil,
)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("User-Agent", "kvm-vpn-tool-manager")
client := &http.Client{Timeout: 2 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
out, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("failed to create target file: %w", err)
}
defer out.Close()
total := resp.ContentLength
var downloaded int64
buf := make([]byte, 64*1024)
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
written, writeErr := out.Write(buf[:n])
if writeErr != nil {
return fmt.Errorf("failed to write target file: %w", writeErr)
}
if written != n {
return fmt.Errorf("short write while saving downloaded file")
}
downloaded += int64(n)
if onProgress != nil {
onProgress(downloaded, total)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return fmt.Errorf("failed to read download stream: %w", readErr)
}
}
return nil
}
func extractFromTarGz(archivePath string, targetBinaryNames []string, outDir string) (map[string]bool, error) {
f, err := os.Open(archivePath)
if err != nil {
return nil, err
}
defer f.Close()
gzReader, err := gzip.NewReader(f)
if err != nil {
return nil, err
}
defer gzReader.Close()
tr := tar.NewReader(gzReader)
need := make(map[string]bool, len(targetBinaryNames))
found := make(map[string]bool, len(targetBinaryNames))
for _, b := range targetBinaryNames {
need[b] = true
found[b] = false
}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return found, err
}
if hdr.Typeflag != tar.TypeReg {
continue
}
base := filepath.Base(hdr.Name)
if !need[base] {
continue
}
dstPath := filepath.Join(outDir, base)
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return found, err
}
if _, err := io.Copy(dst, tr); err != nil {
dst.Close()
return found, err
}
if err := dst.Close(); err != nil {
return found, err
}
found[base] = true
}
return found, nil
}
func extractFromZip(archivePath string, targetBinaryNames []string, outDir string) (map[string]bool, error) {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return nil, err
}
defer zr.Close()
need := make(map[string]bool, len(targetBinaryNames))
found := make(map[string]bool, len(targetBinaryNames))
for _, b := range targetBinaryNames {
need[b] = true
found[b] = false
}
for _, f := range zr.File {
base := filepath.Base(f.Name)
if !need[base] {
continue
}
src, err := f.Open()
if err != nil {
return found, err
}
dstPath := filepath.Join(outDir, base)
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
src.Close()
return found, err
}
if _, err := io.Copy(dst, src); err != nil {
src.Close()
dst.Close()
return found, err
}
src.Close()
if err := dst.Close(); err != nil {
return found, err
}
found[base] = true
}
return found, nil
}
func ensureBinariesInstalled(found map[string]bool, required []string) error {
missing := make([]string, 0)
for _, b := range required {
if !found[b] {
missing = append(missing, b)
}
}
if len(missing) > 0 {
return fmt.Errorf("required binaries are missing in package: %s", strings.Join(missing, ", "))
}
return nil
}
func setupCurrentSymlinks(spec vpnToolSpec, version string) error {
currentDir := vpnToolCurrentDir(spec)
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
if err := os.MkdirAll(currentDir, 0755); err != nil {
return err
}
for _, b := range spec.Binaries {
currentLink := filepath.Join(currentDir, b)
_ = os.Remove(currentLink)
target := filepath.Join(versionDir, b)
if err := os.Symlink(target, currentLink); err != nil {
return fmt.Errorf("failed to update current binary symlink for %s: %w", b, err)
}
}
return nil
}
func rpcInstallVpnTool(tool, version, assetName, downloadURL string) error {
spec, err := getVpnToolSpec(tool)
if err != nil {
return err
}
version = strings.TrimSpace(version)
if version == "" {
return fmt.Errorf("version is required")
}
if spec.Name == "vnt" && version != vntPinnedVersion && version != strings.TrimPrefix(vntPinnedVersion, "v") {
return fmt.Errorf("vnt install is temporarily pinned to %s", vntPinnedVersion)
}
downloadURL = strings.TrimSpace(downloadURL)
if downloadURL == "" {
return fmt.Errorf("downloadURL is required")
}
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
appendVpnToolInstallLog(tool, "Preparing version directory...")
updateVpnToolInstallTask(tool, 0.05, "preparing")
if err := os.MkdirAll(versionDir, 0755); err != nil {
return fmt.Errorf("failed to create version directory: %w", err)
}
tmpFile, err := os.CreateTemp("", "vpn-tool-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpPath)
appendVpnToolInstallLog(tool, "Downloading release package...")
if err := downloadFileToPath(downloadURL, tmpPath, func(downloaded int64, total int64) {
if total > 0 {
ratio := float64(downloaded) / float64(total)
updateVpnToolInstallTask(tool, 0.1+ratio*0.65, "downloading")
return
}
// Unknown total size, keep showing progress moving slowly.
updateVpnToolInstallTask(tool, 0.4, "downloading")
}); err != nil {
return err
}
appendVpnToolInstallLog(tool, "Download completed.")
assetLower := strings.ToLower(strings.TrimSpace(assetName))
if assetLower == "" {
assetLower = strings.ToLower(strings.TrimSpace(downloadURL))
}
found := map[string]bool{}
updateVpnToolInstallTask(tool, 0.8, "extracting")
appendVpnToolInstallLog(tool, "Extracting package...")
switch {
case strings.HasSuffix(assetLower, ".tar.gz") || strings.HasSuffix(assetLower, ".tgz"):
found, err = extractFromTarGz(tmpPath, spec.Binaries, versionDir)
if err != nil {
return fmt.Errorf("failed to extract tar.gz: %w", err)
}
case strings.HasSuffix(assetLower, ".zip"):
found, err = extractFromZip(tmpPath, spec.Binaries, versionDir)
if err != nil {
return fmt.Errorf("failed to extract zip: %w", err)
}
default:
if len(spec.Binaries) != 1 {
return fmt.Errorf("single binary install is not supported for %s", spec.Name)
}
dstPath := filepath.Join(versionDir, spec.Binaries[0])
input, err := os.Open(tmpPath)
if err != nil {
return err
}
defer input.Close()
output, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return err
}
if _, err := io.Copy(output, input); err != nil {
output.Close()
return err
}
if err := output.Close(); err != nil {
return err
}
found[spec.Binaries[0]] = true
}
appendVpnToolInstallLog(tool, "Validating installed binaries...")
updateVpnToolInstallTask(tool, 0.92, "validating")
if err := ensureBinariesInstalled(found, spec.Binaries); err != nil {
return err
}
appendVpnToolInstallLog(tool, "Switching to installed version...")
updateVpnToolInstallTask(tool, 0.97, "activating")
if err := setupCurrentSymlinks(spec, version); err != nil {
return err
}
updateVpnToolInstallTask(tool, 1, "completed")
return nil
}
func rpcStartVpnToolInstall(tool, version, assetName, downloadURL string) error {
_, err := getVpnToolSpec(tool)
if err != nil {
return err
}
vpnToolInstallTaskMu.Lock()
if task, ok := vpnToolInstallTasks[tool]; ok && task.Running {
vpnToolInstallTaskMu.Unlock()
return fmt.Errorf("install task is already running for %s", tool)
}
vpnToolInstallTaskMu.Unlock()
initVpnToolInstallTask(tool, version)
go func() {
err := rpcInstallVpnTool(tool, version, assetName, downloadURL)
finishVpnToolInstallTask(tool, err)
}()
return nil
}
func rpcUseVpnToolVersion(tool, version string) error {
spec, err := getVpnToolSpec(tool)
if err != nil {
return err
}
version = strings.TrimSpace(version)
if version == "" {
return fmt.Errorf("version is required")
}
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
info, err := os.Stat(versionDir)
if err != nil {
return fmt.Errorf("version is not installed: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("invalid version directory")
}
for _, b := range spec.Binaries {
if _, err := os.Stat(filepath.Join(versionDir, b)); err != nil {
return fmt.Errorf("binary %s is missing in version %s", b, version)
}
}
return setupCurrentSymlinks(spec, version)
}
func rpcUninstallVpnToolVersion(tool, version string) error {
spec, err := getVpnToolSpec(tool)
if err != nil {
return err
}
version = strings.TrimSpace(version)
if version == "" {
return fmt.Errorf("version is required")
}
versionDir := filepath.Join(vpnToolVersionsDir(spec), version)
if err := os.RemoveAll(versionDir); err != nil {
return fmt.Errorf("failed to uninstall version: %w", err)
}
if currentManagedVersion(spec) == version {
currentDir := vpnToolCurrentDir(spec)
for _, b := range spec.Binaries {
_ = os.Remove(filepath.Join(currentDir, b))
}
}
remaining := listManagedVersions(spec)
if len(remaining) > 0 {
// Automatically switch to latest remaining managed version.
if err := setupCurrentSymlinks(spec, remaining[0]); err != nil {
return err
}
}
return nil
}

View File

@@ -71,6 +71,47 @@ export interface CloudflaredRunningResponse {
running: boolean; running: boolean;
} }
type ManagedVpnTool = "frpc" | "easytier" | "vnt" | "cloudflared";
export interface VpnToolSystemInfo {
goos: string;
goarch: string;
uname_arch: string;
arch_label: string;
arch_keywords: string[];
}
export interface VpnToolStatus {
tool: string;
installed: boolean;
source: string;
current_version: string;
detected_version: string;
managed_versions: string[];
}
export interface VpnToolReleaseAsset {
name: string;
url: string;
arch_match: boolean;
}
export interface VpnToolRelease {
tag_name: string;
assets: VpnToolReleaseAsset[];
}
export interface VpnToolInstallTask {
tool: string;
running: boolean;
progress: number;
message: string;
logs: string[];
error: string;
version: string;
updated_at: number;
}
export interface WireguardStatus { export interface WireguardStatus {
running: boolean; running: boolean;
} }
@@ -198,6 +239,20 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
const [cloudflaredLog, setCloudflaredLog] = useState<string>(""); const [cloudflaredLog, setCloudflaredLog] = useState<string>("");
const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false); const [showCloudflaredLogModal, setShowCloudflaredLogModal] = useState(false);
const [vpnToolSystemInfo, setVpnToolSystemInfo] = useState<VpnToolSystemInfo | null>(null);
const [vpnToolStatusMap, setVpnToolStatusMap] = useState<Record<string, VpnToolStatus>>({});
const [vpnToolReleasesMap, setVpnToolReleasesMap] = useState<Record<string, VpnToolRelease[]>>({});
const [vpnToolSelectedVersionMap, setVpnToolSelectedVersionMap] = useState<Record<string, string>>({});
const [vpnToolSelectedAssetMap, setVpnToolSelectedAssetMap] = useState<Record<string, string>>({});
const [vpnToolBusyMap, setVpnToolBusyMap] = useState<Record<string, boolean>>({});
const [vpnToolInstallTaskMap, setVpnToolInstallTaskMap] = useState<Record<string, VpnToolInstallTask>>({});
const [vpnToolInstallPanelOpenMap, setVpnToolInstallPanelOpenMap] = useState<Record<string, boolean>>({
frpc: false,
easytier: false,
vnt: false,
cloudflared: false,
});
const getTLSState = useCallback(() => { const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => { send("getTLSState", {}, resp => {
@@ -282,10 +337,156 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
}); });
}, [send]); }, [send]);
const managedTools: ManagedVpnTool[] = ["frpc", "easytier", "vnt", "cloudflared"];
const getVpnToolSystemInfo = useCallback(() => {
send("getVpnToolSystemInfo", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get system architecture info: ${resp.error.data || "Unknown error"}`);
return;
}
setVpnToolSystemInfo(resp.result as VpnToolSystemInfo);
});
}, [send]);
const getVpnToolStatus = useCallback((tool: ManagedVpnTool) => {
send("getVpnToolStatus", { tool }, resp => {
if ("error" in resp) {
notifications.error(`Failed to get ${tool} status: ${resp.error.data || "Unknown error"}`);
return;
}
setVpnToolStatusMap(prev => ({ ...prev, [tool]: resp.result as VpnToolStatus }));
});
}, [send]);
const listVpnToolReleases = useCallback((tool: ManagedVpnTool) => {
send("listVpnToolReleases", { tool }, resp => {
if ("error" in resp) {
notifications.error(`Failed to list ${tool} releases: ${resp.error.data || "Unknown error"}`);
return;
}
const releases = resp.result as VpnToolRelease[];
setVpnToolReleasesMap(prev => ({ ...prev, [tool]: releases }));
if (!releases.length) {
return;
}
setVpnToolSelectedVersionMap(prev => {
if (prev[tool]) return prev;
return { ...prev, [tool]: releases[0].tag_name };
});
setVpnToolSelectedAssetMap(prev => {
if (prev[tool]) return prev;
const firstRelease = releases[0];
const preferred = firstRelease.assets.find(asset => asset.arch_match) || firstRelease.assets[0];
return preferred ? { ...prev, [tool]: preferred.url } : prev;
});
});
}, [send]);
const refreshVpnToolManager = useCallback((tool: ManagedVpnTool, withReleases: boolean) => {
getVpnToolStatus(tool);
if (withReleases) {
listVpnToolReleases(tool);
}
}, [getVpnToolStatus, listVpnToolReleases]);
const getVpnToolInstallTask = useCallback((tool: ManagedVpnTool) => {
send("getVpnToolInstallTask", { tool }, resp => {
if ("error" in resp) {
return;
}
setVpnToolInstallTaskMap(prev => ({ ...prev, [tool]: resp.result as VpnToolInstallTask }));
});
}, [send]);
const handleVpnToolVersionChange = useCallback((tool: ManagedVpnTool, version: string) => {
setVpnToolSelectedVersionMap(prev => ({ ...prev, [tool]: version }));
const releases = vpnToolReleasesMap[tool] || [];
const selectedRelease = releases.find(release => release.tag_name === version);
if (!selectedRelease || !selectedRelease.assets.length) {
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: "" }));
return;
}
const preferred = selectedRelease.assets.find(asset => asset.arch_match) || selectedRelease.assets[0];
setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: preferred?.url || "" }));
}, [vpnToolReleasesMap]);
const handleInstallVpnTool = useCallback((tool: ManagedVpnTool) => {
const version = vpnToolSelectedVersionMap[tool];
const downloadURL = vpnToolSelectedAssetMap[tool];
const releases = vpnToolReleasesMap[tool] || [];
const release = releases.find(item => item.tag_name === version);
const selectedAsset = release?.assets.find(asset => asset.url === downloadURL);
if (!version || !downloadURL || !selectedAsset) {
notifications.error(`Please select version and release asset for ${tool}`);
return;
}
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
send("startVpnToolInstall", { tool, version, assetName: selectedAsset.name, downloadURL }, resp => {
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
if ("error" in resp) {
notifications.error(`Failed to install ${tool}: ${resp.error.data || "Unknown error"}`);
return;
}
notifications.success(`${tool} install task started (${version})`);
getVpnToolInstallTask(tool);
});
}, [send, vpnToolSelectedVersionMap, vpnToolSelectedAssetMap, vpnToolReleasesMap, getVpnToolInstallTask]);
const handleUninstallVpnToolVersion = useCallback((tool: ManagedVpnTool, version: string) => {
if (!version) {
notifications.error("Please select installed version");
return;
}
setVpnToolBusyMap(prev => ({ ...prev, [tool]: true }));
send("uninstallVpnToolVersion", { tool, version }, resp => {
setVpnToolBusyMap(prev => ({ ...prev, [tool]: false }));
if ("error" in resp) {
notifications.error(`Failed to uninstall ${tool} version ${version}: ${resp.error.data || "Unknown error"}`);
return;
}
notifications.success(`${tool} ${version} uninstalled`);
refreshVpnToolManager(tool, true);
});
}, [send, refreshVpnToolManager]);
useEffect(() => { useEffect(() => {
getCloudflaredStatus(); getCloudflaredStatus();
}, [getCloudflaredStatus]); }, [getCloudflaredStatus]);
useEffect(() => {
getVpnToolSystemInfo();
managedTools.forEach(tool => {
refreshVpnToolManager(tool, false);
getVpnToolInstallTask(tool);
});
}, [getVpnToolSystemInfo, refreshVpnToolManager, getVpnToolInstallTask]);
useEffect(() => {
const timer = setInterval(() => {
managedTools.forEach(tool => {
getVpnToolInstallTask(tool);
});
}, 1000);
return () => clearInterval(timer);
}, [getVpnToolInstallTask]);
useEffect(() => {
const tabToolMap: Partial<Record<string, ManagedVpnTool>> = {
frp: "frpc",
easytier: "easytier",
vnt: "vnt",
cloudflared: "cloudflared",
};
const tool = tabToolMap[activeTab];
if (tool) {
refreshVpnToolManager(tool, false);
}
if (activeTab === "cloudflared") {
getCloudflaredStatus();
}
}, [activeTab, refreshVpnToolManager, getCloudflaredStatus]);
// Handle TLS mode change // Handle TLS mode change
const handleTlsModeChange = (value: string) => { const handleTlsModeChange = (value: string) => {
setTlsMode(value); setTlsMode(value);
@@ -870,6 +1071,154 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
getVntConfigFile(); getVntConfigFile();
}, [getVntStatus, getVntConfig]); }, [getVntStatus, getVntConfig]);
const renderVpnToolManager = (tool: ManagedVpnTool, label: string) => {
const status = vpnToolStatusMap[tool];
const releases = vpnToolReleasesMap[tool] || [];
const selectedVersion = vpnToolSelectedVersionMap[tool] || "";
const selectedRelease = releases.find(release => release.tag_name === selectedVersion);
const selectedAsset = vpnToolSelectedAssetMap[tool] || "";
const assets = selectedRelease?.assets || [];
const busy = vpnToolBusyMap[tool] || false;
const installTask = vpnToolInstallTaskMap[tool];
const installRunning = installTask?.running === true;
const installPanelOpen = vpnToolInstallPanelOpenMap[tool] || false;
const isInstalled = status?.installed === true;
const uninstallVersion = status?.current_version || status?.managed_versions?.[0] || "";
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{label} {$at("Version Manager")}
</span>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="light"
text={$at("Refresh")}
onClick={() => refreshVpnToolManager(tool, installPanelOpen)}
disabled={busy || installRunning}
/>
<Button
size="SM"
theme="light"
text={installPanelOpen ? $at("Hide Install Actions") : $at("Show Install Actions")}
onClick={() => {
const nextOpen = !installPanelOpen;
setVpnToolInstallPanelOpenMap(prev => ({ ...prev, [tool]: nextOpen }));
if (nextOpen) {
listVpnToolReleases(tool);
}
}}
disabled={busy || installRunning}
/>
</div>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Install Status")}: {status?.installed ? $at("Installed") : $at("Not Installed")}
{status?.source ? ` (${status.source})` : ""}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Detected Version")}: {status?.detected_version || "-"}
</div>
{installPanelOpen ? (
<>
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("System Architecture")}: {vpnToolSystemInfo?.arch_label || "unknown"}
{vpnToolSystemInfo?.arch_keywords?.length
? ` (${vpnToolSystemInfo.arch_keywords.join(", ")})`
: ""}
</div>
<div className="space-y-2">
<SettingsItem title={$at("Release Version")} description="">
<Select
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
value={selectedVersion}
onChange={value => handleVpnToolVersionChange(tool, value)}
options={releases.map(release => ({
value: release.tag_name,
label: release.tag_name,
}))}
/>
</SettingsItem>
<SettingsItem title={$at("Release Asset")} description="">
<Select
className={isMobile ? "!w-full !h-[36px]" : "!w-[60%] !h-[36px]"}
value={selectedAsset}
onChange={value => setVpnToolSelectedAssetMap(prev => ({ ...prev, [tool]: value }))}
options={assets.map(asset => ({
value: asset.url,
label: `${asset.arch_match ? "[ARCH OK] " : ""}${asset.name}`,
}))}
/>
</SettingsItem>
</div>
<div className="flex items-center gap-x-2">
{isInstalled ? (
<>
<Button
size="SM"
theme="primary"
text={$at("Update")}
onClick={() => handleInstallVpnTool(tool)}
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
/>
<Button
size="SM"
theme="danger"
text={$at("Uninstall")}
onClick={() => handleUninstallVpnToolVersion(tool, uninstallVersion)}
disabled={busy || installRunning || !uninstallVersion}
/>
</>
) : (
<Button
size="SM"
theme="primary"
text={$at("Install")}
onClick={() => handleInstallVpnTool(tool)}
disabled={busy || installRunning || !selectedVersion || !selectedAsset}
/>
)}
</div>
{installRunning && installTask ? (
<div className="space-y-2 rounded border border-slate-200 p-2 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Install Task")}: {installTask.message || "-"}
{installTask.error ? ` (${installTask.error})` : ""}
</div>
<div className="h-2 w-full rounded bg-slate-200 dark:bg-slate-700">
<div
className="h-2 rounded bg-blue-500 transition-all"
style={{ width: `${Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%` }}
/>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Progress")}: {Math.max(0, Math.min(100, Math.round((installTask.progress || 0) * 100)))}%
</div>
{!!installTask.logs?.length && (
<pre className="max-h-36 overflow-auto rounded bg-slate-50 p-2 text-[11px] text-slate-600 dark:bg-slate-900 dark:text-slate-300">
{installTask.logs.join("\n")}
</pre>
)}
</div>
) : null}
{status?.managed_versions?.length ? (
<div className="text-xs text-slate-500 dark:text-slate-400">
{$at("Installed Versions")}: {status.managed_versions.join(", ")}
</div>
) : null}
</>
) : null}
</div>
</div>
);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -1307,6 +1656,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard> <GridCard>
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
{renderVpnToolManager("easytier", "EasyTier")}
{ easyTierRunningStatus.running ? ( { easyTierRunningStatus.running ? (
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
@@ -1424,6 +1774,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard> <GridCard>
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
{renderVpnToolManager("vnt", "Vnt")}
{ vntRunningStatus.running ? ( { vntRunningStatus.running ? (
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
@@ -1636,6 +1987,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard> <GridCard>
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
{renderVpnToolManager("cloudflared", "Cloudflare")}
{cloudflaredRunningStatus.running ? ( {cloudflaredRunningStatus.running ? (
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<Button <Button
@@ -1684,6 +2036,7 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
<GridCard> <GridCard>
<div className="p-4"> <div className="p-4">
<div className="space-y-4"> <div className="space-y-4">
{renderVpnToolManager("frpc", "frpc")}
<TextAreaWithLabel <TextAreaWithLabel
label={$at("Edit frpc.toml")} label={$at("Edit frpc.toml")}
placeholder={$at("Enter frpc configuration")} placeholder={$at("Enter frpc configuration")}

16
vpn.go
View File

@@ -332,7 +332,7 @@ func rpcStartFrpc(frpcToml string) error {
if err := os.WriteFile(frpcTomlPath, []byte(frpcToml), 0600); err != nil { if err := os.WriteFile(frpcTomlPath, []byte(frpcToml), 0600); err != nil {
return err return err
} }
cmd := exec.Command("frpc", "-c", frpcTomlPath) cmd := exec.Command(resolveVpnToolBinary("frpc", "frpc"), "-c", frpcTomlPath)
cmd.Stdout = nil cmd.Stdout = nil
cmd.Stderr = nil cmd.Stderr = nil
logFile, err := os.OpenFile(frpcLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) logFile, err := os.OpenFile(frpcLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
@@ -386,7 +386,9 @@ type CloudflaredStatus struct {
} }
func cloudflaredRunning() bool { func cloudflaredRunning() bool {
cmd := exec.Command("pgrep", "-x", "cloudflared") // Only treat long-running tunnel process as running.
// This avoids false positives from short-lived version checks like `cloudflared -v`.
cmd := exec.Command("pgrep", "-f", `cloudflared.*tunnel.*run`)
return cmd.Run() == nil return cmd.Run() == nil
} }
@@ -401,7 +403,7 @@ func rpcStartCloudflared(token string) error {
if token == "" { if token == "" {
return fmt.Errorf("cloudflared token is empty") return fmt.Errorf("cloudflared token is empty")
} }
cmd := exec.Command("cloudflared", "tunnel", "run", "--token", token) cmd := exec.Command(resolveVpnToolBinary("cloudflared", "cloudflared"), "tunnel", "run", "--token", token)
logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) logFile, err := os.OpenFile(cloudflaredLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil { if err != nil {
return err return err
@@ -521,7 +523,7 @@ func rpcGetEasyTierLog() (string, error) {
} }
func rpcGetEasyTierNodeInfo() (string, error) { func rpcGetEasyTierNodeInfo() (string, error) {
cmd := exec.Command("easytier-cli", "node") cmd := exec.Command(resolveVpnToolBinary("easytier", "easytier-cli"), "node")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get easytier node info: %w", err) return "", fmt.Errorf("failed to get easytier node info: %w", err)
@@ -543,7 +545,7 @@ func rpcStartEasyTier(name, secret, node string) error {
return fmt.Errorf("easytier config is invalid") return fmt.Errorf("easytier config is invalid")
} }
cmd := exec.Command("easytier-core", "-d", "--network-name", name, "--network-secret", secret, "-p", node) cmd := exec.Command(resolveVpnToolBinary("easytier", "easytier-core"), "-d", "--network-name", name, "--network-secret", secret, "-p", node)
cmd.Stdout = nil cmd.Stdout = nil
cmd.Stderr = nil cmd.Stderr = nil
logFile, err := os.OpenFile(easytierLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) logFile, err := os.OpenFile(easytierLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
@@ -639,7 +641,7 @@ func rpcGetVntLog() (string, error) {
} }
func rpcGetVntInfo() (string, error) { func rpcGetVntInfo() (string, error) {
cmd := exec.Command("vnt-cli", "--info") cmd := exec.Command(resolveVpnToolBinary("vnt", "vnt-cli"), "--info")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get vnt info: %w", err) return "", fmt.Errorf("failed to get vnt info: %w", err)
@@ -707,7 +709,7 @@ func rpcStartVnt(configMode, token, deviceId, name, serverAddr, configFile strin
args = append(args, "--compressor", "lz4") args = append(args, "--compressor", "lz4")
} }
cmd := exec.Command("vnt-cli", args...) cmd := exec.Command(resolveVpnToolBinary("vnt", "vnt-cli"), args...)
cmd.Stdout = nil cmd.Stdout = nil
cmd.Stderr = nil cmd.Stderr = nil
logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) logFile, err := os.OpenFile(vntLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)