package main import ( "context" "crypto/rand" "crypto/tls" "crypto/x509" "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" "github.com/spf13/cobra" "google.golang.org/grpc" "google.golang.org/grpc/credentials" mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1" "git.wntrmute.dev/mc/mcr/internal/auth" "git.wntrmute.dev/mc/mcr/internal/config" "git.wntrmute.dev/mc/mcr/internal/webserver" ) var version = "dev" func main() { root := &cobra.Command{ Use: "mcr-web", Short: "Metacircular Container Registry web UI", Version: version, } root.AddCommand(serverCmd()) if err := root.Execute(); err != nil { os.Exit(1) } } func serverCmd() *cobra.Command { var configPath string cmd := &cobra.Command{ Use: "server", Short: "Start the web UI server", RunE: func(_ *cobra.Command, _ []string) error { return runServer(configPath) }, } cmd.Flags().StringVar(&configPath, "config", "", "path to mcr.toml config file (required)") if err := cmd.MarkFlagRequired("config"); err != nil { log.Fatalf("flag setup: %v", err) } return cmd } func runServer(configPath string) error { cfg, err := config.Load(configPath) if err != nil { return fmt.Errorf("load config: %w", err) } if cfg.Web.ListenAddr == "" { return fmt.Errorf("config: web.listen_addr is required") } if cfg.Web.GRPCAddr == "" { return fmt.Errorf("config: web.grpc_addr is required") } // Connect to mcrsrv gRPC API. grpcConn, err := dialGRPC(cfg) if err != nil { return fmt.Errorf("connect to gRPC server: %w", err) } defer func() { _ = grpcConn.Close() }() // Create gRPC clients. registryClient := mcrv1.NewRegistryServiceClient(grpcConn) policyClient := mcrv1.NewPolicyServiceClient(grpcConn) auditClient := mcrv1.NewAuditServiceClient(grpcConn) adminClient := mcrv1.NewAdminServiceClient(grpcConn) // Create MCIAS auth client for login. authClient, err := auth.NewClient( cfg.MCIAS.ServerURL, cfg.MCIAS.CACert, cfg.MCIAS.ServiceName, cfg.MCIAS.Tags, ) if err != nil { return fmt.Errorf("create auth client: %w", err) } loginFn := func(username, password string) (string, int, error) { return authClient.Login(username, password) } validateFn := func(token string) ([]string, error) { claims, err := authClient.ValidateToken(token) if err != nil { return nil, err } return claims.Roles, nil } // Generate CSRF key. csrfKey := make([]byte, 32) if _, err := rand.Read(csrfKey); err != nil { return fmt.Errorf("generate CSRF key: %w", err) } // Create web server. srv, err := webserver.New(registryClient, policyClient, auditClient, adminClient, loginFn, validateFn, csrfKey) if err != nil { return fmt.Errorf("create web server: %w", err) } // Configure TLS. tlsCfg := &tls.Config{ MinVersion: tls.VersionTLS13, } cert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey) if err != nil { return fmt.Errorf("load TLS certificate: %w", err) } tlsCfg.Certificates = []tls.Certificate{cert} httpServer := &http.Server{ Addr: cfg.Web.ListenAddr, Handler: srv.Handler(), TLSConfig: tlsCfg, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } // Graceful shutdown on SIGINT/SIGTERM. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() errCh := make(chan error, 1) go func() { log.Printf("mcr-web listening on %s", cfg.Web.ListenAddr) // TLS is configured via TLSConfig, so use ServeTLS with empty cert/key // paths (they are already loaded). errCh <- httpServer.ListenAndServeTLS("", "") }() select { case err := <-errCh: return fmt.Errorf("server error: %w", err) case <-ctx.Done(): log.Println("shutting down mcr-web...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := httpServer.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("shutdown: %w", err) } log.Println("mcr-web stopped") return nil } } // dialGRPC establishes a gRPC connection to the mcrsrv API server. func dialGRPC(cfg *config.Config) (*grpc.ClientConn, error) { tlsCfg := &tls.Config{ MinVersion: tls.VersionTLS13, ServerName: cfg.Web.TLSServerName, } if cfg.Web.CACert != "" { pem, err := os.ReadFile(cfg.Web.CACert) //nolint:gosec // CA cert path is operator-supplied if err != nil { return nil, fmt.Errorf("read CA cert: %w", err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("no valid certificates in CA cert file") } tlsCfg.RootCAs = pool } creds := credentials.NewTLS(tlsCfg) conn, err := grpc.NewClient( cfg.Web.GRPCAddr, grpc.WithTransportCredentials(creds), grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})), ) if err != nil { return nil, err } return conn, nil }