feat: add webUI route (#2668)

Signed-off-by: joyceliu <joyceliu@yunify.com>
This commit is contained in:
liujian 2025-07-23 17:12:02 +08:00 committed by GitHub
parent 6b9636d144
commit 5ff30bff45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 143 additions and 50 deletions

View File

@ -7,3 +7,9 @@ import "embed"
//
//go:embed swagger-ui
var Swagger embed.FS
// WebUI embeds the web directory containing the static web UI assets
// This allows serving the web UI directly from the binary without needing external files
//
//go:embed all:web
var WebUI embed.FS

10
config/web/index.html Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>

View File

@ -3,10 +3,17 @@ package _const
import "k8s.io/apimachinery/pkg/runtime"
const (
// APIPath defines the base path for API endpoints in the KubeKey API server.
// This path is used as the prefix for all API routes, including those for
// managing inventories, playbooks, and other KubeKey resources.
APIPath = "/kapis/"
// CoreAPIPath defines the base path for core API endpoints in the KubeKey API server.
// All core resource management routes (inventories, playbooks, etc.) are prefixed with this path.
CoreAPIPath = "/kapis/"
// SwaggerAPIPath defines the base path for serving the Swagger UI (OpenAPI documentation).
// This is used to provide interactive API documentation for the KubeKey API server.
SwaggerAPIPath = "/swagger-ui/"
// ResourcesAPIPath defines the base path for resource-related endpoints.
// This path is used as the prefix for routes that serve static resources, schemas, and related files.
ResourcesAPIPath = "/resources/"
// KubeKeyTag is the tag used for KubeKey related resources
// This tag is used to identify and categorize KubeKey-specific resources

View File

@ -39,7 +39,8 @@ func (m webManager) Run(ctx context.Context) error {
Add(web.NewCoreService(m.workdir, m.Client, m.Config)).
// openapi
Add(web.NewSwaggerUIService()).
Add(web.NewAPIService(container.RegisteredWebServices()))
Add(web.NewAPIService(container.RegisteredWebServices())).
Add(web.NewUIService())
server := &http.Server{
Addr: fmt.Sprintf(":%d", m.port),

View File

@ -111,72 +111,79 @@ type genCertArgs struct {
isCA *bool
}
// signedCertificate generate certificate signed by root certificate
func (gca genCertArgs) signedCertificate(cfg *cgutilcert.Config) (string, string) {
parentKey, err := TryLoadKeyFromDisk(gca.rootKey)
// signedCertificate generates a certificate signed by the root certificate
func (gca genCertArgs) signedCertificate(cfg cgutilcert.Config) (string, string) {
// Load CA private key
caKey, err := TryLoadKeyFromDisk(gca.rootKey)
if err != nil {
return "", fmt.Sprintf("failed to load root key: %v", err)
}
parentCert, _, err := TryLoadCertChainFromDisk(gca.rootCert)
// Load CA certificate
caCert, _, err := TryLoadCertChainFromDisk(gca.rootCert)
if err != nil {
return "", fmt.Sprintf("failed to load root certificate: %v", err)
}
if gca.policy == policyIfNotPresent {
if _, err := TryLoadKeyFromDisk(gca.outKey); err != nil {
klog.V(4).InfoS("Failed to load out key, new it")
goto NEW
}
existCert, intermediates, err := TryLoadCertChainFromDisk(gca.outCert)
// Function to generate and write new certificate and key
generateAndWrite := func() (string, string) {
newKey, err := rsa.GenerateKey(cryptorand.Reader, rsaKeySize)
if err != nil {
klog.V(4).InfoS("Failed to load out cert, new it")
goto NEW
return "", fmt.Sprintf("generate rsa key error: %v", err)
}
// check if the existing key and cert match the root key and cert
newCert, err := NewSignedCert(cfg, gca.date, newKey, caCert, caKey, ptr.Deref(gca.isCA, false))
if err != nil {
return "", fmt.Sprintf("failed to generate certificate: %v", err)
}
if err := WriteKey(gca.outKey, newKey, gca.policy); err != nil {
return "", fmt.Sprintf("failed to write key: %v", err)
}
if err := WriteCert(gca.outCert, newCert, gca.policy); err != nil {
return "", fmt.Sprintf("failed to write certificate: %v", err)
}
return StdoutSuccess, ""
}
switch gca.policy {
case policyIfNotPresent:
// Check if key exists
if _, err := TryLoadKeyFromDisk(gca.outKey); err != nil {
klog.V(4).InfoS("Failed to load out key, create it")
return generateAndWrite()
}
// Check if certificate exists
existCert, existIntermediates, err := TryLoadCertChainFromDisk(gca.outCert)
if err != nil {
klog.V(4).InfoS("Failed to load out cert, create it")
return generateAndWrite()
}
// Validate certificate period
if err := ValidateCertPeriod(existCert, 0); err != nil {
return "", fmt.Sprintf("failed to ValidateCertPeriod: %v", err)
}
if err := VerifyCertChain(existCert, intermediates, parentCert); err != nil {
// Validate certificate chain
if err := VerifyCertChain(existCert, existIntermediates, caCert); err != nil {
return "", fmt.Sprintf("failed to VerifyCertChain: %v", err)
}
// Validate certificate SAN and other config
if err := validateCertificateWithConfig(existCert, gca.outCert, cfg); err != nil {
return "", fmt.Sprintf("failed to validateCertificateWithConfig: %v", err)
}
// Existing certificate and key are valid, skip generation
return StdoutSkip, ""
default:
// Otherwise, always generate new certificate and key
return generateAndWrite()
}
NEW:
newKey, err := rsa.GenerateKey(cryptorand.Reader, rsaKeySize)
if err != nil {
return "", fmt.Sprintf("generate rsa key error: %v", err)
}
newCert, err := NewSignedCert(*cfg, gca.date, newKey, parentCert, parentKey, ptr.Deref(gca.isCA, false))
if err != nil {
return "", fmt.Sprintf("failed to generate certificate: %v", err)
}
// write key and cert to file
if err := WriteKey(gca.outKey, newKey, gca.policy); err != nil {
return "", fmt.Sprintf("failed to write key: %v", err)
}
if err := WriteCert(gca.outCert, newCert, gca.policy); err != nil {
return "", fmt.Sprintf("failed to write certificate: %v", err)
}
return StdoutSuccess, ""
}
// selfSignedCertificate generate Self-signed certificate
func (gca genCertArgs) selfSignedCertificate(cfg *cgutilcert.Config) (string, string) {
func (gca genCertArgs) selfSignedCertificate(cfg cgutilcert.Config) (string, string) {
newKey, err := rsa.GenerateKey(cryptorand.Reader, rsaKeySize)
if err != nil {
return "", fmt.Sprintf("generate rsa key error: %v", err)
}
newCert, err := NewSelfSignedCACert(*cfg, gca.date, newKey)
newCert, err := NewSelfSignedCACert(cfg, gca.date, newKey)
if err != nil {
return "", fmt.Sprintf("failed to generate self-signed certificate: %v", err)
}
@ -239,9 +246,9 @@ func ModuleGenCert(ctx context.Context, options ExecOptions) (string, string) {
switch {
case gca.rootKey == "" || gca.rootCert == "":
return gca.selfSignedCertificate(cfg)
return gca.selfSignedCertificate(*cfg)
default:
return gca.signedCertificate(cfg)
return gca.signedCertificate(*cfg)
}
}
@ -503,7 +510,7 @@ func VerifyCertChain(cert *x509.Certificate, intermediates []*x509.Certificate,
// validateCertificateWithConfig makes sure that a given certificate is valid at
// least for the SANs defined in the configuration.
func validateCertificateWithConfig(cert *x509.Certificate, baseName string, cfg *cgutilcert.Config) error {
func validateCertificateWithConfig(cert *x509.Certificate, baseName string, cfg cgutilcert.Config) error {
for _, dnsName := range cfg.AltNames.DNSNames {
if err := cert.VerifyHostname(dnsName); err != nil {
return errors.Wrapf(err, "certificate %s is invalid", baseName)

View File

@ -41,7 +41,7 @@ import (
func NewCoreService(workdir string, client ctrlclient.Client, config *rest.Config) *restful.WebService {
ws := new(restful.WebService).
// the GroupVersion might be empty, we need to remove the final /
Path(strings.TrimRight(_const.APIPath+kkcorev1.SchemeGroupVersion.String(), "/"))
Path(strings.TrimRight(_const.CoreAPIPath+kkcorev1.SchemeGroupVersion.String(), "/"))
h := newCoreHandler(workdir, client, config)

View File

@ -3,7 +3,9 @@ package web
import (
"io/fs"
"net/http"
"strings"
"github.com/cockroachdb/errors"
restfulspec "github.com/emicklei/go-restful-openapi/v2"
"github.com/emicklei/go-restful/v3"
"github.com/go-openapi/spec"
@ -14,11 +16,71 @@ import (
"github.com/kubesphere/kubekey/v4/version"
)
// NewUIService creates a new WebService that serves the static web UI and handles SPA routing.
// - Serves "/" with index.html
// - Serves static assets (e.g., .js, .css, .png) from the embedded web directory
// - Forwards all other non-API paths to index.html for SPA client-side routing
func NewUIService() *restful.WebService {
ws := new(restful.WebService)
ws.Path("/")
// Create a sub-filesystem for the embedded web UI assets
subFS, err := fs.Sub(config.WebUI, "web")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(subFS))
// Serve the root path "/" with index.html
ws.Route(ws.GET("").To(func(req *restful.Request, resp *restful.Response) {
data, err := fs.ReadFile(subFS, "index.html")
if err != nil {
_ = resp.WriteError(http.StatusNotFound, err)
return
}
resp.AddHeader("Content-Type", "text/html")
_, _ = resp.Write(data)
}))
// Serve all subpaths:
// - If the path matches an API prefix, return 404 to let other WebServices handle it
// - If the path looks like a static asset (contains a dot), serve the file
// - Otherwise, serve index.html for SPA routing
ws.Route(ws.GET("/{subpath:*}").To(func(req *restful.Request, resp *restful.Response) {
requestedPath := req.PathParameter("subpath")
// If the path matches any API route, return 404 so other WebServices can handle it
if strings.HasPrefix(requestedPath, strings.TrimLeft(_const.CoreAPIPath, "/")) ||
strings.HasPrefix(requestedPath, strings.TrimLeft(_const.SwaggerAPIPath, "/")) ||
strings.HasPrefix(requestedPath, strings.TrimLeft(_const.ResourcesAPIPath, "/")) {
_ = resp.WriteError(http.StatusNotFound, errors.New("not found"))
return
}
// If the path looks like a static asset (e.g., .js, .css, .ico, .png, etc.), serve it
if strings.Contains(requestedPath, ".") {
fileServer.ServeHTTP(resp.ResponseWriter, req.Request)
return
}
// For all other paths, serve index.html (SPA client-side routing)
data, err := fs.ReadFile(subFS, "index.html")
if err != nil {
_ = resp.WriteError(http.StatusInternalServerError, err)
return
}
resp.AddHeader("Content-Type", "text/html")
_, _ = resp.Write(data)
}))
return ws
}
// NewSwaggerUIService creates a new WebService that serves the Swagger UI interface
// It mounts the embedded swagger-ui files and handles requests to display the API documentation
func NewSwaggerUIService() *restful.WebService {
ws := new(restful.WebService)
ws.Path("/swagger-ui")
ws.Path(strings.TrimRight(_const.SwaggerAPIPath, "/"))
subFS, err := fs.Sub(config.Swagger, "swagger-ui")
if err != nil {

View File

@ -36,7 +36,7 @@ import (
// The {subpath:*} parameter allows for matching any path under /resources/schema/.
func NewSchemaService(rootPath string, workdir string, client ctrlclient.Client) *restful.WebService {
ws := new(restful.WebService)
ws.Path("/resources").
ws.Path(strings.TrimRight(_const.ResourcesAPIPath, "/")).
Produces(restful.MIME_JSON, "text/plain")
h := newSchemaHandler(rootPath, workdir, client)