mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-27 03:21:25 +01:00
revert: tea 1.0 rewrite (#1122–#1126) (#1146)
Reverts #1126, #1125, #1124, #1123 and #1122. `go.mod` keeps the Go 1.26 line with toolchain `go1.26.9` (instead of the original `go1.26.6`) so the security fix from #1131 isn't lost. reopens #69 reopens #141 reopens #162 reopens #172 reopens #197 reopens #232 reopens #240 reopens #246 reopens #323 reopens #325 reopens #326 reopens #328 reopens #344 reopens #351 reopens #373 reopens #403 reopens #406 reopens #417 reopens #430 reopens #457 reopens #480 reopens #482 reopens #489 reopens #490 reopens #498 reopens #500 reopens #504 reopens #510 reopens #516 reopens #523 reopens #556 reopens #560 reopens #569 reopens #571 reopens #602 reopens #632 reopens #633 reopens #643 reopens #666 reopens #670 reopens #672 reopens #677 reopens #683 reopens #697 reopens #726 reopens #737 reopens #770 reopens #779 reopens #804 reopens #817 reopens #831 reopens #833 reopens #834 reopens #908 reopens #966 reopens #1023 reopens #1029 reopens #1032 reopens #1050 reopens #1052 reopens #1069 reopens #1073 reopens #1087 reopens #1091 reopens #1094 reopens #1110 reopens #1115 reopens #1116 reopens #1118 Reviewed-on: https://gitea.com/gitea/tea/pulls/1146 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
33bb12729e
commit
c5f674111a
604 files changed
+33711
-35545
No files matched your search
@@ -0,0 +1,549 @@
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: l.Insecure}),
|
||||
}
|
||||
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.
|
||||
httpClient := &http.Client{
|
||||
Transport: httputil.WrapTransport(nil),
|
||||
}
|
||||
if l.Insecure {
|
||||
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
|
||||
|
||||
httpClient = &http.Client{
|
||||
Jar: cookieJar,
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: true}),
|
||||
}
|
||||
}
|
||||
|
||||
// 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.SSHKeyPath(), 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.SSHKeyPath(), 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.SSHKeyPath()); 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
|
||||
}
|
||||
|
||||
// SSHKeyPath returns the on-disk SSH key to use, or an empty string when the
|
||||
// login is configured to authenticate through a running ssh-agent.
|
||||
func (l *Login) SSHKeyPath() string {
|
||||
if l.SSHAgent {
|
||||
return ""
|
||||
}
|
||||
return l.SSHKey
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user