mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-05-27 16:45:08 +02:00
907 lines
23 KiB
Go
907 lines
23 KiB
Go
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
|
|
}
|