diff --git a/modules/config/login.go b/modules/config/login.go index 3cd1adb7..2cf63d69 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -528,5 +528,5 @@ func (l *Login) GetSSHHost() string { return "" } - return u.Host + return u.Hostname() } diff --git a/modules/config/login_test.go b/modules/config/login_test.go index 9348c1ae..3bbe5514 100644 --- a/modules/config/login_test.go +++ b/modules/config/login_test.go @@ -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) + } + }) + } +} diff --git a/modules/utils/ssh_host.go b/modules/utils/ssh_host.go new file mode 100644 index 00000000..a930fb20 --- /dev/null +++ b/modules/utils/ssh_host.go @@ -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 +} diff --git a/modules/utils/ssh_host_test.go b/modules/utils/ssh_host_test.go new file mode 100644 index 00000000..82e86e4c --- /dev/null +++ b/modules/utils/ssh_host_test.go @@ -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) + } + }) + } +}