This commit implements multi-tenancy. A tenant is defined based on a value from JWT claims. The key of this value must be specified in the configuration file. This allows us to pick different values when using multiple SSOs. Let me explain more in depth how this works: Cloud API gets a new compose request. Firstly, it extracts a tenant name from JWT claims. The considered claims are configured as an array in cloud_api.jwt.tenant_provider_fields in composer's config file. The channel name for all jobs belonging to this compose is created by `"org-" + tenant`. Why is the channel prefixed by "org-"? To give us options in the future. I can imagine the request having a channel override. This basically means that multiple tenants can share a channel. A real use-case for this is multiple Fedora projects sharing one pool of workers. Why this commit adds a whole new cloud_api section to the config? Because the current config is a mess and we should stop adding new stuff into the koji section. As the Koji API is basically deprecated, we will need to remove it soon nevertheless. Signed-off-by: Ondřej Budai <ondrej@budai.cz>
379 lines
9.5 KiB
Go
379 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
|
|
logrus "github.com/sirupsen/logrus"
|
|
|
|
"github.com/osbuild/osbuild-composer/internal/auth"
|
|
"github.com/osbuild/osbuild-composer/internal/cloudapi"
|
|
v2 "github.com/osbuild/osbuild-composer/internal/cloudapi/v2"
|
|
"github.com/osbuild/osbuild-composer/internal/distroregistry"
|
|
"github.com/osbuild/osbuild-composer/internal/jobqueue"
|
|
"github.com/osbuild/osbuild-composer/internal/jobqueue/dbjobqueue"
|
|
"github.com/osbuild/osbuild-composer/internal/jobqueue/fsjobqueue"
|
|
"github.com/osbuild/osbuild-composer/internal/kojiapi"
|
|
"github.com/osbuild/osbuild-composer/internal/rpmmd"
|
|
"github.com/osbuild/osbuild-composer/internal/weldr"
|
|
"github.com/osbuild/osbuild-composer/internal/worker"
|
|
)
|
|
|
|
type Composer struct {
|
|
config *ComposerConfigFile
|
|
stateDir string
|
|
cacheDir string
|
|
logger *log.Logger
|
|
distros *distroregistry.Registry
|
|
|
|
rpm rpmmd.RPMMD
|
|
|
|
workers *worker.Server
|
|
weldr *weldr.API
|
|
api *cloudapi.Server
|
|
koji *kojiapi.Server
|
|
|
|
weldrListener, localWorkerListener, workerListener, apiListener net.Listener
|
|
}
|
|
|
|
func NewComposer(config *ComposerConfigFile, stateDir, cacheDir string) (*Composer, error) {
|
|
c := Composer{
|
|
config: config,
|
|
stateDir: stateDir,
|
|
cacheDir: cacheDir,
|
|
}
|
|
|
|
workerConfig := worker.Config{
|
|
BasePath: config.Worker.BasePath,
|
|
JWTEnabled: config.Worker.EnableJWT,
|
|
TenantProviderFields: config.Worker.JWTTenantProviderFields,
|
|
}
|
|
|
|
var err error
|
|
if config.Worker.EnableArtifacts {
|
|
workerConfig.ArtifactsDir, err = c.ensureStateDirectory("artifacts", 0755)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
c.distros = distroregistry.NewDefault()
|
|
logrus.Infof("Loaded %d distros", len(c.distros.List()))
|
|
|
|
c.rpm = rpmmd.NewRPMMD(path.Join(c.cacheDir, "rpmmd"))
|
|
|
|
var jobs jobqueue.JobQueue
|
|
if config.Worker.PGDatabase != "" {
|
|
dbURL := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
|
|
config.Worker.PGUser,
|
|
config.Worker.PGPassword,
|
|
config.Worker.PGHost,
|
|
config.Worker.PGPort,
|
|
config.Worker.PGDatabase,
|
|
config.Worker.PGSSLMode,
|
|
)
|
|
|
|
if config.Worker.PGMaxConns > 0 {
|
|
dbURL += fmt.Sprintf("&pool_max_conns=%d", config.Worker.PGMaxConns)
|
|
}
|
|
|
|
jobs, err = dbjobqueue.New(dbURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create jobqueue: %v", err)
|
|
}
|
|
} else {
|
|
queueDir, err := c.ensureStateDirectory("jobs", 0700)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jobs, err = fsjobqueue.New(queueDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create jobqueue: %v", err)
|
|
}
|
|
}
|
|
|
|
workerConfig.RequestJobTimeout, err = time.ParseDuration(config.Worker.RequestJobTimeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Unable to parse request job timeout: %v", err)
|
|
}
|
|
|
|
c.workers = worker.NewServer(c.logger, jobs, workerConfig)
|
|
|
|
return &c, nil
|
|
}
|
|
|
|
func (c *Composer) InitWeldr(repoPaths []string, weldrListener net.Listener,
|
|
distrosImageTypeDenylist map[string][]string) (err error) {
|
|
c.weldr, err = weldr.New(repoPaths, c.stateDir, c.rpm, c.distros, c.logger, c.workers, distrosImageTypeDenylist)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.weldrListener = weldrListener
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Composer) InitAPI(cert, key string, enableTLS bool, enableMTLS bool, enableJWT bool, l net.Listener) error {
|
|
config := v2.ServerConfig{
|
|
AWSBucket: c.config.Koji.AWS.Bucket,
|
|
JWTEnabled: c.config.Koji.EnableJWT,
|
|
TenantProviderFields: c.config.Koji.JWTTenantProviderFields,
|
|
}
|
|
|
|
c.api = cloudapi.NewServer(c.workers, c.distros, config)
|
|
c.koji = kojiapi.NewServer(c.logger, c.workers, c.rpm, c.distros)
|
|
|
|
if !enableTLS {
|
|
c.apiListener = l
|
|
return nil
|
|
}
|
|
|
|
// If both are off or both are on, error out
|
|
if enableJWT == enableMTLS {
|
|
return fmt.Errorf("API: Either mTLS or JWT authentication must be enabled")
|
|
}
|
|
|
|
clientAuth := tls.RequireAndVerifyClientCert
|
|
if enableJWT {
|
|
// jwt enabled => tls listener without client auth
|
|
clientAuth = tls.NoClientCert
|
|
}
|
|
|
|
tlsConfig, err := createTLSConfig(&connectionConfig{
|
|
CACertFile: c.config.Koji.CA,
|
|
ServerKeyFile: key,
|
|
ServerCertFile: cert,
|
|
AllowedDomains: c.config.Koji.AllowedDomains,
|
|
ClientAuth: clientAuth,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("Error creating TLS configuration: %v", err)
|
|
}
|
|
|
|
c.apiListener = tls.NewListener(l, tlsConfig)
|
|
return nil
|
|
}
|
|
|
|
func (c *Composer) InitLocalWorker(l net.Listener) {
|
|
c.localWorkerListener = l
|
|
}
|
|
|
|
func (c *Composer) InitRemoteWorkers(cert, key string, enableTLS bool, enableMTLS bool, enableJWT bool, l net.Listener) error {
|
|
if !enableTLS {
|
|
c.workerListener = l
|
|
return nil
|
|
}
|
|
|
|
// If both are off or both are on, error out
|
|
if enableJWT == enableMTLS {
|
|
return fmt.Errorf("Remote worker API: Either mTLS or JWT authentication must be enabled")
|
|
}
|
|
|
|
clientAuth := tls.RequireAndVerifyClientCert
|
|
if enableJWT {
|
|
// jwt enabled => tls listener without client auth
|
|
clientAuth = tls.NoClientCert
|
|
}
|
|
|
|
tlsConfig, err := createTLSConfig(&connectionConfig{
|
|
CACertFile: c.config.Worker.CA,
|
|
ServerKeyFile: key,
|
|
ServerCertFile: cert,
|
|
AllowedDomains: c.config.Worker.AllowedDomains,
|
|
ClientAuth: clientAuth,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("Error creating TLS configuration for remote worker API: %v", err)
|
|
}
|
|
c.workerListener = tls.NewListener(l, tlsConfig)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Start Composer with all the APIs that had their respective Init*() called.
|
|
//
|
|
// Running without the weldr API is currently not supported.
|
|
func (c *Composer) Start() error {
|
|
// sanity checks
|
|
if c.localWorkerListener == nil && c.workerListener == nil {
|
|
logrus.Fatal("neither the local worker socket nor the remote worker socket is enabled, osbuild-composer is useless without workers")
|
|
}
|
|
|
|
if c.apiListener == nil && c.weldrListener == nil {
|
|
logrus.Fatal("neither the weldr API socket nor the composer API socket is enabled, osbuild-composer is useless without one of these APIs enabled")
|
|
}
|
|
|
|
if c.localWorkerListener != nil {
|
|
go func() {
|
|
s := &http.Server{
|
|
ErrorLog: c.logger,
|
|
Handler: c.workers.Handler(),
|
|
}
|
|
err := s.Serve(c.localWorkerListener)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
if c.workerListener != nil {
|
|
go func() {
|
|
handler := c.workers.Handler()
|
|
var err error
|
|
if c.config.Worker.EnableJWT {
|
|
keysURLs := c.config.Worker.JWTKeysURLs
|
|
handler, err = auth.BuildJWTAuthHandler(
|
|
keysURLs,
|
|
c.config.Worker.JWTKeysCA,
|
|
c.config.Worker.JWTACLFile,
|
|
[]string{
|
|
"/api/image-builder-worker/v1/openapi/?$",
|
|
},
|
|
handler,
|
|
)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
s := &http.Server{
|
|
ErrorLog: c.logger,
|
|
Handler: handler,
|
|
}
|
|
err = s.Serve(c.workerListener)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
if c.apiListener != nil {
|
|
go func() {
|
|
const apiRouteV2 = "/api/image-builder-composer/v2"
|
|
const kojiRoute = "/api/composer-koji/v1"
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// Add a "/" here, because http.ServeMux expects the
|
|
// trailing slash for rooted subtrees, whereas the
|
|
// handler functions don't.
|
|
mux.Handle(apiRouteV2+"/", c.api.V2(apiRouteV2))
|
|
mux.Handle(kojiRoute+"/", c.koji.Handler(kojiRoute))
|
|
|
|
// Metrics handler attached to api mux to avoid a
|
|
// separate listener/socket
|
|
mux.Handle("/metrics", promhttp.Handler().(http.HandlerFunc))
|
|
|
|
handler := http.Handler(mux)
|
|
var err error
|
|
if c.config.Koji.EnableJWT {
|
|
keysURLs := c.config.Koji.JWTKeysURLs
|
|
handler, err = auth.BuildJWTAuthHandler(
|
|
keysURLs,
|
|
c.config.Koji.JWTKeysCA,
|
|
c.config.Koji.JWTACLFile,
|
|
[]string{
|
|
"/api/image-builder-composer/v2/openapi/?$",
|
|
"/api/image-builder-composer/v2/errors/?$",
|
|
"/metrics/?$",
|
|
}, mux)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
s := &http.Server{
|
|
ErrorLog: c.logger,
|
|
Handler: handler,
|
|
}
|
|
err = s.Serve(c.apiListener)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
if c.weldrListener != nil {
|
|
go func() {
|
|
err := c.weldr.Serve(c.weldrListener)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// wait indefinitely
|
|
select {}
|
|
}
|
|
|
|
func (c *Composer) ensureStateDirectory(name string, perm os.FileMode) (string, error) {
|
|
d := path.Join(c.stateDir, name)
|
|
|
|
err := os.Mkdir(d, perm)
|
|
if err != nil && !os.IsExist(err) {
|
|
return "", fmt.Errorf("cannot create state directory %s: %v", name, err)
|
|
}
|
|
|
|
return d, nil
|
|
}
|
|
|
|
type connectionConfig struct {
|
|
// CA used for client certificate validation. If empty, then the CAs
|
|
// trusted by the host system are used.
|
|
CACertFile string
|
|
|
|
ServerKeyFile string
|
|
ServerCertFile string
|
|
AllowedDomains []string
|
|
ClientAuth tls.ClientAuthType
|
|
}
|
|
|
|
func createTLSConfig(c *connectionConfig) (*tls.Config, error) {
|
|
var roots *x509.CertPool
|
|
|
|
if c.CACertFile != "" {
|
|
caCertPEM, err := ioutil.ReadFile(c.CACertFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
roots = x509.NewCertPool()
|
|
ok := roots.AppendCertsFromPEM(caCertPEM)
|
|
if !ok {
|
|
panic("failed to parse root certificate")
|
|
}
|
|
}
|
|
|
|
cert, err := tls.LoadX509KeyPair(c.ServerCertFile, c.ServerKeyFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
ClientAuth: c.ClientAuth,
|
|
ClientCAs: roots,
|
|
MinVersion: tls.VersionTLS12,
|
|
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
|
|
for _, chain := range verifiedChains {
|
|
for _, domain := range c.AllowedDomains {
|
|
if chain[0].VerifyHostname(domain) == nil {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors.New("domain not in allowlist")
|
|
},
|
|
}, nil
|
|
}
|