Skip to content

Commit 5ff30bf

Browse files
authored
feat: add webUI route (#2668)
Signed-off-by: joyceliu <joyceliu@yunify.com>
1 parent 6b9636d commit 5ff30bf

8 files changed

Lines changed: 141 additions & 48 deletions

File tree

config/file.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,9 @@ import "embed"
77
//
88
//go:embed swagger-ui
99
var Swagger embed.FS
10+
11+
// WebUI embeds the web directory containing the static web UI assets
12+
// This allows serving the web UI directly from the binary without needing external files
13+
//
14+
//go:embed all:web
15+
var WebUI embed.FS

config/web/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Hello World</title>
6+
</head>
7+
<body>
8+
<h1>Hello World</h1>
9+
</body>
10+
</html>

pkg/const/web.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ package _const
33
import "k8s.io/apimachinery/pkg/runtime"
44

55
const (
6-
// APIPath defines the base path for API endpoints in the KubeKey API server.
7-
// This path is used as the prefix for all API routes, including those for
8-
// managing inventories, playbooks, and other KubeKey resources.
9-
APIPath = "/kapis/"
6+
// CoreAPIPath defines the base path for core API endpoints in the KubeKey API server.
7+
// All core resource management routes (inventories, playbooks, etc.) are prefixed with this path.
8+
CoreAPIPath = "/kapis/"
9+
10+
// SwaggerAPIPath defines the base path for serving the Swagger UI (OpenAPI documentation).
11+
// This is used to provide interactive API documentation for the KubeKey API server.
12+
SwaggerAPIPath = "/swagger-ui/"
13+
14+
// ResourcesAPIPath defines the base path for resource-related endpoints.
15+
// This path is used as the prefix for routes that serve static resources, schemas, and related files.
16+
ResourcesAPIPath = "/resources/"
1017

1118
// KubeKeyTag is the tag used for KubeKey related resources
1219
// This tag is used to identify and categorize KubeKey-specific resources

pkg/manager/web_manager.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ func (m webManager) Run(ctx context.Context) error {
3939
Add(web.NewCoreService(m.workdir, m.Client, m.Config)).
4040
// openapi
4141
Add(web.NewSwaggerUIService()).
42-
Add(web.NewAPIService(container.RegisteredWebServices()))
42+
Add(web.NewAPIService(container.RegisteredWebServices())).
43+
Add(web.NewUIService())
4344

4445
server := &http.Server{
4546
Addr: fmt.Sprintf(":%d", m.port),

pkg/modules/gen_cert.go

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -111,72 +111,79 @@ type genCertArgs struct {
111111
isCA *bool
112112
}
113113

114-
// signedCertificate generate certificate signed by root certificate
115-
func (gca genCertArgs) signedCertificate(cfg *cgutilcert.Config) (string, string) {
116-
parentKey, err := TryLoadKeyFromDisk(gca.rootKey)
114+
// signedCertificate generates a certificate signed by the root certificate
115+
func (gca genCertArgs) signedCertificate(cfg cgutilcert.Config) (string, string) {
116+
// Load CA private key
117+
caKey, err := TryLoadKeyFromDisk(gca.rootKey)
117118
if err != nil {
118119
return "", fmt.Sprintf("failed to load root key: %v", err)
119120
}
120-
parentCert, _, err := TryLoadCertChainFromDisk(gca.rootCert)
121+
// Load CA certificate
122+
caCert, _, err := TryLoadCertChainFromDisk(gca.rootCert)
121123
if err != nil {
122124
return "", fmt.Sprintf("failed to load root certificate: %v", err)
123125
}
124126

125-
if gca.policy == policyIfNotPresent {
126-
if _, err := TryLoadKeyFromDisk(gca.outKey); err != nil {
127-
klog.V(4).InfoS("Failed to load out key, new it")
128-
129-
goto NEW
127+
// Function to generate and write new certificate and key
128+
generateAndWrite := func() (string, string) {
129+
newKey, err := rsa.GenerateKey(cryptorand.Reader, rsaKeySize)
130+
if err != nil {
131+
return "", fmt.Sprintf("generate rsa key error: %v", err)
130132
}
131-
132-
existCert, intermediates, err := TryLoadCertChainFromDisk(gca.outCert)
133+
newCert, err := NewSignedCert(cfg, gca.date, newKey, caCert, caKey, ptr.Deref(gca.isCA, false))
133134
if err != nil {
134-
klog.V(4).InfoS("Failed to load out cert, new it")
135+
return "", fmt.Sprintf("failed to generate certificate: %v", err)
136+
}
137+
if err := WriteKey(gca.outKey, newKey, gca.policy); err != nil {
138+
return "", fmt.Sprintf("failed to write key: %v", err)
139+
}
140+
if err := WriteCert(gca.outCert, newCert, gca.policy); err != nil {
141+
return "", fmt.Sprintf("failed to write certificate: %v", err)
142+
}
143+
return StdoutSuccess, ""
144+
}
135145

136-
goto NEW
146+
switch gca.policy {
147+
case policyIfNotPresent:
148+
// Check if key exists
149+
if _, err := TryLoadKeyFromDisk(gca.outKey); err != nil {
150+
klog.V(4).InfoS("Failed to load out key, create it")
151+
return generateAndWrite()
152+
}
153+
// Check if certificate exists
154+
existCert, existIntermediates, err := TryLoadCertChainFromDisk(gca.outCert)
155+
if err != nil {
156+
klog.V(4).InfoS("Failed to load out cert, create it")
157+
return generateAndWrite()
137158
}
138-
// check if the existing key and cert match the root key and cert
159+
// Validate certificate period
139160
if err := ValidateCertPeriod(existCert, 0); err != nil {
140161
return "", fmt.Sprintf("failed to ValidateCertPeriod: %v", err)
141162
}
142-
if err := VerifyCertChain(existCert, intermediates, parentCert); err != nil {
163+
// Validate certificate chain
164+
if err := VerifyCertChain(existCert, existIntermediates, caCert); err != nil {
143165
return "", fmt.Sprintf("failed to VerifyCertChain: %v", err)
144166
}
167+
// Validate certificate SAN and other config
145168
if err := validateCertificateWithConfig(existCert, gca.outCert, cfg); err != nil {
146169
return "", fmt.Sprintf("failed to validateCertificateWithConfig: %v", err)
147170
}
148-
171+
// Existing certificate and key are valid, skip generation
149172
return StdoutSkip, ""
173+
default:
174+
// Otherwise, always generate new certificate and key
175+
return generateAndWrite()
150176
}
151-
NEW:
152-
newKey, err := rsa.GenerateKey(cryptorand.Reader, rsaKeySize)
153-
if err != nil {
154-
return "", fmt.Sprintf("generate rsa key error: %v", err)
155-
}
156-
newCert, err := NewSignedCert(*cfg, gca.date, newKey, parentCert, parentKey, ptr.Deref(gca.isCA, false))
157-
if err != nil {
158-
return "", fmt.Sprintf("failed to generate certificate: %v", err)
159-
}
160-
161-
// write key and cert to file
162-
if err := WriteKey(gca.outKey, newKey, gca.policy); err != nil {
163-
return "", fmt.Sprintf("failed to write key: %v", err)
164-
}
165-
if err := WriteCert(gca.outCert, newCert, gca.policy); err != nil {
166-
return "", fmt.Sprintf("failed to write certificate: %v", err)
167-
}
168-
169-
return StdoutSuccess, ""
170177
}
171178

172179
// selfSignedCertificate generate Self-signed certificate
173-
func (gca genCertArgs) selfSignedCertificate(cfg *cgutilcert.Config) (string, string) {
180+
func (gca genCertArgs) selfSignedCertificate(cfg cgutilcert.Config) (string, string) {
174181
newKey, err := rsa.GenerateKey(cryptorand.Reader, rsaKeySize)
175182
if err != nil {
176183
return "", fmt.Sprintf("generate rsa key error: %v", err)
177184
}
178185

179-
newCert, err := NewSelfSignedCACert(*cfg, gca.date, newKey)
186+
newCert, err := NewSelfSignedCACert(cfg, gca.date, newKey)
180187
if err != nil {
181188
return "", fmt.Sprintf("failed to generate self-signed certificate: %v", err)
182189
}
@@ -239,9 +246,9 @@ func ModuleGenCert(ctx context.Context, options ExecOptions) (string, string) {
239246

240247
switch {
241248
case gca.rootKey == "" || gca.rootCert == "":
242-
return gca.selfSignedCertificate(cfg)
249+
return gca.selfSignedCertificate(*cfg)
243250
default:
244-
return gca.signedCertificate(cfg)
251+
return gca.signedCertificate(*cfg)
245252
}
246253
}
247254

@@ -503,7 +510,7 @@ func VerifyCertChain(cert *x509.Certificate, intermediates []*x509.Certificate,
503510

504511
// validateCertificateWithConfig makes sure that a given certificate is valid at
505512
// least for the SANs defined in the configuration.
506-
func validateCertificateWithConfig(cert *x509.Certificate, baseName string, cfg *cgutilcert.Config) error {
513+
func validateCertificateWithConfig(cert *x509.Certificate, baseName string, cfg cgutilcert.Config) error {
507514
for _, dnsName := range cfg.AltNames.DNSNames {
508515
if err := cert.VerifyHostname(dnsName); err != nil {
509516
return errors.Wrapf(err, "certificate %s is invalid", baseName)

pkg/web/corev1.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import (
4141
func NewCoreService(workdir string, client ctrlclient.Client, config *rest.Config) *restful.WebService {
4242
ws := new(restful.WebService).
4343
// the GroupVersion might be empty, we need to remove the final /
44-
Path(strings.TrimRight(_const.APIPath+kkcorev1.SchemeGroupVersion.String(), "/"))
44+
Path(strings.TrimRight(_const.CoreAPIPath+kkcorev1.SchemeGroupVersion.String(), "/"))
4545

4646
h := newCoreHandler(workdir, client, config)
4747

pkg/web/openapi.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package web
33
import (
44
"io/fs"
55
"net/http"
6+
"strings"
67

8+
"github.com/cockroachdb/errors"
79
restfulspec "github.com/emicklei/go-restful-openapi/v2"
810
"github.com/emicklei/go-restful/v3"
911
"github.com/go-openapi/spec"
@@ -14,11 +16,71 @@ import (
1416
"github.com/kubesphere/kubekey/v4/version"
1517
)
1618

19+
// NewUIService creates a new WebService that serves the static web UI and handles SPA routing.
20+
// - Serves "/" with index.html
21+
// - Serves static assets (e.g., .js, .css, .png) from the embedded web directory
22+
// - Forwards all other non-API paths to index.html for SPA client-side routing
23+
func NewUIService() *restful.WebService {
24+
ws := new(restful.WebService)
25+
ws.Path("/")
26+
27+
// Create a sub-filesystem for the embedded web UI assets
28+
subFS, err := fs.Sub(config.WebUI, "web")
29+
if err != nil {
30+
panic(err)
31+
}
32+
fileServer := http.FileServer(http.FS(subFS))
33+
34+
// Serve the root path "/" with index.html
35+
ws.Route(ws.GET("").To(func(req *restful.Request, resp *restful.Response) {
36+
data, err := fs.ReadFile(subFS, "index.html")
37+
if err != nil {
38+
_ = resp.WriteError(http.StatusNotFound, err)
39+
return
40+
}
41+
resp.AddHeader("Content-Type", "text/html")
42+
_, _ = resp.Write(data)
43+
}))
44+
45+
// Serve all subpaths:
46+
// - If the path matches an API prefix, return 404 to let other WebServices handle it
47+
// - If the path looks like a static asset (contains a dot), serve the file
48+
// - Otherwise, serve index.html for SPA routing
49+
ws.Route(ws.GET("/{subpath:*}").To(func(req *restful.Request, resp *restful.Response) {
50+
requestedPath := req.PathParameter("subpath")
51+
52+
// If the path matches any API route, return 404 so other WebServices can handle it
53+
if strings.HasPrefix(requestedPath, strings.TrimLeft(_const.CoreAPIPath, "/")) ||
54+
strings.HasPrefix(requestedPath, strings.TrimLeft(_const.SwaggerAPIPath, "/")) ||
55+
strings.HasPrefix(requestedPath, strings.TrimLeft(_const.ResourcesAPIPath, "/")) {
56+
_ = resp.WriteError(http.StatusNotFound, errors.New("not found"))
57+
return
58+
}
59+
60+
// If the path looks like a static asset (e.g., .js, .css, .ico, .png, etc.), serve it
61+
if strings.Contains(requestedPath, ".") {
62+
fileServer.ServeHTTP(resp.ResponseWriter, req.Request)
63+
return
64+
}
65+
66+
// For all other paths, serve index.html (SPA client-side routing)
67+
data, err := fs.ReadFile(subFS, "index.html")
68+
if err != nil {
69+
_ = resp.WriteError(http.StatusInternalServerError, err)
70+
return
71+
}
72+
resp.AddHeader("Content-Type", "text/html")
73+
_, _ = resp.Write(data)
74+
}))
75+
76+
return ws
77+
}
78+
1779
// NewSwaggerUIService creates a new WebService that serves the Swagger UI interface
1880
// It mounts the embedded swagger-ui files and handles requests to display the API documentation
1981
func NewSwaggerUIService() *restful.WebService {
2082
ws := new(restful.WebService)
21-
ws.Path("/swagger-ui")
83+
ws.Path(strings.TrimRight(_const.SwaggerAPIPath, "/"))
2284

2385
subFS, err := fs.Sub(config.Swagger, "swagger-ui")
2486
if err != nil {

pkg/web/resources.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import (
3636
// The {subpath:*} parameter allows for matching any path under /resources/schema/.
3737
func NewSchemaService(rootPath string, workdir string, client ctrlclient.Client) *restful.WebService {
3838
ws := new(restful.WebService)
39-
ws.Path("/resources").
39+
ws.Path(strings.TrimRight(_const.ResourcesAPIPath, "/")).
4040
Produces(restful.MIME_JSON, "text/plain")
4141

4242
h := newSchemaHandler(rootPath, workdir, client)

0 commit comments

Comments
 (0)