mirror of
https://gitea.com/gitea/tea.git
synced 2026-09-10 07:26:33 -04:00
Gitea instances behind a reverse proxy that requires mutual TLS (nginx'
ssl_verify_client, or the equivalent in Traefik or HAProxy) reject every
tea request at the proxy, before it reaches Gitea. The user sees an opaque
403/400 from the proxy rather than an authentication error, and there was
no way to make tea present a certificate: only full verification and
--insecure were configurable.
Add an optional client certificate to a login, stored as client_cert and
client_key next to the existing insecure flag:
tea login add --url https://git.example.com --token ... \
--client-cert ~/.certs/client.crt \
--client-key ~/.certs/client.key
The pair is loaded by Login.TLSConfig(), which now builds the TLS config
for every outgoing connection: SDK API calls, the OAuth authorization and
token refresh flows, and the raw 'tea api' client. Because the certificate
lives on the login, all commands using it present it automatically. Paths
support ~ expansion so they can be written into the config file by hand,
and clones pass the same material to git as http.sslCert/http.sslKey.
Both fields must be set together; setting only one, or pointing at a
certificate that cannot be loaded, is a hard error rather than a silent
fallback to a plain connection, which would fail at the proxy with the
same opaque error the certificate is meant to avoid.
Requests without either setting keep Go's default TLS behaviour: the
config is only overridden when the login asks for it.
Fixes #451
Signed-off-by: Suhaib <suhaib.abdulquddos@gmail.com>
597 lines
18 KiB
Go
597 lines
18 KiB
Go
// Copyright 2020 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
gitea "gitea.dev/sdk"
|
|
|
|
"gitea.dev/tea/modules/debug"
|
|
"gitea.dev/tea/modules/httputil"
|
|
"gitea.dev/tea/modules/theme"
|
|
"gitea.dev/tea/modules/utils"
|
|
|
|
"charm.land/huh/v2"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// TokenRefreshThreshold is how far before expiry we should refresh OAuth tokens.
|
|
// This is used by config.Login.Client() for automatic token refresh.
|
|
const TokenRefreshThreshold = 5 * time.Minute
|
|
|
|
// DefaultClientID is the default OAuth2 client ID included in most Gitea instances
|
|
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
|
|
|
|
// AuthMethodOAuth marks a login as using OAuth with secure credential storage.
|
|
const AuthMethodOAuth = "oauth"
|
|
|
|
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
|
|
type Login struct {
|
|
Name string `yaml:"name"`
|
|
URL string `yaml:"url"`
|
|
Token string `yaml:"token,omitempty"`
|
|
Default bool `yaml:"default"`
|
|
SSHHost string `yaml:"ssh_host"`
|
|
// optional path to the private key
|
|
SSHKey string `yaml:"ssh_key"`
|
|
Insecure bool `yaml:"insecure"`
|
|
// ClientCert is an optional path to a PEM encoded TLS client certificate,
|
|
// presented to servers that require mutual TLS (e.g. a reverse proxy
|
|
// configured with nginx' ssl_verify_client). ClientKey is its private key;
|
|
// both must be set together.
|
|
ClientCert string `yaml:"client_cert,omitempty"`
|
|
ClientKey string `yaml:"client_key,omitempty"`
|
|
SSHCertPrincipal string `yaml:"ssh_certificate_principal"`
|
|
SSHAgent bool `yaml:"ssh_agent"`
|
|
SSHKeyFingerprint string `yaml:"ssh_key_agent_pub"`
|
|
SSHPassphrase string `yaml:"-"`
|
|
VersionCheck bool `yaml:"version_check"`
|
|
// User is username from gitea
|
|
User string `yaml:"user"`
|
|
// Created is auto created unix timestamp
|
|
Created int64 `yaml:"created"`
|
|
// AuthMethod indicates the authentication method ("oauth" for OAuth with credstore)
|
|
AuthMethod string `yaml:"auth_method,omitempty"`
|
|
// RefreshToken is used to renew the access token when it expires
|
|
RefreshToken string `yaml:"refresh_token,omitempty"`
|
|
// TokenExpiry is when the token expires (unix timestamp)
|
|
TokenExpiry int64 `yaml:"token_expiry,omitempty"`
|
|
}
|
|
|
|
// TLSConfig returns the TLS configuration to use for HTTPS requests to this
|
|
// login's server. It returns nil when the login needs neither relaxed
|
|
// verification nor a client certificate, so callers keep Go's default TLS
|
|
// behaviour.
|
|
func (l *Login) TLSConfig() (*tls.Config, error) {
|
|
cert, err := l.clientCertificate()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cert == nil && !l.Insecure {
|
|
return nil, nil
|
|
}
|
|
|
|
cfg := &tls.Config{InsecureSkipVerify: l.Insecure}
|
|
if cert != nil {
|
|
cfg.Certificates = []tls.Certificate{*cert}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// clientCertificate loads the login's PEM client certificate/key pair, and
|
|
// returns nil when the login does not configure one.
|
|
func (l *Login) clientCertificate() (*tls.Certificate, error) {
|
|
if l.ClientCert == "" && l.ClientKey == "" {
|
|
return nil, nil
|
|
}
|
|
if l.ClientCert == "" || l.ClientKey == "" {
|
|
return nil, errors.New("client_cert and client_key must be set together to use a TLS client certificate")
|
|
}
|
|
|
|
certFile, err := utils.AbsPathWithExpansion(l.ClientCert)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid client certificate path %q: %w", l.ClientCert, err)
|
|
}
|
|
keyFile, err := utils.AbsPathWithExpansion(l.ClientKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid client key path %q: %w", l.ClientKey, err)
|
|
}
|
|
|
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load TLS client certificate: %w", err)
|
|
}
|
|
return &cert, nil
|
|
}
|
|
|
|
// IsOAuth returns true if this login uses OAuth with secure credential storage.
|
|
func (l *Login) IsOAuth() bool {
|
|
return l.AuthMethod == AuthMethodOAuth
|
|
}
|
|
|
|
// loadOAuthToken loads the OAuth token from credstore, returning nil if
|
|
// this is not an OAuth login or if the load fails (caller should fallback).
|
|
func (l *Login) loadOAuthToken() *OAuthToken {
|
|
if !l.IsOAuth() {
|
|
return nil
|
|
}
|
|
tok, err := LoadOAuthToken(l.Name)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &OAuthToken{
|
|
AccessToken: tok.AccessToken,
|
|
RefreshToken: tok.RefreshToken,
|
|
ExpiresAt: tok.ExpiresAt,
|
|
}
|
|
}
|
|
|
|
// OAuthToken holds the token fields loaded from credstore.
|
|
type OAuthToken struct {
|
|
AccessToken string
|
|
RefreshToken string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// GetAccessToken returns the effective access token.
|
|
// For OAuth logins, reads from credstore. For others, returns l.Token directly.
|
|
func (l *Login) GetAccessToken() string {
|
|
if tok := l.loadOAuthToken(); tok != nil {
|
|
return tok.AccessToken
|
|
}
|
|
return l.Token
|
|
}
|
|
|
|
// GetRefreshToken returns the refresh token.
|
|
func (l *Login) GetRefreshToken() string {
|
|
if tok := l.loadOAuthToken(); tok != nil {
|
|
return tok.RefreshToken
|
|
}
|
|
return l.RefreshToken
|
|
}
|
|
|
|
// GetTokenExpiry returns the token expiry time.
|
|
func (l *Login) GetTokenExpiry() time.Time {
|
|
if tok := l.loadOAuthToken(); tok != nil {
|
|
return tok.ExpiresAt
|
|
}
|
|
return time.Unix(l.TokenExpiry, 0)
|
|
}
|
|
|
|
// GetLogins return all login available by config
|
|
func GetLogins() ([]Login, error) {
|
|
if err := loadConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
return config.Logins, nil
|
|
}
|
|
|
|
// GetDefaultLogin return the default login
|
|
func GetDefaultLogin() (*Login, error) {
|
|
if err := loadConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(config.Logins) == 0 {
|
|
return nil, errors.New("no available login")
|
|
}
|
|
for _, l := range config.Logins {
|
|
if l.Default {
|
|
return &l, nil
|
|
}
|
|
}
|
|
|
|
return &config.Logins[0], nil
|
|
}
|
|
|
|
// SetDefaultLogin set the default login by name (case insensitive)
|
|
func SetDefaultLogin(name string) error {
|
|
return withConfigLock(func() error {
|
|
loginExist := false
|
|
for i := range config.Logins {
|
|
config.Logins[i].Default = false
|
|
if strings.EqualFold(config.Logins[i].Name, name) {
|
|
config.Logins[i].Default = true
|
|
loginExist = true
|
|
}
|
|
}
|
|
|
|
if !loginExist {
|
|
return fmt.Errorf("login '%s' not found", name)
|
|
}
|
|
|
|
return saveConfigUnsafe()
|
|
})
|
|
}
|
|
|
|
// GetLoginByName get login by name (case insensitive)
|
|
func GetLoginByName(name string) (*Login, error) {
|
|
if err := loadConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i := range config.Logins {
|
|
if strings.EqualFold(config.Logins[i].Name, name) {
|
|
return &config.Logins[i], nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// GetLoginByToken get login by token
|
|
func GetLoginByToken(token string) (*Login, error) {
|
|
if token == "" {
|
|
return nil, nil
|
|
}
|
|
if err := loadConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, l := range config.Logins {
|
|
if l.Token == token {
|
|
return &l, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// GetLoginByHost finds a login by its server URL
|
|
func GetLoginByHost(host string) (*Login, error) {
|
|
logins, err := GetLoginsByHost(host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(logins) > 0 {
|
|
return logins[0], nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// GetLoginsByHost returns all logins matching a host
|
|
func GetLoginsByHost(host string) ([]*Login, error) {
|
|
if err := loadConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var matches []*Login
|
|
for i := range config.Logins {
|
|
loginURL, err := url.Parse(config.Logins[i].URL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if loginURL.Host == host {
|
|
matches = append(matches, &config.Logins[i])
|
|
}
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
// DeleteLogin delete a login by name from config
|
|
func DeleteLogin(name string) error {
|
|
return withConfigLock(func() error {
|
|
idx := -1
|
|
for i, l := range config.Logins {
|
|
if strings.EqualFold(l.Name, name) {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx == -1 {
|
|
return fmt.Errorf("can not delete login '%s', does not exist", name)
|
|
}
|
|
|
|
isOAuth := config.Logins[idx].IsOAuth()
|
|
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
|
|
|
|
// Clean up credstore tokens for OAuth logins
|
|
if isOAuth {
|
|
_ = DeleteOAuthToken(name)
|
|
}
|
|
|
|
return saveConfigUnsafe()
|
|
})
|
|
}
|
|
|
|
// AddLogin save a login to config
|
|
func AddLogin(login *Login) error {
|
|
return withConfigLock(func() error {
|
|
// Check for duplicate login names
|
|
for _, existing := range config.Logins {
|
|
if strings.EqualFold(existing.Name, login.Name) {
|
|
return fmt.Errorf("login name '%s' already exists", login.Name)
|
|
}
|
|
}
|
|
|
|
// save login to global var
|
|
config.Logins = append(config.Logins, *login)
|
|
|
|
// save login to config file
|
|
return saveConfigUnsafe()
|
|
})
|
|
}
|
|
|
|
// AddOAuthLogin saves the OAuth token and login profile as one operation.
|
|
// The profile is only written after secure token storage succeeds.
|
|
func AddOAuthLogin(login *Login, accessToken, refreshToken string, expiresAt time.Time) error {
|
|
return withConfigLock(func() error {
|
|
// Check for duplicate login names before touching credential storage.
|
|
for _, existing := range config.Logins {
|
|
if strings.EqualFold(existing.Name, login.Name) {
|
|
return fmt.Errorf("login name '%s' already exists", login.Name)
|
|
}
|
|
}
|
|
|
|
if err := SaveOAuthToken(login.Name, accessToken, refreshToken, expiresAt); err != nil {
|
|
return fmt.Errorf("failed to save token to secure store: %w", err)
|
|
}
|
|
|
|
config.Logins = append(config.Logins, *login)
|
|
if err := saveConfigUnsafe(); err != nil {
|
|
config.Logins = config.Logins[:len(config.Logins)-1]
|
|
if deleteErr := DeleteOAuthToken(login.Name); deleteErr != nil {
|
|
return errors.Join(err, fmt.Errorf("failed to clean up OAuth token after config save failure: %w", deleteErr))
|
|
}
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SaveLoginTokens updates the token fields for an existing login.
|
|
// This is used after browser-based re-authentication to save new tokens.
|
|
func SaveLoginTokens(login *Login) error {
|
|
if login.IsOAuth() {
|
|
return SaveOAuthToken(login.Name, login.GetAccessToken(), login.GetRefreshToken(), login.GetTokenExpiry())
|
|
}
|
|
return withConfigLock(func() error {
|
|
for i, l := range config.Logins {
|
|
if strings.EqualFold(l.Name, login.Name) {
|
|
config.Logins[i].Token = login.Token
|
|
config.Logins[i].RefreshToken = login.RefreshToken
|
|
config.Logins[i].TokenExpiry = login.TokenExpiry
|
|
return saveConfigUnsafe()
|
|
}
|
|
}
|
|
return fmt.Errorf("login %s not found", login.Name)
|
|
})
|
|
}
|
|
|
|
// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
|
|
// Returns nil without doing anything if no refresh is needed.
|
|
func (l *Login) RefreshOAuthTokenIfNeeded() error {
|
|
// Load once to avoid multiple credstore reads
|
|
if tok := l.loadOAuthToken(); tok != nil {
|
|
if tok.RefreshToken == "" || tok.ExpiresAt.IsZero() {
|
|
return nil
|
|
}
|
|
if time.Now().Add(TokenRefreshThreshold).After(tok.ExpiresAt) {
|
|
return l.RefreshOAuthToken()
|
|
}
|
|
return nil
|
|
}
|
|
// Non-OAuth path: use YAML fields
|
|
if l.RefreshToken == "" || l.TokenExpiry == 0 {
|
|
return nil
|
|
}
|
|
if time.Now().Add(TokenRefreshThreshold).After(time.Unix(l.TokenExpiry, 0)) {
|
|
return l.RefreshOAuthToken()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RefreshOAuthToken refreshes the OAuth access token using the refresh token.
|
|
// It updates the login with new token information and saves it to config.
|
|
// Uses double-checked locking to avoid unnecessary refresh calls when multiple
|
|
// processes race to refresh the same token.
|
|
func (l *Login) RefreshOAuthToken() error {
|
|
if l.GetRefreshToken() == "" {
|
|
return fmt.Errorf("no refresh token available")
|
|
}
|
|
|
|
return withConfigLock(func() error {
|
|
// Double-check: after acquiring lock, re-read config and check if
|
|
// another process already refreshed the token
|
|
for i, login := range config.Logins {
|
|
if login.Name == l.Name {
|
|
// Check if token was refreshed by another process
|
|
currentExpiry := login.GetTokenExpiry()
|
|
ourExpiry := l.GetTokenExpiry()
|
|
if currentExpiry != ourExpiry && !currentExpiry.IsZero() {
|
|
if time.Now().Add(TokenRefreshThreshold).Before(currentExpiry) {
|
|
// Token was refreshed by another process, update our copy
|
|
if !login.IsOAuth() {
|
|
l.Token = login.Token
|
|
l.RefreshToken = login.RefreshToken
|
|
l.TokenExpiry = login.TokenExpiry
|
|
}
|
|
// For OAuth logins, credstore already has the latest tokens
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Still need to refresh - proceed with OAuth call
|
|
newToken, err := doOAuthRefresh(context.Background(), l)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if l.IsOAuth() {
|
|
// Save tokens to credstore; no YAML changes needed
|
|
return SaveOAuthTokenFromOAuth2(l.Name, newToken, l)
|
|
}
|
|
|
|
// Update login with new token information (legacy path)
|
|
l.Token = newToken.AccessToken
|
|
if newToken.RefreshToken != "" {
|
|
l.RefreshToken = newToken.RefreshToken
|
|
}
|
|
if !newToken.Expiry.IsZero() {
|
|
l.TokenExpiry = newToken.Expiry.Unix()
|
|
}
|
|
config.Logins[i] = *l
|
|
return saveConfigUnsafe()
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("login %s not found", l.Name)
|
|
})
|
|
}
|
|
|
|
// doOAuthRefresh performs the actual OAuth token refresh API call.
|
|
func doOAuthRefresh(ctx context.Context, l *Login) (*oauth2.Token, error) {
|
|
// Build current token from credstore (single load) or YAML fields
|
|
var accessToken, refreshToken string
|
|
var expiry time.Time
|
|
if tok := l.loadOAuthToken(); tok != nil {
|
|
accessToken = tok.AccessToken
|
|
refreshToken = tok.RefreshToken
|
|
expiry = tok.ExpiresAt
|
|
} else {
|
|
accessToken = l.Token
|
|
refreshToken = l.RefreshToken
|
|
expiry = time.Unix(l.TokenExpiry, 0)
|
|
}
|
|
currentToken := &oauth2.Token{
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
Expiry: expiry,
|
|
}
|
|
|
|
tlsConfig, err := l.TLSConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient := &http.Client{
|
|
Transport: httputil.WrapTransport(tlsConfig),
|
|
}
|
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
|
|
|
oauth2Config := &oauth2.Config{
|
|
ClientID: DefaultClientID,
|
|
Endpoint: oauth2.Endpoint{
|
|
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
|
|
},
|
|
}
|
|
|
|
newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
|
}
|
|
|
|
return newToken, nil
|
|
}
|
|
|
|
// Client returns a client to operate Gitea API. You may provide additional modifiers
|
|
// for the client like gitea.SetBasicAuth() for customization
|
|
func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
|
// Refresh OAuth token if expired or near expiry
|
|
if err := l.RefreshOAuthTokenIfNeeded(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
|
|
os.Exit(1)
|
|
}
|
|
|
|
return l.ClientWithoutRefresh(options...)
|
|
}
|
|
|
|
// ClientWithoutRefresh returns a client to operate the Gitea API without
|
|
// attempting an automatic OAuth token refresh. Commands that need to handle
|
|
// token refresh errors themselves (such as 'tea login status') should use this
|
|
// instead of Client, which prints to stderr and exits on refresh failure.
|
|
func (l *Login) ClientWithoutRefresh(options ...gitea.ClientOption) *gitea.Client {
|
|
// Configure transport-level timeouts so a stalled or unresponsive server
|
|
// fails fast instead of hanging forever. These bound connection setup and
|
|
// time-to-first-response-byte only, so slow-but-progressing transfers (e.g.
|
|
// large attachment uploads) are unaffected.
|
|
tlsConfig, err := l.TLSConfig()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to configure TLS for login %s: %s\n", l.Name, err)
|
|
os.Exit(1)
|
|
}
|
|
httpClient := &http.Client{
|
|
Transport: httputil.WrapTransport(tlsConfig),
|
|
}
|
|
if l.Insecure {
|
|
httpClient.Jar, _ = cookiejar.New(nil) // New with nil options never returns an error
|
|
}
|
|
|
|
// versioncheck must be prepended in options to make sure we don't hit any version checks in the sdk
|
|
if !l.VersionCheck {
|
|
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
|
|
}
|
|
|
|
// SetUserAgent is intentionally redundant with the User-Agent the WrapTransport
|
|
// transport already sets: this is the SDK's own guarantee, so the UA survives
|
|
// even if the client is ever given a transport that didn't come from WrapTransport.
|
|
// Both resolve to httputil.UserAgent(), so the duplicate Header.Set is a no-op.
|
|
options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
|
|
if debug.IsDebug() {
|
|
options = append(options, gitea.SetDebugMode())
|
|
}
|
|
|
|
if l.SSHCertPrincipal != "" {
|
|
if err := l.askForSSHPassphrase(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKey, l.SSHPassphrase))
|
|
}
|
|
|
|
if l.SSHKeyFingerprint != "" {
|
|
if err := l.askForSSHPassphrase(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKey, l.SSHPassphrase))
|
|
}
|
|
|
|
client, err := gitea.NewClient(l.URL, options...)
|
|
if err != nil {
|
|
var versionError *gitea.ErrUnknownVersion
|
|
if !errors.As(err, &versionError) {
|
|
fmt.Fprintf(os.Stderr, "Failed to create Gitea client: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "WARNING: could not detect gitea version: %s\nINFO: set gitea version: to last supported one\n", versionError)
|
|
}
|
|
return client
|
|
}
|
|
|
|
func (l *Login) askForSSHPassphrase() error {
|
|
if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" {
|
|
return huh.NewInput().
|
|
Title("ssh-key is encrypted please enter the passphrase: ").
|
|
Validate(huh.ValidateNotEmpty()).
|
|
EchoMode(huh.EchoModePassword).
|
|
Value(&l.SSHPassphrase).
|
|
WithTheme(theme.GetTheme()).
|
|
Run()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetSSHHost returns SSH host name
|
|
func (l *Login) GetSSHHost() string {
|
|
if l.SSHHost != "" {
|
|
return l.SSHHost
|
|
}
|
|
|
|
u, err := url.Parse(l.URL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return u.Host
|
|
}
|