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:
GyeongHo Kim 2026-06-14 02:19:52 +09:00 committed by GyeongHo Kim
parent 6435b12202
commit 511884b2af
No known key found for this signature in database
GPG key ID: 54111101FD01C8BD
4 changed files with 254 additions and 1 deletions

View file

@ -528,5 +528,5 @@ func (l *Login) GetSSHHost() string {
return ""
}
return u.Host
return u.Hostname()
}

View file

@ -108,3 +108,35 @@ func writeTestSSHKey(t *testing.T) (string, string) {
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
View 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
}

View 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)
}
})
}
}