package installers import ( "errors" "fmt" "github.com/TeaOSLab/EdgeCommon/pkg/configutils" "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" "github.com/iwind/TeaGo/Tea" stringutil "github.com/iwind/TeaGo/utils/string" "golang.org/x/crypto/ssh" "net" "path/filepath" "regexp" "strconv" "strings" "time" ) type BaseInstaller struct { client *SSHClient } // Login 登录SSH服务 func (this *BaseInstaller) Login(credentials *Credentials) error { var hostKeyCallback ssh.HostKeyCallback = nil // 检查参数 if len(credentials.Host) == 0 { return errors.New("'host' should not be empty") } if credentials.Port <= 0 { return errors.New("'port' should be greater than 0") } if len(credentials.Password) == 0 && len(credentials.PrivateKey) == 0 { return errors.New("require user 'password' or 'privateKey'") } // 不使用known_hosts if hostKeyCallback == nil { hostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { return nil } } // 认证 var methods = []ssh.AuthMethod{} if credentials.Method == "user" { { var authMethod = ssh.Password(credentials.Password) methods = append(methods, authMethod) } { authMethod := ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { if len(questions) == 0 { return []string{}, nil } return []string{credentials.Password}, nil }) methods = append(methods, authMethod) } } else if credentials.Method == "privateKey" { var signer ssh.Signer var err error if len(credentials.Passphrase) > 0 { signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(credentials.PrivateKey), []byte(credentials.Passphrase)) } else { signer, err = ssh.ParsePrivateKey([]byte(credentials.PrivateKey)) } if err != nil { return fmt.Errorf("parse private key: %w", err) } authMethod := ssh.PublicKeys(signer) methods = append(methods, authMethod) } else { return errors.New("invalid method '" + credentials.Method + "'") } // SSH客户端 if len(credentials.Username) == 0 { credentials.Username = "root" } var config = &ssh.ClientConfig{ User: credentials.Username, Auth: methods, HostKeyCallback: hostKeyCallback, Timeout: 5 * time.Second, // TODO 后期可以设置这个超时时间 } sshClient, err := ssh.Dial("tcp", configutils.QuoteIP(credentials.Host)+":"+strconv.Itoa(credentials.Port), config) if err != nil { return err } client, err := NewSSHClient(sshClient) if err != nil { return err } if credentials.Sudo { client.Sudo(credentials.Password) } this.client = client return nil } // Close 关闭SSH服务 func (this *BaseInstaller) Close() error { if this.client != nil { return this.client.Close() } return nil } // LookupLatestInstaller 查找最新的版本的文件 func (this *BaseInstaller) LookupLatestInstaller(filePrefix string) (string, error) { matches, err := filepath.Glob(Tea.Root + Tea.DS + "deploy" + Tea.DS + "*.zip") if err != nil { return "", err } pattern, err := regexp.Compile(filePrefix + `-v([\d.]+)\.zip`) if err != nil { return "", err } var lastVersion = "" var result = "" for _, match := range matches { var baseName = filepath.Base(match) if !pattern.MatchString(baseName) { continue } var m = pattern.FindStringSubmatch(baseName) if len(m) < 2 { continue } version := m[1] if len(lastVersion) == 0 || stringutil.VersionCompare(version, lastVersion) > 0 { lastVersion = version result = match } } return result, nil } // LookupLatestInstallerForTarget 按目标系统优先选择安装包: // 1) Linux 且识别到发行版时,优先 filePrefix-{distroTag}-v*.zip(如 ubuntu22.04 / amzn2023) // 2) 回退 filePrefix-v*.zip(通用包) func (this *BaseInstaller) LookupLatestInstallerForTarget(filePrefix string, env *Env) (string, error) { if env != nil && env.OS == "linux" { distroTag, err := this.detectLinuxDistroTag() if err != nil { return "", err } if len(distroTag) > 0 { zipFile, err := this.LookupLatestInstaller(filePrefix + "-" + distroTag) if err != nil { return "", err } if len(zipFile) > 0 { return zipFile, nil } } } return this.LookupLatestInstaller(filePrefix) } // detectLinuxDistroTag 识别远端 Linux 发行版标签(用于选择发行版定制安装包)。 // 当前支持:ubuntu22.04 / amzn2023;其他系统返回空字符串(回退通用包)。 func (this *BaseInstaller) detectLinuxDistroTag() (string, error) { if this.client == nil { return "", nil } releaseData, stderr, err := this.client.Exec("cat /etc/os-release") if err != nil { return "", fmt.Errorf("read /etc/os-release failed: %w, stderr: %s", err, stderr) } if len(strings.TrimSpace(releaseData)) == 0 { return "", nil } releaseMap := parseOSRelease(releaseData) id := strings.ToLower(strings.TrimSpace(releaseMap["ID"])) versionID := strings.TrimSpace(releaseMap["VERSION_ID"]) switch { case id == "ubuntu" && strings.HasPrefix(versionID, "22.04"): return "ubuntu22.04", nil case id == "amzn" && strings.HasPrefix(versionID, "2023"): return "amzn2023", nil default: return "", nil } } // InstallHelper 上传安装助手 func (this *BaseInstaller) InstallHelper(targetDir string, role nodeconfigs.NodeRole) (env *Env, err error) { var uname = this.uname() var osName string var archName string if strings.Contains(uname, "Darwin") { osName = "darwin" } else if strings.Contains(uname, "Linux") { osName = "linux" } else { // TODO 支持freebsd, aix ... return env, errors.New("installer not supported os '" + uname + "'") } if strings.Contains(uname, "aarch64") || strings.Contains(uname, "armv8") { archName = "arm64" } else if strings.Contains(uname, "aarch64_be") { archName = "arm64be" } else if strings.Contains(uname, "mips64el") { archName = "mips64le" } else if strings.Contains(uname, "mips64") { archName = "mips64" } else if strings.Contains(uname, "x86_64") { archName = "amd64" } else { archName = "386" } var exeName = "edge-installer-helper-" + osName + "-" + archName switch role { case nodeconfigs.NodeRoleDNS: exeName = "edge-installer-dns-helper-" + osName + "-" + archName } var exePath = Tea.Root + "/installers/" + exeName var realHelperPath = "" var firstCopyErr error for _, path := range []string{ targetDir + "/" + exeName, this.client.UserHome() + "/" + exeName, "/tmp/" + exeName, } { err = this.client.Copy(exePath, path, 0777) if err != nil { if firstCopyErr == nil { firstCopyErr = err } } else { err = nil firstCopyErr = nil realHelperPath = path break } } if firstCopyErr != nil { return env, errors.New("copy '" + exeName + "' to '" + targetDir + "' failed: " + firstCopyErr.Error()) } env = &Env{ OS: osName, Arch: archName, HelperPath: realHelperPath, } return env, nil } func (this *BaseInstaller) uname() (uname string) { var unameRetries = 3 for i := 0; i < unameRetries; i++ { for _, unameExe := range []string{"uname", "/bin/uname", "/usr/bin/uname"} { uname, _, _ = this.client.Exec(unameExe + " -a") if len(uname) > 0 { return } } } return "x86_64 GNU/Linux" } func parseOSRelease(content string) map[string]string { result := map[string]string{} lines := strings.Split(content, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { continue } parts := strings.SplitN(line, "=", 2) key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) value = strings.Trim(value, "\"") result[key] = value } return result } func shQuote(s string) string { if len(s) == 0 { return "''" } return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }