diff --git a/README.md b/README.md index 4c3e563..e1359f6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ metadata: openapi.aggregator.io/swagger: "true" # Required openapi.aggregator.io/path: "/v2/api-docs" # Optional (default: /v2/api-docs) openapi.aggregator.io/port: "8080" # Optional (default: 8080) + openapi.aggregator.io/allowed-methods: "get,post" # Optional: Filter allowed HTTP methods ``` ### 3. Create Aggregator Instance @@ -59,6 +60,7 @@ Then open http://localhost:9090 in your browser. - ๐ŸŒ **Unified UI**: Single Swagger UI interface to browse all discovered APIs - ๐Ÿ“ **Service Information**: Displays service metadata including namespace and resource type - โšก **Zero-config Services**: Works with any service that exposes an OpenAPI/Swagger specification +- ๐Ÿ”’ **Secure API Access**: All API requests from Swagger UI are proxied through the aggregator server instead of direct service access ### 5. Ingress/Route Integration @@ -122,6 +124,20 @@ spec: - Serves unified Swagger UI interface - Fetches OpenAPI specs in real-time - Provides API selection and documentation + - Acts as a proxy for all API requests, enhancing security by preventing direct access to services + +### Request Flow + +1. User accesses Swagger UI and selects an API endpoint +2. API request is sent to the Swagger UI Server +3. Server proxies the request to the target service +4. Response is returned through the proxy to Swagger UI + +This proxy architecture provides several benefits: +- Enhanced security by preventing direct access to services +- Consistent request routing and handling +- Ability to add request/response transformations +- Centralized access control and monitoring ### Project Structure @@ -169,43 +185,105 @@ make deploy For installation via Operator Lifecycle Manager, see detailed instructions in [OLM Installation Guide](docs/olm-install.md). -### Development Environment Setup +### Development Setup + +### Prerequisites + +- Go 1.19 or higher +- Kubernetes cluster (local or remote) +- kubectl +- kustomize +- controller-gen + +### Local Development -1. Deploy the test service: +1. Clone the repository: ```bash -# First, deploy the test service that provides OpenAPI specs -kubectl apply -f config/samples/test-service.yaml +git clone https://github.com/hellices/openapi-aggregator-operator.git +cd openapi-aggregator-operator +``` -# Port forward the test service to localhost:8080 -kubectl port-forward svc/test-service 8080:8080 +2. Install dependencies: +```bash +make install-tools ``` -2. Run the operator in development mode: +3. Run the operator locally: ```bash -# Run locally make run +``` + +### Building and Testing -# Run tests +- Build the operator: +```bash +make build +``` + +- Run tests: +```bash make test +``` + +- Build docker image: +```bash +make docker-build +``` + +## Configuration -# Build and push image -make docker-build docker-push +### Annotation Options -# Generate manifests -make manifests +| Annotation | Description | Default | Required | +|------------|-------------|---------|----------| +| openapi.aggregator.io/swagger | Enable swagger aggregation | - | Yes | +| openapi.aggregator.io/path | Path to OpenAPI/Swagger endpoint | /v2/api-docs | No | +| openapi.aggregator.io/port | Port for OpenAPI/Swagger endpoint | 8080 | No | +| openapi.aggregator.io/allowed-methods | Comma-separated list of allowed HTTP methods | All methods | No | + +### OpenAPIAggregator CR Options + +```yaml +apiVersion: observability.aggregator.io/v1alpha1 +kind: OpenAPIAggregator +metadata: + name: openapi-aggregator +spec: + labelSelector: + matchLabels: + app: myapp # Optional: Filter services by labels + updateInterval: 10s # Optional: Specification update interval ``` -Note: When running the operator in development mode with `make run`, ensure that the test service is running and port-forwarded to localhost:8080. This is required for the operator to properly fetch and display the OpenAPI specifications in the Swagger UI. +## Troubleshooting + +### Common Issues + +1. **Services not being discovered** + - Verify service annotations are correct + - Check if service is in the same namespace as the operator + - Ensure service endpoints are accessible -### Version Management +2. **Swagger UI not loading** + - Verify port-forward is running correctly + - Check if swagger-ui service is deployed + - Ensure OpenAPI specifications are valid -- Version is managed in `versions.txt` -- Used for Docker images, releases, and binary info -- Format: `ghcr.io/hellices/openapi-aggregator-operator:` +3. **API endpoints not accessible** + - Verify allowed-methods annotation + - Check if service is running and healthy + - Ensure network policies allow access + +### Logs + +To check operator logs: +```bash +kubectl logs -n openapi-aggregator-system deployment/openapi-aggregator-controller-manager -c manager +``` ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. ## License diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index e2a2341..1e14bfe 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -5,4 +5,4 @@ kind: Kustomization images: - name: controller newName: ghcr.io/hellices/openapi-aggregator-operator - newTag: 0.1.0 + newTag: 0.2.0 diff --git a/config/samples/test-service.yaml b/config/samples/test-service.yaml index 73e8d52..4709b84 100644 --- a/config/samples/test-service.yaml +++ b/config/samples/test-service.yaml @@ -8,7 +8,7 @@ metadata: openapi.aggregator.io/swagger: "true" openapi.aggregator.io/path: "/api/swagger.json" # ์˜ต์…˜ openapi.aggregator.io/port: "8080" # ์˜ต์…˜ - openapi.aggregator.io/allowed-methods: "get" # get๋งŒ ํ—ˆ์šฉ, 'get,post'์ฒ˜๋Ÿผ ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„ํ•˜์—ฌ ์—ฌ๋Ÿฌ ๋ฉ”์„œ๋“œ ํ—ˆ์šฉ ๊ฐ€๋Šฅ + openapi.aggregator.io/allowed-methods: "get,post" # get๋งŒ ํ—ˆ์šฉ, 'get,post'์ฒ˜๋Ÿผ ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„ํ•˜์—ฌ ์—ฌ๋Ÿฌ ๋ฉ”์„œ๋“œ ํ—ˆ์šฉ ๊ฐ€๋Šฅ spec: ports: - port: 8080 diff --git a/install.yaml b/install.yaml index f956218..1bfeedb 100644 --- a/install.yaml +++ b/install.yaml @@ -47,6 +47,11 @@ spec: spec: description: OpenAPIAggregatorSpec defines the desired state of OpenAPIAggregator properties: + allowedMethodsAnnotation: + default: openapi.aggregator.io/allowed-methods + description: AllowedMethodsAnnotation is the annotation key for allowed + HTTP methods in Swagger UI + type: string defaultPath: default: /v2/api-docs description: DefaultPath is the default path for OpenAPI documentation @@ -85,6 +90,12 @@ spec: description: APIInfo contains information about a collected OpenAPI spec properties: + allowedMethods: + description: AllowedMethods stores the allowed HTTP methods + for Swagger UI + items: + type: string + type: array annotations: additionalProperties: type: string @@ -437,7 +448,7 @@ spec: - --metrics-bind-address=:8080 command: - /manager - image: ghcr.io/hellices/openapi-aggregator-operator:0.1.0 + image: ghcr.io/hellices/openapi-aggregator-operator:0.2.0 livenessProbe: httpGet: path: /healthz diff --git a/internal/controller/openapiaggregator_controller.go b/internal/controller/openapiaggregator_controller.go index 5aa395d..0046668 100644 --- a/internal/controller/openapiaggregator_controller.go +++ b/internal/controller/openapiaggregator_controller.go @@ -148,6 +148,11 @@ func (r *OpenAPIAggregatorReconciler) processService(ctx context.Context, svc co } } + // Ensure path starts with "/" + if path != "" && !strings.HasPrefix(path, "/") { + path = "/" + path + } + // Create API info apiInfo := &observabilityv1alpha1.APIInfo{ Name: svc.Name, diff --git a/pkg/swagger/server.go b/pkg/swagger/server.go index 893018b..e7ed47f 100644 --- a/pkg/swagger/server.go +++ b/pkg/swagger/server.go @@ -8,6 +8,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "strings" "sync" @@ -128,32 +129,51 @@ func (s *Server) serveIndividualSpec(w http.ResponseWriter, r *http.Request) { } // Fetch the spec in real-time - url := metadata.URL + urlStr := metadata.URL if os.Getenv("DEV_MODE") == "true" { // In development mode, rewrite any cluster URLs to localhost:8080 - if strings.Contains(url, ".svc.cluster.local:8080") { - url = "http://localhost:8080" + strings.Split(url, ".svc.cluster.local:8080")[1] + if strings.Contains(urlStr, ".svc.cluster.local:8080") { + urlStr = "http://localhost:8080" + strings.Split(urlStr, ".svc.cluster.local:8080")[1] } } - resp, err := http.Get(url) + resp, err := http.Get(urlStr) if err != nil { http.Error(w, fmt.Sprintf("Failed to fetch spec: %v", err), http.StatusInternalServerError) return } - defer func() { - if err := resp.Body.Close(); err != nil { - log.Printf("Failed to close response body: %v", err) - } - }() + if resp != nil { + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("Failed to close response body: %v", err) + } + }() + } if resp.StatusCode != http.StatusOK { http.Error(w, fmt.Sprintf("Failed to fetch spec, status: %d", resp.StatusCode), resp.StatusCode) return } + // Parse metadata URL to get the server URL + metadataURL, err := url.Parse(metadata.URL) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to parse metadata URL: %v", err), http.StatusInternalServerError) + return + } + + // Read and parse the OpenAPI/Swagger spec + var spec map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&spec); err != nil { + http.Error(w, fmt.Sprintf("Failed to decode OpenAPI spec: %v", err), http.StatusInternalServerError) + return + } + + // Update spec based on OpenAPI/Swagger version + s.updateSpecServerInfo(spec, metadataURL) + w.Header().Set("Content-Type", "application/json") - if _, err := io.Copy(w, resp.Body); err != nil { - http.Error(w, "Failed to copy response", http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(spec); err != nil { + http.Error(w, "Failed to encode modified spec", http.StatusInternalServerError) } } @@ -222,12 +242,138 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.serveSpecs(w, r) case strings.HasPrefix(path, "/api/"): s.serveIndividualSpec(w, r) + case strings.HasPrefix(path, "/proxy/"): + s.proxyRequest(w, r) default: s.serveStaticFiles(w, r) } } -//TODO: swagger์—์„œ ๋ณด๋‚ด๋Š” ์š”์ฒญ์„ ๋ชจ๋‘ server.go๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋Š” ๊ธฐ๋Šฅ ์ถ”๊ฐ€. ์„œ๋ฒ„๋Š” ์ด๊ฑธ ๋ฐ›์•„์„œ proxy ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•จ. +// proxyRequest handles proxy requests by forwarding them to the target URL +func (s *Server) proxyRequest(w http.ResponseWriter, r *http.Request) { + var proxyURL string + var reqBody io.Reader + + proxyURL = r.URL.Query().Get("proxyUrl") + if proxyURL == "" { + http.Error(w, "proxyUrl query parameter is required for all requests", http.StatusBadRequest) + return + } + reqBody = nil + + // Get the path after /proxy/ and combine with proxyURL if needed + originalPath := strings.TrimPrefix(r.URL.Path, "/proxy/") + targetURL := proxyURL + // Fetch the spec in real-time + if os.Getenv("DEV_MODE") == "true" { + // In development mode, rewrite any cluster URLs to localhost:8080 + if strings.Contains(targetURL, ".svc.cluster.local:8080") { + targetURL = "http://localhost:8080" + strings.Split(targetURL, ".svc.cluster.local:8080")[1] + } + } + + if originalPath != "" { + targetURL = fmt.Sprintf("%s/%s", proxyURL, originalPath) + } + + // Create new request with the same method and modified body + proxyReq, err := http.NewRequest(r.Method, targetURL, reqBody) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create proxy request: %v", err), http.StatusInternalServerError) + return + } + + // Copy headers from original request + for key, values := range r.Header { + for _, value := range values { + proxyReq.Header.Add(key, value) + } + } + + // Forward the request + client := &http.Client{} + resp, err := client.Do(proxyReq) + if err != nil { + fmt.Printf("Error forwarding request: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to forward request: %v", err), http.StatusInternalServerError) + return + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("Error closing response body: %v", err) + } + }() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + + // Set response status code + w.WriteHeader(resp.StatusCode) + + // Copy response body + if _, err := io.Copy(w, resp.Body); err != nil { + log.Printf("Error copying response body: %v", err) + } +} + +// makeServerURL creates a server URL by combining metadata URL with an optional path from existing URL +func (s *Server) makeServerURL(metadataURL *url.URL, existingURL string) string { + if existingURL == "" { + return fmt.Sprintf("%s://%s", metadataURL.Scheme, metadataURL.Host) + } + + if parsedURL, err := url.Parse(existingURL); err == nil && parsedURL.Path != "" { + return fmt.Sprintf("%s://%s%s", metadataURL.Scheme, metadataURL.Host, parsedURL.Path) + } + + return fmt.Sprintf("%s://%s", metadataURL.Scheme, metadataURL.Host) +} + +// updateSpecServerInfo updates the server information in the OpenAPI spec based on its version +func (s *Server) updateSpecServerInfo(spec map[string]interface{}, metadataURL *url.URL) { + openAPIVersion, _ := spec["openapi"].(string) + swaggerVersion, _ := spec["swagger"].(string) + + // OpenAPI 3.x + if openAPIVersion != "" && strings.HasPrefix(openAPIVersion, "3.") { + existingServers, _ := spec["servers"].([]interface{}) + newServers := make([]interface{}, 0) + + // If there are existing servers, get the URI part from the first server + if len(existingServers) > 0 { + if firstServer, ok := existingServers[0].(map[string]interface{}); ok { + if serverURL, ok := firstServer["url"].(string); ok { + newServers = append(newServers, map[string]interface{}{ + "url": s.makeServerURL(metadataURL, serverURL), + }) + } + } + } + + // If we couldn't get URI from existing servers, add just the host + if len(newServers) == 0 { + newServers = append(newServers, map[string]interface{}{ + "url": s.makeServerURL(metadataURL, ""), + }) + } + + // Append existing servers + newServers = append(newServers, existingServers...) + spec["servers"] = newServers + + } else if swaggerVersion == "2.0" { // Swagger/OpenAPI 2.0 + spec["host"] = metadataURL.Host + + } else { // Swagger 1.2 or undefined + if basePath, ok := spec["basePath"].(string); ok { + spec["basePath"] = s.makeServerURL(metadataURL, basePath) + } + } +} // Start starts the Swagger UI server func (s *Server) Start(port int) error { diff --git a/pkg/swagger/swagger-ui/index.html b/pkg/swagger/swagger-ui/index.html index 43d912a..a85a839 100644 --- a/pkg/swagger/swagger-ui/index.html +++ b/pkg/swagger/swagger-ui/index.html @@ -48,6 +48,27 @@

OpenAPI Specifications

maxRetries: 10 }; + // Request interceptor function + async function requestInterceptor(request) { + // swagger-ui์˜ ๋‚ด๋ถ€ ์š”์ฒญ์ด๋‚˜ static ํŒŒ์ผ ์š”์ฒญ์€ ๊ฐ€๋กœ์ฑ„์ง€ ์•Š์Œ + if (request.url.includes(window.location.host)) { + return request; + } + + try { + // ํ”„๋ก์‹œ URL๋กœ ๋ณ€ํ™˜ + const proxyUrl = new URL(`${window.location.origin}/proxy/`); + proxyUrl.searchParams.set('proxyUrl', request.url); + + // ์›๋ณธ request์˜ URL๋งŒ ์ˆ˜์ • + request.url = proxyUrl.toString(); + return request; + } catch (error) { + console.error('Proxy request failed:', error); + throw error; + } + } + // UI Elements const elements = { get namespaceList() { return document.getElementById('namespaceList'); }, @@ -207,7 +228,8 @@

Failed to load API specifications

Failed to load API specifications