Implement TLS API Support

* Added flags to point to TLS PEM files to use for exposing and connecting
  to an encrypted remote API socket with server and client authentication.
* Added TLS fields for system connection ls templates.
* Added special "tls" format for system connection ls to list TLS fields
  in human-readable table format.
* Updated remote integration and system tests to allow specifying a
  "transport" to run the full suite against a unix, tcp, tls, or mtls
  system service.
* Added system tests to verify basic operation of unix, tcp, tls, and mtls
  services, clients, and connections.

Signed-off-by: Andrew Melnick <meln5674.5674@gmail.com>
This commit is contained in:
Andrew Melnick
2025-07-31 18:51:37 -06:00
parent a118fdf4e2
commit feb36e4fe6
116 changed files with 1848 additions and 616 deletions

View File

@ -4,6 +4,7 @@ package server
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
@ -22,6 +23,7 @@ import (
"github.com/containers/podman/v5/pkg/api/server/idle"
"github.com/containers/podman/v5/pkg/api/types"
"github.com/containers/podman/v5/pkg/domain/entities"
"github.com/containers/podman/v5/pkg/util/tlsutil"
"github.com/coreos/go-systemd/v22/daemon"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
@ -38,6 +40,9 @@ type APIServer struct {
CorsHeaders string // Inject Cross-Origin Resource Sharing (CORS) headers
PProfAddr string // Binding network address for pprof profiles
idleTracker *idle.Tracker // Track connections to support idle shutdown
tlsCertFile string // TLS serving certificate PEM file
tlsKeyFile string // TLS serving certificate private key PEM file
tlsClientCAFile string // TLS client certifiicate CA bundle PEM file
}
// Number of seconds to wait for next request, if exceeded shutdown server
@ -76,10 +81,13 @@ func newServer(runtime *libpod.Runtime, listener net.Listener, opts entities.Ser
Handler: router,
IdleTimeout: opts.Timeout * 2,
},
CorsHeaders: opts.CorsHeaders,
Listener: listener,
PProfAddr: opts.PProfAddr,
idleTracker: tracker,
CorsHeaders: opts.CorsHeaders,
Listener: listener,
PProfAddr: opts.PProfAddr,
idleTracker: tracker,
tlsCertFile: opts.TLSCertFile,
tlsKeyFile: opts.TLSKeyFile,
tlsClientCAFile: opts.TLSClientCAFile,
}
server.BaseContext = func(l net.Listener) context.Context {
@ -90,6 +98,18 @@ func newServer(runtime *libpod.Runtime, listener net.Listener, opts entities.Ser
return ctx
}
if opts.TLSClientCAFile != "" {
logrus.Debugf("will validate client certs against %s", opts.TLSClientCAFile)
pool, err := tlsutil.ReadCertBundle(opts.TLSClientCAFile)
if err != nil {
return nil, err
}
server.TLSConfig = &tls.Config{
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
}
}
// Capture panics and print stack traces for diagnostics,
// additionally process X-Reference-Id Header to support event correlation
router.Use(panicHandler(), referenceIDHandler())
@ -217,7 +237,15 @@ func (s *APIServer) Serve() error {
errChan := make(chan error, 1)
s.setupSystemd()
go func() {
err := s.Server.Serve(s.Listener)
var err error
if s.tlsClientCAFile != "" || (s.tlsCertFile != "" && s.tlsKeyFile != "") {
if s.tlsCertFile != "" && s.tlsKeyFile != "" {
logrus.Debugf("serving TLS with cert %s and key %s", s.tlsCertFile, s.tlsKeyFile)
}
err = s.Server.ServeTLS(s.Listener, s.tlsCertFile, s.tlsKeyFile)
} else {
err = s.Server.Serve(s.Listener)
}
if err != nil && err != http.ErrServerClosed {
errChan <- fmt.Errorf("failed to start API service: %w", err)
return

View File

@ -3,6 +3,7 @@ package bindings
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
@ -17,6 +18,7 @@ import (
"time"
"github.com/blang/semver/v4"
"github.com/containers/podman/v5/pkg/util/tlsutil"
"github.com/containers/podman/v5/version"
"github.com/kevinburke/ssh_config"
"github.com/sirupsen/logrus"
@ -33,6 +35,7 @@ type APIResponse struct {
type Connection struct {
URI *url.URL
Client *http.Client
tls bool
}
type valueKey string
@ -89,7 +92,7 @@ func JoinURL(elements ...string) string {
// NewConnection creates a new service connection without an identity
func NewConnection(ctx context.Context, uri string) (context.Context, error) {
return NewConnectionWithIdentity(ctx, uri, "", false)
return NewConnectionWithOptions(ctx, Options{URI: uri})
}
// NewConnectionWithIdentity takes a URI as a string and returns a context with the
@ -101,14 +104,31 @@ func NewConnection(ctx context.Context, uri string) (context.Context, error) {
// or unix:///run/podman/podman.sock
// or ssh://<user>@<host>[:port]/run/podman/podman.sock
func NewConnectionWithIdentity(ctx context.Context, uri string, identity string, machine bool) (context.Context, error) {
var err error
if v, found := os.LookupEnv("CONTAINER_HOST"); found && uri == "" {
uri = v
}
return NewConnectionWithOptions(ctx, Options{URI: uri, Identity: identity, Machine: machine})
}
if v, found := os.LookupEnv("CONTAINER_SSHKEY"); found && len(identity) == 0 {
identity = v
type Options struct {
URI string
Identity string
TLSCertFile string
TLSKeyFile string
TLSCAFile string
Machine bool
}
func orEnv(s string, env string) string {
if len(s) != 0 {
return s
}
s, _ = os.LookupEnv(env)
return s
}
func NewConnectionWithOptions(ctx context.Context, opts Options) (context.Context, error) {
var err error
uri := orEnv(opts.URI, "CONTAINER_HOST")
identity := orEnv(opts.Identity, "CONTAINER_SSHKEY")
_url, err := url.Parse(uri)
if err != nil {
@ -119,7 +139,7 @@ func NewConnectionWithIdentity(ctx context.Context, uri string, identity string,
var connection Connection
switch _url.Scheme {
case "ssh":
conn, err := sshClient(_url, uri, identity, machine)
conn, err := sshClient(_url, uri, identity, opts.Machine)
if err != nil {
return nil, err
}
@ -135,7 +155,7 @@ func NewConnectionWithIdentity(ctx context.Context, uri string, identity string,
if !strings.HasPrefix(uri, "tcp://") {
return nil, errors.New("tcp URIs should begin with tcp://")
}
conn, err := tcpClient(_url)
conn, err := tcpClient(_url, opts.TLSCertFile, opts.TLSKeyFile, opts.TLSCAFile)
if err != nil {
return nil, newConnectError(err)
}
@ -151,7 +171,7 @@ func NewConnectionWithIdentity(ctx context.Context, uri string, identity string,
}
ctx = context.WithValue(ctx, versionKey, serviceVersion)
ctx = context.WithValue(ctx, machineModeKey, machine)
ctx = context.WithValue(ctx, machineModeKey, opts.Machine)
return ctx, nil
}
@ -288,7 +308,7 @@ func sshClient(_url *url.URL, uri string, identity string, machine bool) (Connec
return connection, nil
}
func tcpClient(_url *url.URL) (Connection, error) {
func tcpClient(_url *url.URL, tlsCertFile, tlsKeyFile, tlsCAFile string) (Connection, error) {
connection := Connection{
URI: _url,
}
@ -320,11 +340,34 @@ func tcpClient(_url *url.URL) (Connection, error) {
}
}
}
transport := http.Transport{
DialContext: dialContext,
DisableCompression: true,
}
if len(tlsCAFile) != 0 || len(tlsCertFile) != 0 || len(tlsKeyFile) != 0 {
logrus.Debugf("using TLS cert=%s key=%s ca=%s", tlsCertFile, tlsKeyFile, tlsCAFile)
transport.TLSClientConfig = &tls.Config{}
connection.tls = true
}
if len(tlsCAFile) != 0 {
pool, err := tlsutil.ReadCertBundle(tlsCAFile)
if err != nil {
return connection, fmt.Errorf("unable to read CA bundle: %w", err)
}
transport.TLSClientConfig.RootCAs = pool
}
if (len(tlsCertFile) == 0) != (len(tlsKeyFile) == 0) {
return connection, fmt.Errorf("TLS Key and Certificate must both or neither be provided")
}
if len(tlsCertFile) != 0 && len(tlsKeyFile) != 0 {
keyPair, err := tls.LoadX509KeyPair(tlsCertFile, tlsKeyFile)
if err != nil {
return connection, fmt.Errorf("unable to read TLS key pair: %w", err)
}
transport.TLSClientConfig.Certificates = append(transport.TLSClientConfig.Certificates, keyPair)
}
connection.Client = &http.Client{
Transport: &http.Transport{
DialContext: dialContext,
DisableCompression: true,
},
Transport: &transport,
}
return connection, nil
}
@ -405,8 +448,14 @@ func (c *Connection) DoRequest(ctx context.Context, httpBody io.Reader, httpMeth
baseURL := "http://d"
if c.URI.Scheme == "tcp" {
var scheme string
if c.tls {
scheme = "https"
} else {
scheme = "http"
}
// Allow path prefixes for tcp connections to match Docker behavior
baseURL = "http://" + c.URI.Host + c.URI.Path
baseURL = scheme + "://" + c.URI.Host + c.URI.Path
}
uri := fmt.Sprintf(baseURL+"/v%s/libpod"+endpoint, params...)
logrus.Debugf("DoRequest Method: %s URI: %v", httpMethod, uri)

View File

@ -3,6 +3,7 @@ package containers
import (
"bytes"
"context"
"crypto/tls"
"encoding/binary"
"errors"
"fmt"
@ -548,9 +549,11 @@ func newUpgradeRequest(ctx context.Context, conn *bindings.Connection, body io.R
"Upgrade": []string{"tcp"},
}
// FIXME: This is one giant race condition. Let's hope no-one uses this same client until we're done!
var socket net.Conn
socketSet := false
dialContext := conn.Client.Transport.(*http.Transport).DialContext
tlsConfig := conn.Client.Transport.(*http.Transport).TLSClientConfig
t := &http.Transport{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
c, err := dialContext(ctx, network, address)
@ -563,7 +566,33 @@ func newUpgradeRequest(ctx context.Context, conn *bindings.Connection, body io.R
}
return c, err
},
DialTLSContext: func(ctx context.Context, network, address string) (net.Conn, error) {
c, err := dialContext(ctx, network, address)
if err != nil {
return nil, err
}
var cfg *tls.Config
if tlsConfig == nil {
cfg = new(tls.Config)
} else {
cfg = tlsConfig.Clone()
}
if cfg.ServerName == "" {
var firstTLSHost string
if firstTLSHost, _, err = net.SplitHostPort(address); err != nil {
return nil, err
}
cfg.ServerName = firstTLSHost
}
c = tls.Client(c, cfg)
if !socketSet {
socket = c
socketSet = true
}
return c, err
},
IdleConnTimeout: time.Duration(0),
TLSClientConfig: tlsConfig,
}
conn.Client.Transport = t
response, err := conn.DoRequest(ctx, body, http.MethodPost, path, params, headers)

View File

@ -37,6 +37,9 @@ type PodmanConfig struct {
HooksDir []string
CdiSpecDirs []string
Identity string // ssh identity for connecting to server
TLSCertFile string // tls client cert for connecting to server
TLSKeyFile string // tls client cert private key for connection to server
TLSCAFile string // tls certificate authority to verify server connection
IsRenumber bool // Is this a system renumber command? If so, a number of checks will be relaxed
IsReset bool // Is this a system reset command? If so, a number of checks will be skipped/omitted
MaxWorks int // maximum number of parallel threads

View File

@ -9,10 +9,13 @@ import (
// ServiceOptions provides the input for starting an API and sidecar pprof services
type ServiceOptions struct {
CorsHeaders string // Cross-Origin Resource Sharing (CORS) headers
PProfAddr string // Network address to bind pprof profiles service
Timeout time.Duration // Duration of inactivity the service should wait before shutting down
URI string // Path to unix domain socket service should listen on
CorsHeaders string // Cross-Origin Resource Sharing (CORS) headers
PProfAddr string // Network address to bind pprof profiles service
Timeout time.Duration // Duration of inactivity the service should wait before shutting down
URI string // Path to unix domain socket service should listen on
TLSCertFile string // Path to serving certificate PEM file
TLSKeyFile string // Path to serving certificate key PEM file
TLSClientCAFile string // Path to client certificate authority
}
// SystemCheckOptions provides options for checking storage consistency.

View File

@ -18,7 +18,14 @@ func NewContainerEngine(facts *entities.PodmanConfig) (entities.ContainerEngine,
r, err := NewLibpodRuntime(facts.FlagSet, facts)
return r, err
case entities.TunnelMode:
ctx, err := bindings.NewConnectionWithIdentity(context.Background(), facts.URI, facts.Identity, facts.MachineMode)
ctx, err := bindings.NewConnectionWithOptions(context.Background(), bindings.Options{
URI: facts.URI,
Identity: facts.Identity,
TLSCertFile: facts.TLSCertFile,
TLSKeyFile: facts.TLSKeyFile,
TLSCAFile: facts.TLSCAFile,
Machine: facts.MachineMode,
})
return &tunnel.ContainerEngine{ClientCtx: ctx}, err
}
return nil, fmt.Errorf("runtime mode '%v' is not supported", facts.EngineMode)
@ -32,7 +39,14 @@ func NewImageEngine(facts *entities.PodmanConfig) (entities.ImageEngine, error)
return r, err
case entities.TunnelMode:
// TODO: look at me!
ctx, err := bindings.NewConnectionWithIdentity(context.Background(), facts.URI, facts.Identity, facts.MachineMode)
ctx, err := bindings.NewConnectionWithOptions(context.Background(), bindings.Options{
URI: facts.URI,
Identity: facts.Identity,
TLSCertFile: facts.TLSCertFile,
TLSKeyFile: facts.TLSKeyFile,
TLSCAFile: facts.TLSCAFile,
Machine: facts.MachineMode,
})
if err != nil {
return nil, fmt.Errorf("%w: %s", err, facts.URI)
}

View File

@ -17,13 +17,20 @@ var (
connection *context.Context
)
func newConnection(uri string, identity, farmNodeName string, machine bool) (context.Context, error) {
func newConnection(uri string, identity, tlsCertFile, tlsKeyFile, tlsCAFile, farmNodeName string, machine bool) (context.Context, error) {
connectionMutex.Lock()
defer connectionMutex.Unlock()
// if farmNodeName given, then create a connection with the node so that we can send builds there
if connection == nil || farmNodeName != "" {
ctx, err := bindings.NewConnectionWithIdentity(context.Background(), uri, identity, machine)
ctx, err := bindings.NewConnectionWithOptions(context.Background(), bindings.Options{
URI: uri,
Identity: identity,
TLSCertFile: tlsCertFile,
TLSKeyFile: tlsKeyFile,
TLSCAFile: tlsCAFile,
Machine: machine,
})
if err != nil {
return ctx, err
}
@ -37,7 +44,7 @@ func NewContainerEngine(facts *entities.PodmanConfig) (entities.ContainerEngine,
case entities.ABIMode:
return nil, fmt.Errorf("direct runtime not supported")
case entities.TunnelMode:
ctx, err := newConnection(facts.URI, facts.Identity, "", facts.MachineMode)
ctx, err := newConnection(facts.URI, facts.Identity, facts.TLSCertFile, facts.TLSKeyFile, facts.TLSCAFile, "", facts.MachineMode)
return &tunnel.ContainerEngine{ClientCtx: ctx}, err
}
return nil, fmt.Errorf("runtime mode '%v' is not supported", facts.EngineMode)
@ -49,7 +56,7 @@ func NewImageEngine(facts *entities.PodmanConfig) (entities.ImageEngine, error)
case entities.ABIMode:
return nil, fmt.Errorf("direct image runtime not supported")
case entities.TunnelMode:
ctx, err := newConnection(facts.URI, facts.Identity, facts.FarmNodeName, facts.MachineMode)
ctx, err := newConnection(facts.URI, facts.Identity, facts.TLSCertFile, facts.TLSKeyFile, facts.TLSCAFile, facts.FarmNodeName, facts.MachineMode)
return &tunnel.ImageEngine{ClientCtx: ctx, FarmNode: tunnel.FarmNode{NodeName: facts.FarmNodeName}}, err
}
return nil, fmt.Errorf("runtime mode '%v' is not supported", facts.EngineMode)

32
pkg/util/tlsutil/tls.go Normal file
View File

@ -0,0 +1,32 @@
package tlsutil
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
)
func ReadCertBundle(path string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
caPEM, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading cert bundle %s: %w", path, err)
}
for ix := 0; len(caPEM) != 0; ix++ {
var caDER *pem.Block
caDER, caPEM = pem.Decode(caPEM)
if caDER == nil {
return nil, fmt.Errorf("reading cert bundle %s: non-PEM data found", path)
}
if caDER.Type != "CERTIFICATE" {
return nil, fmt.Errorf("reading cert bundle %s: non-certificate type `%s` PEM data found", path, caDER.Type)
}
caCert, err := x509.ParseCertificate(caDER.Bytes)
if err != nil {
return nil, fmt.Errorf("reading cert bundle %s: parsing item %d: %w", path, ix, err)
}
pool.AddCert(caCert)
}
return pool, nil
}