mirror of
https://gitea.com/gitea/tea.git
synced 2026-09-10 07:26:33 -04:00
feat(login): support TLS client certificates for mutual TLS servers
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>
This commit is contained in:
parent
58931b5d17
commit
8240b385b1
|
|
@ -120,7 +120,10 @@ func runApi(requestCtx stdctx.Context, cmd *cli.Command) error {
|
|||
}
|
||||
|
||||
// Create API client and make request
|
||||
client := api.NewClient(ctx.Login)
|
||||
client, err := api.NewClient(ctx.Login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(request.Method, request.Endpoint, body, request.Headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,16 @@ token. Equivalent to running 'tea login helper setup' afterwards.`,
|
|||
Aliases: []string{"i"},
|
||||
Usage: "Disable TLS verification",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "client-cert",
|
||||
Sources: cli.EnvVars("GITEA_SERVER_CLIENT_CERT"),
|
||||
Usage: "Path to a PEM encoded TLS client certificate, for servers requiring mutual TLS",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "client-key",
|
||||
Sources: cli.EnvVars("GITEA_SERVER_CLIENT_KEY"),
|
||||
Usage: "Path to the PEM encoded private key for --client-cert",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ssh-agent-principal",
|
||||
Aliases: []string{"c"},
|
||||
|
|
@ -127,9 +137,11 @@ func runLoginAdd(requestCtx context.Context, cmd *cli.Command) error {
|
|||
// if OAuth flag is provided, use OAuth2 PKCE flow
|
||||
if cmd.Bool("oauth") {
|
||||
opts := auth.OAuthOptions{
|
||||
Name: cmd.String("name"),
|
||||
URL: cmd.String("url"),
|
||||
Insecure: cmd.Bool("insecure"),
|
||||
Name: cmd.String("name"),
|
||||
URL: cmd.String("url"),
|
||||
Insecure: cmd.Bool("insecure"),
|
||||
ClientCert: cmd.String("client-cert"),
|
||||
ClientKey: cmd.String("client-key"),
|
||||
}
|
||||
|
||||
// Only set clientID if provided
|
||||
|
|
@ -164,6 +176,8 @@ func runLoginAdd(requestCtx context.Context, cmd *cli.Command) error {
|
|||
cmd.String("ssh-agent-principal"),
|
||||
cmd.String("ssh-agent-key"),
|
||||
cmd.Bool("insecure"),
|
||||
cmd.String("client-cert"),
|
||||
cmd.String("client-key"),
|
||||
sshAgent,
|
||||
!cmd.Bool("no-version-check"),
|
||||
cmd.Bool("git-credentials"),
|
||||
|
|
|
|||
|
|
@ -39,8 +39,12 @@ List Gitea logins
|
|||
|
||||
Add a Gitea login
|
||||
|
||||
**--client-cert**="": Path to a PEM encoded TLS client certificate, for servers requiring mutual TLS
|
||||
|
||||
**--client-id**="": OAuth client ID (for use with --oauth)
|
||||
|
||||
**--client-key**="": Path to the PEM encoded private key for --client-cert
|
||||
|
||||
**--git-credentials, --helper, -j**: Register tea as a git credential helper for this login's URL, so 'git push' and 'git clone' over HTTPS authenticate silently using the stored token
|
||||
|
||||
**--insecure, -i**: Disable TLS verification
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
|
|
@ -24,21 +23,28 @@ type Client struct {
|
|||
}
|
||||
|
||||
// NewClient creates a new API client from a Login config
|
||||
func NewClient(login *config.Login) *Client {
|
||||
func NewClient(login *config.Login) (*Client, error) {
|
||||
// Refresh OAuth token if expired or near expiry
|
||||
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
|
||||
log.Printf("Warning: failed to refresh OAuth token: %v", err)
|
||||
}
|
||||
|
||||
// A misconfigured client certificate is fatal rather than a fallback to a
|
||||
// plain connection: the request would otherwise be rejected by the server
|
||||
// with the same opaque error the certificate is meant to avoid.
|
||||
tlsConfig, err := login.TLSConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: login.Insecure}),
|
||||
Transport: httputil.WrapTransport(tlsConfig),
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: strings.TrimSuffix(login.URL, "/"),
|
||||
token: login.GetAccessToken(),
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Do executes an HTTP request with authentication headers
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
|
|
@ -47,6 +46,8 @@ type OAuthOptions struct {
|
|||
Name string
|
||||
URL string
|
||||
Insecure bool
|
||||
ClientCert string
|
||||
ClientKey string
|
||||
ClientID string
|
||||
RedirectURL string
|
||||
Port int
|
||||
|
|
@ -77,7 +78,7 @@ func OAuthLoginWithFullOptions(ctx context.Context, opts OAuthOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return createLoginFromToken(ctx, opts.Name, serverURL, token, opts.Insecure)
|
||||
return createLoginFromToken(ctx, opts, serverURL, token)
|
||||
}
|
||||
|
||||
// performBrowserOAuthFlow performs the browser-based OAuth2 PKCE flow and returns the token.
|
||||
|
|
@ -127,7 +128,11 @@ func performBrowserOAuthFlow(ctx context.Context, opts OAuthOptions) (serverURL
|
|||
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||
|
||||
// Set up the OAuth2 config
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(opts.Insecure))
|
||||
httpClient, err := createHTTPClient(opts)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Configure the OAuth2 endpoints
|
||||
authURL := fmt.Sprintf("%s/login/oauth/authorize", normalizedURL)
|
||||
|
|
@ -198,11 +203,17 @@ func performBrowserOAuthFlow(ctx context.Context, opts OAuthOptions) (serverURL
|
|||
return serverURL, token, nil
|
||||
}
|
||||
|
||||
// createHTTPClient creates an HTTP client with optional insecure setting
|
||||
func createHTTPClient(insecure bool) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: insecure}),
|
||||
// createHTTPClient creates an HTTP client honoring the login's TLS settings,
|
||||
// so the OAuth flow reaches servers behind mutual-TLS proxies too.
|
||||
func createHTTPClient(opts OAuthOptions) (*http.Client, error) {
|
||||
l := config.Login{Insecure: opts.Insecure, ClientCert: opts.ClientCert, ClientKey: opts.ClientKey}
|
||||
tlsConfig, err := l.TLSConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: httputil.WrapTransport(tlsConfig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateCodeVerifier creates a cryptographically random string for PKCE
|
||||
|
|
@ -372,7 +383,8 @@ var openBrowser = func(url string) error {
|
|||
}
|
||||
|
||||
// createLoginFromToken creates a login entry using the obtained access token
|
||||
func createLoginFromToken(ctx context.Context, name, serverURL string, token *oauth2.Token, insecure bool) error {
|
||||
func createLoginFromToken(ctx context.Context, opts OAuthOptions, serverURL string, token *oauth2.Token) error {
|
||||
name := opts.Name
|
||||
if name == "" {
|
||||
var err error
|
||||
name, err = task.GenerateLoginName(serverURL, "")
|
||||
|
|
@ -387,7 +399,9 @@ func createLoginFromToken(ctx context.Context, name, serverURL string, token *oa
|
|||
URL: serverURL,
|
||||
Token: token.AccessToken, // temporarily set for Client() validation
|
||||
AuthMethod: config.AuthMethodOAuth,
|
||||
Insecure: insecure,
|
||||
Insecure: opts.Insecure,
|
||||
ClientCert: opts.ClientCert,
|
||||
ClientKey: opts.ClientKey,
|
||||
VersionCheck: true,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
|
|
@ -435,6 +449,8 @@ func ReauthenticateLogin(ctx context.Context, login *config.Login) error {
|
|||
Name: login.Name,
|
||||
URL: login.URL,
|
||||
Insecure: login.Insecure,
|
||||
ClientCert: login.ClientCert,
|
||||
ClientKey: login.ClientKey,
|
||||
ClientID: config.DefaultClientID,
|
||||
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
|
||||
Port: redirectPort,
|
||||
|
|
|
|||
|
|
@ -44,8 +44,14 @@ type Login struct {
|
|||
Default bool `yaml:"default"`
|
||||
SSHHost string `yaml:"ssh_host"`
|
||||
// optional path to the private key
|
||||
SSHKey string `yaml:"ssh_key"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
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"`
|
||||
|
|
@ -63,6 +69,52 @@ type Login struct {
|
|||
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
|
||||
|
|
@ -417,8 +469,12 @@ func doOAuthRefresh(ctx context.Context, l *Login) (*oauth2.Token, error) {
|
|||
Expiry: expiry,
|
||||
}
|
||||
|
||||
tlsConfig, err := l.TLSConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: l.Insecure}),
|
||||
Transport: httputil.WrapTransport(tlsConfig),
|
||||
}
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
|
|
@ -458,16 +514,16 @@ func (l *Login) ClientWithoutRefresh(options ...gitea.ClientOption) *gitea.Clien
|
|||
// 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(nil),
|
||||
Transport: httputil.WrapTransport(tlsConfig),
|
||||
}
|
||||
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}),
|
||||
}
|
||||
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
|
||||
|
|
|
|||
170
modules/config/login_tls_test.go
Normal file
170
modules/config/login_tls_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/modules/httputil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTLSConfigWithoutSettingsIsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := (&Login{}).TLSConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, cfg, "a login with default TLS settings must not override the transport's TLS config")
|
||||
}
|
||||
|
||||
func TestTLSConfigInsecureOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := (&Login{Insecure: true}).TLSConfig()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
assert.True(t, cfg.InsecureSkipVerify)
|
||||
assert.Empty(t, cfg.Certificates)
|
||||
}
|
||||
|
||||
func TestTLSConfigRequiresBothCertAndKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, login := range map[string]*Login{
|
||||
"cert without key": {ClientCert: "/tmp/client.crt"},
|
||||
"key without cert": {ClientKey: "/tmp/client.key"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := login.TLSConfig()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must be set together")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigReportsUnreadableCertificate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
login := &Login{
|
||||
ClientCert: filepath.Join(dir, "missing.crt"),
|
||||
ClientKey: filepath.Join(dir, "missing.key"),
|
||||
}
|
||||
|
||||
_, err := login.TLSConfig()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to load TLS client certificate")
|
||||
}
|
||||
|
||||
func TestTLSConfigLoadsClientCertificate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, certPath, keyPath := writeTestClientCert(t)
|
||||
|
||||
cfg, err := (&Login{ClientCert: certPath, ClientKey: keyPath}).TLSConfig()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
assert.False(t, cfg.InsecureSkipVerify)
|
||||
require.Len(t, cfg.Certificates, 1)
|
||||
}
|
||||
|
||||
// TestClientCertAuthenticatesAgainstMutualTLSServer is the end-to-end case from
|
||||
// issue #451: a server that rejects any client not presenting a certificate,
|
||||
// standing in for a reverse proxy configured with nginx' ssl_verify_client.
|
||||
func TestClientCertAuthenticatesAgainstMutualTLSServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientCert, certPath, keyPath := writeTestClientCert(t)
|
||||
|
||||
clientCAs := x509.NewCertPool()
|
||||
clientCAs.AddCert(clientCert)
|
||||
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.TLS = &tls.Config{
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: clientCAs,
|
||||
}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
// The server's own certificate is self-signed, so skip verification of it;
|
||||
// the point under test is the certificate tea presents, not the one it gets.
|
||||
withoutCert := &Login{URL: server.URL, Insecure: true}
|
||||
withCert := &Login{URL: server.URL, Insecure: true, ClientCert: certPath, ClientKey: keyPath}
|
||||
|
||||
_, err := doGet(t, withoutCert, server.URL)
|
||||
require.Error(t, err, "server must reject a client that presents no certificate")
|
||||
|
||||
resp, err := doGet(t, withCert, server.URL)
|
||||
require.NoError(t, err, "client certificate should satisfy the server's mutual TLS requirement")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func doGet(t *testing.T, login *Login, url string) (*http.Response, error) {
|
||||
t.Helper()
|
||||
|
||||
tlsConfig, err := login.TLSConfig()
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &http.Client{Transport: httputil.WrapTransport(tlsConfig)}
|
||||
return client.Get(url)
|
||||
}
|
||||
|
||||
// writeTestClientCert generates a self-signed certificate usable for client
|
||||
// authentication and writes the PEM pair into a temp dir.
|
||||
func writeTestClientCert(t *testing.T) (cert *x509.Certificate, certPath, keyPath string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "tea-test-client"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err = x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath = filepath.Join(dir, "client.crt")
|
||||
keyPath = filepath.Join(dir, "client.key")
|
||||
|
||||
require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600))
|
||||
|
||||
return cert, certPath, keyPath
|
||||
}
|
||||
|
|
@ -41,6 +41,8 @@ func GetLoginByEnvVar() *config.Login {
|
|||
Token: token,
|
||||
SSHHost: giteaInstanceSSHHost,
|
||||
Insecure: insecure,
|
||||
ClientCert: os.Getenv("GITEA_INSTANCE_CLIENT_CERT"),
|
||||
ClientKey: os.Getenv("GITEA_INSTANCE_CLIENT_KEY"),
|
||||
SSHKey: "",
|
||||
SSHCertPrincipal: "",
|
||||
SSHKeyFingerprint: "",
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func TestCanSwitchBackends(t *testing.T) {
|
|||
require.Equal(t, "open:demo", repo.WorkTree())
|
||||
require.Equal(t, "fake", CurrentBackendName())
|
||||
|
||||
cloned, err := Clone("target", "https://example.com/repo.git", nil, 1, false)
|
||||
cloned, err := Clone("target", "https://example.com/repo.git", nil, CloneOptions{Depth: 1})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "clone:target:https://example.com/repo.git", cloned.WorkTree())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,25 @@ func (b cliBackend) Open(path string) (RepositoryBackend, error) {
|
|||
return &cliRepository{workTree: strings.TrimSpace(out)}, nil
|
||||
}
|
||||
|
||||
func (b cliBackend) Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error) {
|
||||
extraConfigs := make([]string, 0, 1)
|
||||
// cloneTLSConfigs translates the TLS related clone options into "git -c"
|
||||
// settings, so a clone against a server with a private CA or one demanding a
|
||||
// client certificate uses the same material as tea's own API requests.
|
||||
func cloneTLSConfigs(opts CloneOptions) []string {
|
||||
configs := make([]string, 0, 3)
|
||||
if opts.Insecure {
|
||||
extraConfigs = append(extraConfigs, "http.sslVerify=false")
|
||||
configs = append(configs, "http.sslVerify=false")
|
||||
}
|
||||
if opts.ClientCert != "" {
|
||||
configs = append(configs, "http.sslCert="+opts.ClientCert)
|
||||
}
|
||||
if opts.ClientKey != "" {
|
||||
configs = append(configs, "http.sslKey="+opts.ClientKey)
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
func (b cliBackend) Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error) {
|
||||
extraConfigs := cloneTLSConfigs(opts)
|
||||
|
||||
args := []string{"clone"}
|
||||
if opts.Depth > 0 {
|
||||
|
|
|
|||
42
modules/git/clone_tls_test.go
Normal file
42
modules/git/clone_tls_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCloneTLSConfigs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
opts CloneOptions
|
||||
want []string
|
||||
}{
|
||||
"defaults add nothing": {
|
||||
opts: CloneOptions{},
|
||||
want: []string{},
|
||||
},
|
||||
"insecure disables verification": {
|
||||
opts: CloneOptions{Insecure: true},
|
||||
want: []string{"http.sslVerify=false"},
|
||||
},
|
||||
"client certificate is passed to git": {
|
||||
opts: CloneOptions{ClientCert: "/certs/client.crt", ClientKey: "/certs/client.key"},
|
||||
want: []string{"http.sslCert=/certs/client.crt", "http.sslKey=/certs/client.key"},
|
||||
},
|
||||
"client certificate combines with insecure": {
|
||||
opts: CloneOptions{Insecure: true, ClientCert: "/certs/client.crt", ClientKey: "/certs/client.key"},
|
||||
want: []string{"http.sslVerify=false", "http.sslCert=/certs/client.crt", "http.sslKey=/certs/client.key"},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tc.want, cloneTLSConfigs(tc.opts))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
package git
|
||||
|
||||
// Clone clones a repository using the active backend.
|
||||
func Clone(path, remoteURL string, auth *AuthMethod, depth int, insecure bool) (*TeaRepo, error) {
|
||||
backend, err := currentBackend().Clone(path, remoteURL, auth, CloneOptions{Depth: depth, Insecure: insecure})
|
||||
func Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (*TeaRepo, error) {
|
||||
backend, err := currentBackend().Clone(path, remoteURL, auth, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ type AuthMethod struct {
|
|||
type CloneOptions struct {
|
||||
Depth int
|
||||
Insecure bool
|
||||
// ClientCert and ClientKey are paths to a PEM encoded TLS client
|
||||
// certificate and its key, passed to git as http.sslCert/http.sslKey so
|
||||
// clones from mutual-TLS protected servers succeed.
|
||||
ClientCert string
|
||||
ClientKey string
|
||||
}
|
||||
|
||||
// RepositoryBackend is the backend abstraction used by TeaRepo.
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ func CreateLogin(ctx context.Context) error {
|
|||
printTitleAndContent("Check version of Gitea instance:", strconv.FormatBool(versionCheck))
|
||||
}
|
||||
|
||||
return task.CreateLogin(ctx, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent, versionCheck, helper)
|
||||
return task.CreateLogin(ctx, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, "", "", sshAgent, versionCheck, helper)
|
||||
}
|
||||
|
||||
func parseSSHPubkeySelection(display string) (sshKey, sshCertPrincipal, sshKeyFingerprint string, sshAgent bool, err error) {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ func HasGitCredentialHelper(login config.Login) bool {
|
|||
}
|
||||
|
||||
// CreateLogin create a login to be stored in config
|
||||
func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error {
|
||||
func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure bool, clientCert, clientKey string, sshAgent, versionCheck, addHelper bool) error {
|
||||
// checks ...
|
||||
// ... if we have a url
|
||||
if len(giteaURL) == 0 {
|
||||
|
|
@ -120,6 +120,8 @@ func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, ssh
|
|||
URL: serverURL.String(),
|
||||
Token: token,
|
||||
Insecure: insecure,
|
||||
ClientCert: clientCert,
|
||||
ClientKey: clientKey,
|
||||
SSHKey: sshKey,
|
||||
SSHCertPrincipal: sshCertPrincipal,
|
||||
SSHKeyFingerprint: sshKeyFingerprint,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,12 @@ func RepoClone(
|
|||
path = repoName
|
||||
}
|
||||
|
||||
repo, err := local_git.Clone(path, originURL.String(), auth, depth, login.Insecure)
|
||||
repo, err := local_git.Clone(path, originURL.String(), auth, local_git.CloneOptions{
|
||||
Depth: depth,
|
||||
Insecure: login.Insecure,
|
||||
ClientCert: login.ClientCert,
|
||||
ClientKey: login.ClientKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ func createIntegrationLogin(t *testing.T) *config.Login {
|
|||
|
||||
require.NotEmpty(t, integrationToken, "integration token setup failed")
|
||||
|
||||
require.NoError(t, task.CreateLogin(t.Context(), "integration", integrationToken, "", "", "", "", "", integrationGiteaURL, "", "", true, false, false, false))
|
||||
require.NoError(t, task.CreateLogin(t.Context(), "integration", integrationToken, "", "", "", "", "", integrationGiteaURL, "", "", true, "", "", false, false, false))
|
||||
|
||||
login, err := config.GetLoginByName("integration")
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
Loading…
Reference in a new issue