mirror of
https://github.com/siderolabs/image-factory.git
synced 2026-05-05 12:26:17 +02:00
Add a /talosctl/:version endpoint which lists all downloadable talosctl binaries fro a given version. Signed-off-by: Edward Sammut Alessi <edward.sammutalessi@siderolabs.com>
323 lines
11 KiB
Go
323 lines
11 KiB
Go
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
// Package http implements the HTTP frontend.
|
|
package http
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/julienschmidt/httprouter"
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
"github.com/rs/cors"
|
|
"github.com/siderolabs/gen/ensure"
|
|
"github.com/siderolabs/gen/xerrors"
|
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
|
"github.com/slok/go-http-metrics/middleware"
|
|
httproutermiddleware "github.com/slok/go-http-metrics/middleware/httprouter"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
"golang.org/x/sync/singleflight"
|
|
|
|
"github.com/siderolabs/image-factory/internal/artifacts"
|
|
"github.com/siderolabs/image-factory/internal/asset"
|
|
"github.com/siderolabs/image-factory/internal/image/signer"
|
|
"github.com/siderolabs/image-factory/internal/profile"
|
|
"github.com/siderolabs/image-factory/internal/remotewrap"
|
|
"github.com/siderolabs/image-factory/internal/schematic"
|
|
"github.com/siderolabs/image-factory/internal/schematic/storage"
|
|
"github.com/siderolabs/image-factory/internal/secureboot"
|
|
"github.com/siderolabs/image-factory/internal/version"
|
|
"github.com/siderolabs/image-factory/pkg/enterprise"
|
|
schematicpkg "github.com/siderolabs/image-factory/pkg/schematic"
|
|
)
|
|
|
|
// Frontend is the HTTP frontend.
|
|
type Frontend struct {
|
|
router *httprouter.Router
|
|
schematicFactory *schematic.Factory
|
|
assetBuilder *asset.Builder
|
|
artifactsManager *artifacts.Manager
|
|
secureBootService *secureboot.Service
|
|
checksummer enterprise.Checksummer
|
|
logger *zap.Logger
|
|
puller remotewrap.Puller
|
|
pusher remotewrap.Pusher
|
|
imageSigner signer.Signer
|
|
sf singleflight.Group
|
|
options Options
|
|
}
|
|
|
|
// Options configures the HTTP frontend.
|
|
type Options struct {
|
|
CacheImageSigner signer.Signer
|
|
ExternalURL *url.URL
|
|
ExternalPXEURL *url.URL
|
|
AuthProvider enterprise.AuthProvider
|
|
InstallerInternalRepository name.Repository
|
|
InstallerExternalRepository name.Repository
|
|
MetricsNamespace string
|
|
AllowedOrigins []string
|
|
RemoteOptions []remote.Option
|
|
RegistryRefreshInterval time.Duration
|
|
ProxyInstallerInternalRepository bool
|
|
}
|
|
|
|
// Handler is a custom handler type that includes the context and httprouter params, and returns an error.
|
|
type Handler = func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error
|
|
|
|
// NewFrontend creates a new HTTP frontend.
|
|
func NewFrontend(
|
|
logger *zap.Logger,
|
|
schematicFactory *schematic.Factory,
|
|
assetBuilder *asset.Builder,
|
|
artifactsManager *artifacts.Manager,
|
|
secureBootService *secureboot.Service,
|
|
checksummer enterprise.Checksummer,
|
|
enterprisePlugins []enterprise.FrontendPlugin,
|
|
opts Options,
|
|
) (*Frontend, error) {
|
|
frontend := &Frontend{
|
|
router: httprouter.New(),
|
|
schematicFactory: schematicFactory,
|
|
assetBuilder: assetBuilder,
|
|
artifactsManager: artifactsManager,
|
|
secureBootService: secureBootService,
|
|
checksummer: checksummer,
|
|
logger: logger.With(zap.String("frontend", "http")),
|
|
options: opts,
|
|
}
|
|
|
|
var err error
|
|
|
|
frontend.puller, err = remotewrap.NewPuller(opts.RegistryRefreshInterval, opts.RemoteOptions...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create puller: %w", err)
|
|
}
|
|
|
|
frontend.pusher, err = remotewrap.NewPusher(opts.RegistryRefreshInterval, opts.RemoteOptions...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create pusher: %w", err)
|
|
}
|
|
|
|
frontend.imageSigner = opts.CacheImageSigner
|
|
|
|
// monitoring middleware
|
|
mdlw := middleware.New(middleware.Config{
|
|
Recorder: metrics.NewRecorder(metrics.Config{
|
|
Prefix: opts.MetricsNamespace,
|
|
}),
|
|
})
|
|
|
|
registerRoute := func(registrator func(string, httprouter.Handle), path string, handler Handler) {
|
|
registrator(path, httproutermiddleware.Handler(path, frontend.wrapper(handler), mdlw))
|
|
}
|
|
|
|
registerPublicRoute := func(registrator func(string, httprouter.Handle), path string, handler Handler) {
|
|
registrator(path, httproutermiddleware.Handler(path, frontend.wrapperPublic(handler), mdlw))
|
|
}
|
|
|
|
// enterprise
|
|
for _, enterpriseRoute := range enterprisePlugins {
|
|
for _, method := range enterpriseRoute.Methods() {
|
|
switch method {
|
|
case http.MethodGet:
|
|
registerRoute(frontend.router.GET, enterpriseRoute.Path(), enterpriseRoute.Handle)
|
|
|
|
case http.MethodHead:
|
|
registerRoute(frontend.router.HEAD, enterpriseRoute.Path(), enterpriseRoute.Handle)
|
|
|
|
case http.MethodPost:
|
|
registerRoute(frontend.router.POST, enterpriseRoute.Path(), enterpriseRoute.Handle)
|
|
|
|
default:
|
|
panic(fmt.Sprintf("unsupported method %s for enterprise route %s", method, enterpriseRoute.Path()))
|
|
}
|
|
}
|
|
}
|
|
|
|
// /healthz is always public (Kubernetes probes, monitoring)
|
|
registerPublicRoute(frontend.router.GET, "/healthz", frontend.handleHealth)
|
|
registerPublicRoute(frontend.router.HEAD, "/healthz", frontend.handleHealth)
|
|
|
|
// images - require auth
|
|
registerRoute(frontend.router.GET, "/image/:schematic/:version/:path", frontend.handleImage)
|
|
registerRoute(frontend.router.HEAD, "/image/:schematic/:version/:path", frontend.handleImage)
|
|
|
|
// PXE - require auth
|
|
registerRoute(frontend.router.GET, "/pxe/:schematic/:version/:path", frontend.handlePXE)
|
|
|
|
// registry - /v2 requires auth (OCI spec: 401 challenge when auth enabled)
|
|
registerRoute(frontend.router.GET, "/v2", frontend.handleHealth)
|
|
registerRoute(frontend.router.GET, "/v2/", frontend.handleHealth)
|
|
registerRoute(frontend.router.HEAD, "/v2", frontend.handleHealth)
|
|
registerRoute(frontend.router.HEAD, "/v2/", frontend.handleHealth)
|
|
registerRoute(frontend.router.GET, "/v2/:image/:schematic/blobs/:digest", frontend.handleBlob)
|
|
registerRoute(frontend.router.HEAD, "/v2/:image/:schematic/blobs/:digest", frontend.handleBlob)
|
|
registerRoute(frontend.router.GET, "/v2/:image/:schematic/manifests/:tag", frontend.handleManifest)
|
|
registerRoute(frontend.router.HEAD, "/v2/:image/:schematic/manifests/:tag", frontend.handleManifest)
|
|
registerPublicRoute(frontend.router.GET, "/oci/cosign/signing-key.pub", frontend.handleCosignSigningKeyPub)
|
|
|
|
// schematic - both POST and GET require auth
|
|
registerRoute(frontend.router.POST, "/schematics", frontend.handleSchematicCreate)
|
|
registerRoute(frontend.router.GET, "/schematics/:schematic", frontend.handleSchematicGet)
|
|
|
|
// meta - public
|
|
registerPublicRoute(frontend.router.GET, "/versions", frontend.handleVersions)
|
|
registerPublicRoute(frontend.router.GET, "/version/:version/extensions/official", frontend.handleOfficialExtensions)
|
|
registerPublicRoute(frontend.router.GET, "/version/:version/overlays/official", frontend.handleOfficialOverlays)
|
|
|
|
// secureboot - public
|
|
registerPublicRoute(frontend.router.GET, "/secureboot/signing-cert.pem", frontend.handleSecureBootSigningCert)
|
|
|
|
// talosctl - public
|
|
registerPublicRoute(frontend.router.GET, "/talosctl/:version", frontend.handleTalosctlList)
|
|
registerPublicRoute(frontend.router.HEAD, "/talosctl/:version/:path", frontend.handleTalosctl)
|
|
registerPublicRoute(frontend.router.GET, "/talosctl/:version/:path", frontend.handleTalosctl)
|
|
|
|
// UI - require auth (consistent with all other schematic-creating endpoints)
|
|
registerRoute(frontend.router.GET, "/", frontend.handleUI)
|
|
registerRoute(frontend.router.HEAD, "/", frontend.handleUI)
|
|
registerRoute(frontend.router.POST, "/ui/wizard", frontend.handleUIWizard)
|
|
registerRoute(frontend.router.GET, "/ui/version-doc", frontend.handleUIVersionDoc)
|
|
registerRoute(frontend.router.POST, "/ui/extensions-list", frontend.handleUIExtensionsList)
|
|
|
|
frontend.router.ServeFiles("/css/*filepath", http.FS(ensure.Value(fs.Sub(cssFS, "css"))))
|
|
frontend.router.ServeFiles("/favicons/*filepath", http.FS(ensure.Value(fs.Sub(faviconsFS, "favicons"))))
|
|
frontend.router.ServeFiles("/js/*filepath", http.FS(ensure.Value(fs.Sub(jsFS, "js"))))
|
|
|
|
return frontend, nil
|
|
}
|
|
|
|
// Handler returns the HTTP handler.
|
|
func (f *Frontend) Handler() http.Handler {
|
|
return cors.New(cors.Options{
|
|
AllowedOrigins: f.options.AllowedOrigins,
|
|
AllowedMethods: []string{
|
|
http.MethodHead,
|
|
http.MethodGet,
|
|
http.MethodOptions,
|
|
},
|
|
AllowedHeaders: []string{"Cache-Control"},
|
|
ExposedHeaders: []string{"Content-Disposition", "Content-Length", "Content-Type"},
|
|
}).Handler(f.router)
|
|
}
|
|
|
|
func (f *Frontend) wrapper(h Handler) httprouter.Handle {
|
|
return f.wrapHandler(h, true)
|
|
}
|
|
|
|
func (f *Frontend) wrapperPublic(h Handler) httprouter.Handle {
|
|
return f.wrapHandler(h, false)
|
|
}
|
|
|
|
func (f *Frontend) wrapHandler(h Handler, requireAuth bool) httprouter.Handle {
|
|
if requireAuth && f.options.AuthProvider != nil {
|
|
h = f.options.AuthProvider.Middleware(h)
|
|
}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
|
ctx := r.Context()
|
|
|
|
start := time.Now()
|
|
|
|
w.Header().Set("Server", version.ServerString())
|
|
|
|
err := h(ctx, w, r, p)
|
|
|
|
duration := time.Since(start)
|
|
|
|
level, status := MatchError(err, func(message string, code int) {
|
|
if code == http.StatusUnauthorized {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="Image Factory Enterprise"`)
|
|
}
|
|
|
|
http.Error(w, message, code)
|
|
})
|
|
|
|
f.logger.Log(level, "request",
|
|
zap.String("method", r.Method),
|
|
zap.String("path", r.URL.Path),
|
|
zap.Int("status", status),
|
|
zap.Duration("duration", duration),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
// MatchError matches the error and returns the appropriate HTTP status code and log level.
|
|
// It also calls the callback with the message and code to write the response.
|
|
func MatchError(err error, callback func(message string, code int)) (zapcore.Level, int) {
|
|
status := http.StatusOK
|
|
level := zap.InfoLevel
|
|
|
|
switch {
|
|
case err == nil:
|
|
// happy case
|
|
case xerrors.TagIs[enterprise.ErrNotEnabledTag](err):
|
|
level = zap.WarnLevel
|
|
status = http.StatusPaymentRequired
|
|
|
|
callback(err.Error(), http.StatusPaymentRequired)
|
|
case xerrors.TagIs[storage.ErrNotFoundTag](err),
|
|
xerrors.TagIs[artifacts.ErrNotFoundTag](err):
|
|
level = zap.WarnLevel
|
|
status = http.StatusNotFound
|
|
|
|
callback(err.Error(), http.StatusNotFound)
|
|
case xerrors.TagIs[profile.InvalidErrorTag](err),
|
|
xerrors.TagIs[schematicpkg.InvalidErrorTag](err),
|
|
xerrors.TagIs[InvalidImageTag](err):
|
|
level = zap.WarnLevel
|
|
status = http.StatusBadRequest
|
|
|
|
callback(err.Error(), http.StatusBadRequest)
|
|
case xerrors.TagIs[schematicpkg.RequiresAuthenticationTag](err):
|
|
level = zap.WarnLevel
|
|
status = http.StatusUnauthorized
|
|
|
|
callback("authentication required to access this schematic", http.StatusUnauthorized)
|
|
case xerrors.TagIs[schematicpkg.ForbiddenTag](err):
|
|
level = zap.WarnLevel
|
|
status = http.StatusForbidden
|
|
|
|
callback("access denied", http.StatusForbidden)
|
|
case errors.Is(err, context.Canceled):
|
|
status = 499
|
|
// client closed connection
|
|
default:
|
|
status = http.StatusInternalServerError
|
|
level = zap.ErrorLevel
|
|
|
|
callback("internal server error", http.StatusInternalServerError)
|
|
}
|
|
|
|
return level, status
|
|
}
|
|
|
|
// Use several ways to detect language.
|
|
func (f *Frontend) getLocalizer(r *http.Request) *i18n.Localizer {
|
|
lang := r.URL.Query().Get("lang")
|
|
|
|
if lang == "" {
|
|
if cookie, err := r.Cookie("lang"); err == nil {
|
|
lang = cookie.Value
|
|
}
|
|
}
|
|
|
|
if lang == "" {
|
|
lang = r.Header.Get("Accept-Language")
|
|
}
|
|
|
|
return i18n.NewLocalizer(getLocalizerBundle(), lang, "en")
|
|
}
|