mirror of
https://gitea.com/gitea/tea.git
synced 2026-09-10 07:26:33 -04:00
fix(login): default SSH host to URL hostname
Use the server URL hostname when a login has no explicit SSHHost configured, so an HTTP(S) port from the Gitea URL is not reused as the SSH endpoint. Add shared SSH host normalization and resolution helpers with tests for host and host:port values. Signed-off-by: GyeongHo Kim <gyeongho.dev@proton.me>
This commit is contained in:
parent
6435b12202
commit
511884b2af
|
|
@ -528,5 +528,5 @@ func (l *Login) GetSSHHost() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return u.Host
|
return u.Hostname()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,35 @@ func writeTestSSHKey(t *testing.T) (string, string) {
|
||||||
|
|
||||||
return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey())
|
return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginGetSSHHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
login Login
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "explicit SSH host",
|
||||||
|
login: Login{URL: "https://gitea.example.com:3000", SSHHost: "ssh.example.com:2222"},
|
||||||
|
want: "ssh.example.com:2222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with port",
|
||||||
|
login: Login{URL: "https://gitea.example.com:3000"},
|
||||||
|
want: "gitea.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL without port",
|
||||||
|
login: Login{URL: "https://gitea.example.com"},
|
||||||
|
want: "gitea.example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.login.GetSSHHost(); got != tt.want {
|
||||||
|
t.Fatalf("GetSSHHost() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
90
modules/utils/ssh_host.go
Normal file
90
modules/utils/ssh_host.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeSSHHost validates an SSH host setting and returns it trimmed.
|
||||||
|
// The accepted format is host or host:port. IPv6 addresses must be bracketed.
|
||||||
|
func NormalizeSSHHost(raw string) (string, error) {
|
||||||
|
sshHost := strings.TrimSpace(raw)
|
||||||
|
if sshHost == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ContainsAny(sshHost, "/\\") {
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: use host or host:port, not a URL or path", raw)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(sshHost, " \t\r\n") {
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: whitespace is not allowed", raw)
|
||||||
|
}
|
||||||
|
if strings.Contains(sshHost, "@") {
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: use host or host:port without a user", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := sshHost
|
||||||
|
port := ""
|
||||||
|
colonCount := strings.Count(sshHost, ":")
|
||||||
|
portSpecified := false
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(sshHost, "["):
|
||||||
|
if strings.Contains(sshHost, "]:") {
|
||||||
|
portSpecified = true
|
||||||
|
var err error
|
||||||
|
host, port, err = net.SplitHostPort(sshHost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(sshHost, "]") {
|
||||||
|
host = strings.TrimSuffix(strings.TrimPrefix(sshHost, "["), "]")
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: malformed IPv6 address", raw)
|
||||||
|
}
|
||||||
|
case colonCount == 1:
|
||||||
|
portSpecified = true
|
||||||
|
parts := strings.SplitN(sshHost, ":", 2)
|
||||||
|
host = parts[0]
|
||||||
|
port = parts[1]
|
||||||
|
case colonCount > 1:
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: IPv6 addresses must be bracketed", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "" {
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: host is required", raw)
|
||||||
|
}
|
||||||
|
if portSpecified {
|
||||||
|
if port == "" {
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: port is required", raw)
|
||||||
|
}
|
||||||
|
portNumber, err := strconv.Atoi(port)
|
||||||
|
if err != nil || portNumber < 1 || portNumber > 65535 {
|
||||||
|
return "", fmt.Errorf("invalid SSH host %q: port must be between 1 and 65535", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sshHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSSHHost returns the explicit SSH host when provided, otherwise the
|
||||||
|
// hostname from the Gitea server URL.
|
||||||
|
func ResolveSSHHost(serverURL *url.URL, explicitSSHHost string) (string, error) {
|
||||||
|
sshHost, err := NormalizeSSHHost(explicitSSHHost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if sshHost != "" {
|
||||||
|
return sshHost, nil
|
||||||
|
}
|
||||||
|
if serverURL == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return serverURL.Hostname(), nil
|
||||||
|
}
|
||||||
131
modules/utils/ssh_host_test.go
Normal file
131
modules/utils/ssh_host_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeSSHHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
raw: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host",
|
||||||
|
raw: "gitea.example.com",
|
||||||
|
want: "gitea.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host with port",
|
||||||
|
raw: "gitea.example.com:2222",
|
||||||
|
want: "gitea.example.com:2222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trim spaces",
|
||||||
|
raw: " gitea.example.com:2222 ",
|
||||||
|
want: "gitea.example.com:2222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bracketed IPv6 with port",
|
||||||
|
raw: "[::1]:2222",
|
||||||
|
want: "[::1]:2222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL",
|
||||||
|
raw: "ssh://gitea.example.com:2222",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
raw: "gitea.example.com/owner/repo",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
raw: "git@gitea.example.com",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid port",
|
||||||
|
raw: "gitea.example.com:ssh",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing port",
|
||||||
|
raw: "gitea.example.com:",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing host",
|
||||||
|
raw: ":2222",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare IPv6",
|
||||||
|
raw: "::1",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := NormalizeSSHHost(tt.raw)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("NormalizeSSHHost() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("NormalizeSSHHost() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSSHHost(t *testing.T) {
|
||||||
|
serverURL, err := url.Parse("https://gitea.example.com:3000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
explicitSSHHost string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "uses URL hostname by default",
|
||||||
|
want: "gitea.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uses explicit host",
|
||||||
|
explicitSSHHost: "ssh.example.com",
|
||||||
|
want: "ssh.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uses explicit host with port",
|
||||||
|
explicitSSHHost: "ssh.example.com:2222",
|
||||||
|
want: "ssh.example.com:2222",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ResolveSSHHost(serverURL, tt.explicitSSHHost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveSSHHost() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("ResolveSSHHost() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue