diff --git a/.gitignore b/.gitignore index f91b753..f2508f9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ CODE_REVIEW.md .agent .state -.superpowers \ No newline at end of file +.superpowers +.tmp \ No newline at end of file diff --git a/docs/gateway.md b/docs/gateway.md index 1e4dcef..e9231fc 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -18,13 +18,15 @@ GatewayBuilderPort builder = GatewayFactory.createGateway("gateway-1", "my-clust This creates a local gateway adapter with a specific gateway name and cluster name, sets the base transport port, and selects transport protocol listeners (including L4 TCP proxy). -## Registering Nodes with NodeBuilder +### Registering Nodes with NodeBuilder To support advanced telemetry, authorization, and custom health check paths, GateBridge provides a fluent `NodeBuilder` API: ```java builder.registerNode("node-1", "http://localhost", 3005) .pingProtocol(PingProtocol.HTTP) + .routingProtocol(RoutingProtocol.HTTP) + .telemetryOnly(false) .pingPath("/healthz") .pingHeader("Authorization", "Bearer token123") .register(); @@ -32,6 +34,8 @@ builder.registerNode("node-1", "http://localhost", 3005) * **`registerNode(name, host, port)`** — Specifies the node name, host target, and socket port. * **`pingProtocol(PingProtocol)`** — Toggles and configures the active health check protocol (`HTTP`, `WEBSOCKET`, `TCP`, `UDP`, `GRPC`, or `NONE` for Push-only). +* **`routingProtocol(RoutingProtocol)`** — Selects the routing engine protocol for this node (`HTTP`, `TCP`, `GRPC`). +* **`telemetryOnly(boolean)`** — Flags the node as a passive telemetry-only node. If `true`, the node is ignored during reverse proxy load-balancing routing, but telemetry collection remains active. * **`pingPath(path)`** — Changes the URI path for active health check requests. * **`pingHeader(name, value)`** — Appends custom authentication headers to ping checks. @@ -41,6 +45,18 @@ For simpler registrations, you can still register a node quickly: builder.registerServer(3001, NodeStatus.OFFLINE); ``` +## Ingress Route Rules + +GateBridge supports Nginx-style virtual host and path-pattern Ingress routing rules to map incoming domain requests to target backend clusters: + +```java +// Map requests with Host "app.local" and paths starting with "/api/" to "api-cluster" +RouteRule apiRule = new RouteRule("app.local", "/api/*", "api-cluster"); +builder.addRouteRule(apiRule); +``` + +On incoming requests, GateBridge matches the `Host` header and the request URI path against registered `RouteRule` models. Precedence is given to local server API paths to prevent wildcard rules from shadowing core endpoints. + ## State Persistence Layer The gateway features automatic state persistence: @@ -68,6 +84,12 @@ The optional `event` parameter dispatches a `ClusterEvent.NodeEventSubmitted` ev Refer to [Ping Health-Check Contracts](ping-api-contract.md) for full details. +## API Versioning & Auth Bypass + +Internal framework telemetry and management endpoints are exposed under the `/v1/` URI prefix (e.g., `/v1/clusters`). +- **Path Normalization**: Incoming request paths are automatically normalized to strip the `/v1/` prefix before routing. +- **Authentication Bypass**: In `TokenAuthFilter`, normalized public endpoints mapped under `/v1/` (such as ping endpoints or telemetry push hooks) bypass authentication verification if configured as public. + ## Cluster Routing Modes Clusters can be configured in one of three routing modes to explicitly define behavior: @@ -85,19 +107,22 @@ cluster.setRoutingMode(Cluster.RoutingMode.HYBRID); ## Layer 7 HTTP Reverse Proxy Load-Balancer -When a cluster is in `LOAD_BALANCER_ONLY` or `HYBRID` mode, any incoming HTTP request targeting the REST server on path `/clusters/{clusterName}/{path}` will be proxied: -1. GateBridge selects an active node using thread-safe, overflow-safe Round-Robin. +When a cluster is in `LOAD_BALANCER_ONLY` or `HYBRID` mode, any incoming HTTP request targeting the REST server on path `/clusters/{clusterName}/{path}` (or matched via `RouteRule`) will be proxied: +1. GateBridge filters out `telemetryOnly` nodes and inactive offline nodes, then selects an active node using thread-safe, overflow-safe Round-Robin. 2. The request method, headers, and body are forwarded to the selected node's backend address. -3. The response body is streamed back using chunked transfer encoding (`Transfer-Encoding: chunked`) to prevent JVM heap OOMs. -4. Connection latency is measured passively, and CPU/RAM parameters are extracted from response headers (`X-Telemetry-CPU`, `X-Telemetry-RAM`) to update node state. -5. If target node connection fails, the client receives a `502 Bad Gateway` response. +3. **Traceability Headers**: Auto-injects `X-Forwarded-For` (appending client IP to any existing list), `X-Forwarded-Proto` (the scheme of the client connection), and `X-Forwarded-Host` (original target Host) headers. +4. The response body is streamed back using chunked transfer encoding (`Transfer-Encoding: chunked`) to prevent JVM heap OOMs. +5. Connection latency is measured passively, and CPU/RAM parameters are extracted from response headers (`X-Telemetry-CPU`, `X-Telemetry-RAM`) to update node state. +6. If target node connection fails, the client receives a `502 Bad Gateway` response. ## Layer 4 TCP Proxy Tunneling Load-Balancer When enabled via `.enableTcpProxy(true)`, GateBridge starts a raw Layer 4 TCP proxy on `basePort + 3`: * Spawns virtual threads (`ThreadManager.startVirtual`) to tunnel data bidirectionally between client and backend node. -* Uses Round-Robin node selection to distribute raw TCP streams. -* Handles TCP half-close sequences natively via output shutdown, preserving active tunnels while ensuring clean socket closure upon termination. +* Uses Round-Robin node selection, ignoring `telemetryOnly` nodes, to distribute raw TCP streams. +* **Socket Configurations**: Exposes `tcpSoTimeout(int)` and `tcpKeepAlive(boolean)` configurations on `GatewayBuilderPort` to govern proxy sockets. +* **Dual-Socket Teardown**: Ensures that a close or timeout on either side of the bidirectional tunnel immediately shuts down both sockets, releasing socket file descriptors instantly. +* **Bounded Buffer Pool**: Limits buffer allocation memory footprint using a bounded `BUFFER_POOL` capped at `MAX_POOL_SIZE = 512` cached `8KB` byte arrays, managed via thread-safe atomic size tracking. * Connection latency is passively tracked and updated in the telemetry dashboard. ## Complete Bootstrap Example @@ -110,14 +135,21 @@ GatewayBuilderPort builder = GatewayFactory.createGateway("gateway-1", "producti .enableTelnet(true) .enableHttp(true) .enableWs(true) - .enableTcpProxy(true); // Enables L4 TCP Proxy load-balancing + .enableTcpProxy(true) // Enables L4 TCP Proxy load-balancing + .tcpSoTimeout(30000) // Sets socket timeout to 30s + .tcpKeepAlive(true); // Enables TCP keep-alive pings // Configure routing mode builder.getCluster().setRoutingMode(Cluster.RoutingMode.HYBRID); +// Define Ingress mapping rule +builder.addRouteRule(new RouteRule("service.company.internal", "/users/*", "production-cluster")); + // Register node with name, host, and port builder.registerNode("node-a", "http://localhost", 3001) .pingProtocol(PingProtocol.HTTP) + .routingProtocol(RoutingProtocol.HTTP) + .telemetryOnly(false) .pingPath("/health") .pingHeader("X-Token", "secret") .register(); diff --git a/docs/superpowers/plans/2026-07-21-apply-optimizations.md b/docs/superpowers/plans/2026-07-21-apply-optimizations.md deleted file mode 100644 index dab926f..0000000 --- a/docs/superpowers/plans/2026-07-21-apply-optimizations.md +++ /dev/null @@ -1,35 +0,0 @@ -# Implementation Plan: Apply High-Performance Optimizations - -**Goal:** Apply the approved optimizations from `future_optimizations.md` to Undertow and JDK HTTP transports. - -- [ ] **Step 1: Tune Undertow socket & buffer settings** - - In `UndertowHttpTransport.listen()`, set the following `UndertowOptions` on the builder: - - `ALWAYS_SET_KEEP_ALIVE` = `true` - - `BUFFER_PIPELINED_DATA` = `true` - - `RECORD_REQUEST_START_TIME` = `false` - - `ENABLE_CONNECTOR_STATISTICS` = `false` - - Explicitly configure thread counts and buffer size: - - `.setIoThreads(Runtime.getRuntime().availableProcessors())` - - `.setWorkerThreads(Runtime.getRuntime().availableProcessors())` (restrict native workers since we dispatch to Loom virtual executor) - - `.setBufferSize(16384)` (16 KB buffer size) - -- [ ] **Step 2: Replace HttpURLConnection with Java 21 HttpClient (Connection Pooling)** - - In both `UndertowHttpTransport.java` and `HttpTransport.java`, instantiate a shared, thread-safe `java.net.http.HttpClient` instance: - ```java - private final java.net.http.HttpClient httpClient = java.net.http.HttpClient.newBuilder() - .version(java.net.http.HttpClient.Version.HTTP_2) - .connectTimeout(java.time.Duration.ofMillis(5000)) - .build(); - ``` - - In the proxy routing path, replace `HttpURLConnection` with the shared `httpClient`: - - Build `java.net.http.HttpRequest` with headers, query parameters, method, and request body publisher. - - Send request using `httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream())`. - - Copy headers and stream response input stream back to the client. - -- [ ] **Step 3: Allocation reduction on hot path** - - In `IpRestrictionFilter` and `TokenAuthFilter`, avoid doing string parsing/matching operations unnecessarily. - - Lazily parse path and queries where possible. - -- [ ] **Step 4: Verify compiles & tests** - - Verify compilations and run tests. - - Re-run benchmarks to observe performance impact. diff --git a/docs/superpowers/plans/2026-07-21-benchmark-infrastructure.md b/docs/superpowers/plans/2026-07-21-benchmark-infrastructure.md deleted file mode 100644 index 6f7d1db..0000000 --- a/docs/superpowers/plans/2026-07-21-benchmark-infrastructure.md +++ /dev/null @@ -1,46 +0,0 @@ -# Gateway Benchmark Infrastructure Plan - -**Goal:** Create three benchmark projects (Spring Boot, Node.js + Express, GateBridge) and a Node.js-based benchmarking script using `autocannon` to compare latency and throughput. - -**Directory Structure:** -All benchmark files will reside in `/home/watashi/Projects/Java-framework/gatebridge-benchmarks/`. -- `/home/watashi/Projects/Java-framework/gatebridge-benchmarks/node-express/` (Node + Express project) -- `/home/watashi/Projects/Java-framework/gatebridge-benchmarks/springboot/` (Spring Boot project) -- `/home/watashi/Projects/Java-framework/gatebridge-benchmarks/gatebridge-app/` (GateBridge framework application) -- `/home/watashi/Projects/Java-framework/gatebridge-benchmarks/run-benchmarks.js` (Main runner script) - ---- - -### Step-by-Step Implementation Steps - -- [ ] **Step 1: Create Node.js + Express Benchmark Project** - - Create `node-express/package.json` with dependencies `express`. - - Create `node-express/server.js` running Express on port `8081` with a `/hello` endpoint returning plain text `"hello"`. - -- [ ] **Step 2: Create Spring Boot Benchmark Project** - - Create `springboot/pom.xml` configured with Java 21, Spring Boot Starter Web parent/dependencies, and packaging configurations. - - Create `springboot/src/main/java/benchmark/SpringBootApplication.java` running on port `8082` with a REST controller `/hello` returning plain text `"hello"`. - -- [ ] **Step 3: Create GateBridge Benchmark Project** - - Create `gatebridge-app/pom.xml` using dependency `io.hexacloud:gatebridge-core:1.3.0-release` (compiled/installed locally). - - Create `gatebridge-app/src/main/java/benchmark/GateBridgeApplication.java` starting the gateway on base port `8079` (HTTP on `8080`) with a custom route controller returning plain text `"hello"` for `/hello` endpoint. - -- [ ] **Step 4: Create Benchmark Runner Script** - - In `gatebridge-benchmarks/`, create a `package.json` declaring `autocannon`. - - Run `npm install` inside `gatebridge-benchmarks/` to download `autocannon` locally. - - Create `gatebridge-benchmarks/run-benchmarks.js` that: - 1. Spawns `node-express/server.js` on port `8081`. - 2. Runs autocannon load test (e.g. 100 concurrent connections for 10 seconds). - 3. Stops the process cleanly. - 4. Spawns `springboot` jar on port `8082`. - 5. Runs autocannon load test. - 6. Stops the process cleanly. - 7. Spawns `gatebridge-app` on port `8080` (HTTP on `8080`). - 8. Runs autocannon load test. - 9. Stops the process cleanly. - 10. Prints a markdown table comparing Latency (Average, P50, P90, P99), Throughput (Requests/sec), and Total Requests/Errors. - -- [ ] **Step 5: Run the benchmarks and write results report** - - Build `springboot` and `gatebridge-app` using `mvn clean package`. - - Execute `node run-benchmarks.js` to run the comparison. - - Write results to `/home/watashi/Projects/Java-framework/gatebridge-benchmarks/results.md`. diff --git a/docs/superpowers/plans/2026-07-21-undertow-transport.md b/docs/superpowers/plans/2026-07-21-undertow-transport.md deleted file mode 100644 index 4681f4b..0000000 --- a/docs/superpowers/plans/2026-07-21-undertow-transport.md +++ /dev/null @@ -1,40 +0,0 @@ -# High-Performance Undertow HTTP Transport Implementation Plan - -**Goal:** Integrate the Undertow HTTP engine into GateBridge to achieve 150k+ req/sec throughput. - -- [ ] **Step 1: Add Undertow Dependency** - - Add `undertow-core` version `2.3.13.Final` to `pom.xml`. - -- [ ] **Step 2: Create HttpEngine enum** - - Create `java/src/hexacloud/core/server/HttpEngine.java` containing enum values: `JDK_DEFAULT`, `UNDERTOW`. - -- [ ] **Step 3: Update GatewayBuilderPort and LocalGatewayAdapter** - - Expose `.httpEngine(HttpEngine)` on `GatewayBuilderPort`. - - Store `HttpEngine` field in `LocalGatewayAdapter` (defaulting to `HttpEngine.JDK_DEFAULT`). - - Modify `ensureServerManagerInitialized()` in `LocalGatewayAdapter` to configure the chosen `HttpEngine` on `ServerManager`! - Wait, `ServerManager` needs to know which engine to use. Let's add `HttpEngine` support to `ServerManager`. - -- [ ] **Step 4: Update ServerManager** - - Add `HttpEngine` field to `ServerManager` (with getter/setter, defaulting to `JDK_DEFAULT`). - - In `ServerManager.listen(port)`, if `httpEnabled` is true, check if `httpEngine` is `HttpEngine.UNDERTOW`. - If yes, instantiate `UndertowHttpTransport` instead of `HttpTransport`! - -- [ ] **Step 5: Implement Undertow Request and Response Wrappers** - - Create `java/src/hexacloud/infra/server/UndertowHttpRequestImpl.java` implementing `hexacloud.core.server.filter.HttpRequest`. - - Create `java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java` implementing `hexacloud.core.server.filter.HttpResponse`. - -- [ ] **Step 6: Implement UndertowHttpTransport** - - Create `java/src/hexacloud/infra/server/UndertowHttpTransport.java` implementing `hexacloud.core.server.ServerTransport`. - - Set up CORS headers. - - Execute filter chain (`IpRestrictionFilter`, `RateLimitFilter`, `TokenAuthFilter`, custom filters). - - Handle custom direct routes (like `/hello`). - - Handle proxy load balancing for `/clusters//` using `HttpURLConnection` (just like `HttpTransport` does). - - Ensure blocking operations call `exchange.startBlocking()` first. - -- [ ] **Step 7: Run verification tests** - - Verify compile: `mvn clean test` - - Re-run benchmarks to compare: - 1. Node + Express - 2. Spring Boot - 3. GateBridge (JDK HTTP) - 4. GateBridge (Undertow HTTP) diff --git a/docs/terminal-ui.md b/docs/terminal-ui.md index c48b511..d244f47 100644 --- a/docs/terminal-ui.md +++ b/docs/terminal-ui.md @@ -24,10 +24,19 @@ TerminalUiFactory.createTui("DevOps Control Plane") .clusterManagementEnabled(true) // Enable [C] key to create clusters .nodeManagementEnabled(true) // Enable [A]/[D] to register/deregister nodes .nodeConfigurationEnabled(true) // Enable [Enter] config of ping routes & headers + .redirectSystemOut(false) // If true, redirects System.out/System.err to TUI log panel .seedGateway(hexacloud) // Inject already started gateway instance .startToggleMode(); // Start in non-blocking toggle/detachable mode ``` +* **`readOnly(boolean)`** — Restricts write actions inside the console. +* **`gatewayManagementEnabled(boolean)`** — Enables manually starting/stopping gateway listeners. +* **`clusterManagementEnabled(boolean)`** — Enables cluster creation. +* **`nodeManagementEnabled(boolean)`** — Enables registering/deregistering service nodes. +* **`nodeConfigurationEnabled(boolean)`** — Enables updating route/ping header settings. +* **`redirectSystemOut(boolean)`** — Sets whether standard output (`System.out` and `System.err`) is hijacked and redirected to the dashboard's log pane (defaults to `false` for embedded library usage, `true` for standalone executables). +* **`seedGateway(RunningGatewayPort)`** — Seeds the console with a pre-configured gateway. + ## Non-Blocking Detachable Mode (`startToggleMode`) When calling `startToggleMode()`, the application runs the gateways in the background and prints standard framework logs to standard output. diff --git a/java/src-java8/hexacloud/core/cluster/event/ClusterEvent.java b/java/src-java8/hexacloud/core/cluster/event/ClusterEvent.java index 0b9a80e..3097819 100644 --- a/java/src-java8/hexacloud/core/cluster/event/ClusterEvent.java +++ b/java/src-java8/hexacloud/core/cluster/event/ClusterEvent.java @@ -104,10 +104,12 @@ public String toString() { public static class NodeStatusChanged implements ClusterEvent { private final String host; private final NodeStatus status; + private final String nodeId; - public NodeStatusChanged(String host, NodeStatus status) { + public NodeStatusChanged(String host, NodeStatus status, String nodeId) { this.host = host; this.status = status; + this.nodeId = nodeId; } public String host() { @@ -118,52 +120,62 @@ public NodeStatus status() { return status; } + public String nodeId() { + return nodeId; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; NodeStatusChanged that = (NodeStatusChanged) o; - return Objects.equals(host, that.host) && status == that.status; + return Objects.equals(host, that.host) && status == that.status && Objects.equals(nodeId, that.nodeId); } @Override public int hashCode() { - return Objects.hash(host, status); + return Objects.hash(host, status, nodeId); } @Override public String toString() { - return "NodeStatusChanged[host=" + host + ", status=" + status + "]"; + return "NodeStatusChanged[host=" + host + ", status=" + status + ", nodeId=" + nodeId + "]"; } } public static class NodeTelemetryUpdated implements ClusterEvent { private final String host; + private final String nodeId; - public NodeTelemetryUpdated(String host) { + public NodeTelemetryUpdated(String host, String nodeId) { this.host = host; + this.nodeId = nodeId; } public String host() { return host; } + public String nodeId() { + return nodeId; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; NodeTelemetryUpdated that = (NodeTelemetryUpdated) o; - return Objects.equals(host, that.host); + return Objects.equals(host, that.host) && Objects.equals(nodeId, that.nodeId); } @Override public int hashCode() { - return Objects.hash(host); + return Objects.hash(host, nodeId); } @Override public String toString() { - return "NodeTelemetryUpdated[host=" + host + "]"; + return "NodeTelemetryUpdated[host=" + host + ", nodeId=" + nodeId + "]"; } } diff --git a/java/src-java8/hexacloud/infra/server/WsTransport.java b/java/src-java8/hexacloud/infra/server/WsTransport.java index 4f3a0e7..c8eeeb3 100644 --- a/java/src-java8/hexacloud/infra/server/WsTransport.java +++ b/java/src-java8/hexacloud/infra/server/WsTransport.java @@ -247,7 +247,7 @@ public void stop() { } closeAllClients(); threadPool.shutdownNow(); - DebugUtils.log("WebSocket Transport stopped."); + DebugUtils.info("WebSocket Transport stopped."); } private void closeAllClients() { diff --git a/java/src/hexacloud/application/Main.java b/java/src/hexacloud/application/Main.java index 541cf9c..2839de5 100644 --- a/java/src/hexacloud/application/Main.java +++ b/java/src/hexacloud/application/Main.java @@ -85,6 +85,7 @@ public void start() { // Launch the DevOps Panel in non-blocking toggle mode (detach/reattach with ENTER) TerminalUiFactory.createTui("MyCompany - GateBridge DevOps Panel") .seedGateway(runningGateway) + .redirectSystemOut(true) .startToggleMode(); } @@ -124,7 +125,7 @@ public void onNodeEventSubmitted(NodeEventSubmitted event) { // Custom developer endpoint controller - automatically discovered by PathUtils scanner public static class CustomAppController implements RouteController { - @RouteMapping("HELLO") + @RouteMapping("/hello") public void sayHello(String args, PrintWriter out) { out.println("HELLO FROM DEVELOPER ROUTE! Args: " + args); } diff --git a/java/src/hexacloud/application/MinimalApplication.java b/java/src/hexacloud/application/MinimalApplication.java index ae81618..9212c92 100644 --- a/java/src/hexacloud/application/MinimalApplication.java +++ b/java/src/hexacloud/application/MinimalApplication.java @@ -131,6 +131,7 @@ public void run() { // 6. Launch the DevOps Dashboard in non-blocking toggle mode (detach/reattach with ENTER) hexacloud.core.tui.TerminalUiFactory.createTui("GateBridge Minimal DevOps Panel") .seedGateway(runningGateway) + .redirectSystemOut(true) .startToggleMode(); } @@ -183,13 +184,13 @@ public void onNodeEventSubmitted(ClusterEvent.NodeEventSubmitted event) { public static class DemoRouteController implements RouteController { - @RouteMapping("HELLO") + @RouteMapping("/hello") public void handleHello(String args, PrintWriter out) { out.println("HELLO FROM MINIMAL APPLICATION ROUTE!"); out.println("Arguments received: " + (args.isEmpty() ? "None" : args)); } - @RouteMapping("SYSTEM_INFO") + @RouteMapping("/system_info") public void handleSystemInfo(String args, PrintWriter out) { out.println("GateBridge Framework Status: ACTIVE"); out.println("Available Processors: " + Runtime.getRuntime().availableProcessors()); diff --git a/java/src/hexacloud/application/OnlyTerminalMain.java b/java/src/hexacloud/application/OnlyTerminalMain.java index 7dfa396..4d0b9de 100644 --- a/java/src/hexacloud/application/OnlyTerminalMain.java +++ b/java/src/hexacloud/application/OnlyTerminalMain.java @@ -3,6 +3,7 @@ public class OnlyTerminalMain { public static void main(String[] args) { hexacloud.core.tui.TerminalUiFactory.createTui("MyCompany - GateBridge DevOps Panel") + .redirectSystemOut(true) .start(); } } diff --git a/java/src/hexacloud/application/TerminalMain.java b/java/src/hexacloud/application/TerminalMain.java index 609e882..007c388 100644 --- a/java/src/hexacloud/application/TerminalMain.java +++ b/java/src/hexacloud/application/TerminalMain.java @@ -38,6 +38,7 @@ public static void main(String[] args) { // Launch the DevOps Panel in non-blocking toggle mode (detach/reattach with ENTER) TerminalUiFactory.createTui("MyCompany - GateBridge DevOps Panel") .seedGateway(runningGateway) + .redirectSystemOut(true) .startToggleMode(); } } diff --git a/java/src/hexacloud/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index 539d7d5..59e9e5f 100644 --- a/java/src/hexacloud/core/cluster/Cluster.java +++ b/java/src/hexacloud/core/cluster/Cluster.java @@ -29,6 +29,7 @@ public enum RoutingMode { private final ClusterSecurityManager securityManager; private final RouteRegistry routeRegistry; private final ReentrantLock lock = new ReentrantLock(); + private final java.util.concurrent.atomic.AtomicInteger roundRobinIdx = new java.util.concurrent.atomic.AtomicInteger(0); private String clusterName = ClusterConfig.DEFAULT_CLUSTER_NAME; private String clusterUri = ClusterConfig.DEFAULT_CLUSTER_URI; @@ -62,7 +63,7 @@ public Cluster(String clusterName, ClusterEventBusManager eventManager) { this.clusterName = clusterName; this.eventManager = eventManager; this.securityManager = new ClusterSecurityManager(clusterName); - this.routeRegistry = new RouteRegistry(); + this.routeRegistry = new RouteRegistry("Cluster:" + clusterName); this.routeRegistry.registerController(new ClusterController(this)); ClusterRegistry.getInstance().registerCluster(this); } @@ -94,14 +95,13 @@ public void registerServer(ServerNode node) { String host = validHost(node.host()); if (host == null) return; - String fullHost = host + ":" + node.port(); if (bootstrapMode) { - registeredStaticNodesThisRun.add(fullHost); - staticNodes.add(fullHost); + registeredStaticNodesThisRun.add(node.getId()); + staticNodes.add(node.getId()); // If a state file was loaded, respect remote deletions of static nodes if (hexacloud.core.config.ClusterStatePersistence.isStateLoaded()) { - if (persistedStaticNodes.contains(fullHost) && !cluster.containsKey(fullHost)) { + if (persistedStaticNodes.contains(node.getId()) && !cluster.containsKey(node.getId())) { return; // Ignore/Respect remote deletion } } @@ -112,7 +112,7 @@ public void registerServer(ServerNode node) { ServerNode validNode = new ServerNode( node.name(), host, node.port(), node.status(), node.isExternal(), - node.pingProtocol(), node.pingPath(), node.pingHeaderName(), node.pingHeaderValue(), node.isDynamic(), node.telemetryOnly() + node.pingProtocol(), node.pingPath(), node.pingHeaderName(), node.pingHeaderValue(), node.isDynamic(), node.telemetryOnly(), node.routingProtocol() ); addClusterNode(validNode); } finally { @@ -127,15 +127,14 @@ public void registerLoadedServer(ServerNode node) { String host = validHost(node.host()); if (host == null) return; - String fullHost = host + ":" + node.port(); if (!node.isDynamic()) { - staticNodes.add(fullHost); - persistedStaticNodes.add(fullHost); + staticNodes.add(node.getId()); + persistedStaticNodes.add(node.getId()); } ServerNode validNode = new ServerNode( node.name(), host, node.port(), node.status(), node.isExternal(), - node.pingProtocol(), node.pingPath(), node.pingHeaderName(), node.pingHeaderValue(), node.isDynamic(), node.telemetryOnly() + node.pingProtocol(), node.pingPath(), node.pingHeaderName(), node.pingHeaderValue(), node.isDynamic(), node.telemetryOnly(), node.routingProtocol() ); addClusterNode(validNode); } finally { @@ -149,15 +148,15 @@ public void endBootstrapPhase() { this.bootstrapMode = false; if (hexacloud.core.config.ClusterStatePersistence.isStateLoaded()) { java.util.List toPrune = new java.util.ArrayList<>(); - for (String fullHost : persistedStaticNodes) { - if (!registeredStaticNodesThisRun.contains(fullHost)) { - toPrune.add(fullHost); + for (String nodeId : persistedStaticNodes) { + if (!registeredStaticNodesThisRun.contains(nodeId)) { + toPrune.add(nodeId); } } - for (String fullHost : toPrune) { - cluster.remove(fullHost); - staticNodes.remove(fullHost); - persistedStaticNodes.remove(fullHost); + for (String nodeId : toPrune) { + cluster.remove(nodeId); + staticNodes.remove(nodeId); + persistedStaticNodes.remove(nodeId); } if (!toPrune.isEmpty()) { hexacloud.core.config.ClusterStatePersistence.saveState(); @@ -189,7 +188,7 @@ public void registerServer(int port, NodeStatus status) { public void deregisterServer(String fullHost) { lock.lock(); try { - DebugUtils.log("Deregistering server " + fullHost); + DebugUtils.info("Deregistering server " + fullHost); removeClusterNode(fullHost); } finally { lock.unlock(); @@ -199,7 +198,7 @@ public void deregisterServer(String fullHost) { public void deregisterLastServer() { lock.lock(); try { - DebugUtils.log("Deregistering last server in the cluster"); + DebugUtils.info("Deregistering last server in the cluster"); removeClusterNode(); } finally { lock.unlock(); @@ -229,7 +228,7 @@ public void listClusterNodes() { try { for (ServerNode node : cluster.values()) { if (node != null) { - DebugUtils.log(node.toString()); + DebugUtils.info(node.toString()); } } } finally { @@ -246,14 +245,35 @@ public List getCluster() { } } - public void updateStatusServer(String host, NodeStatus status) { + public ServerNode selectNode() { lock.lock(); try { - if (!this.cluster.containsKey(host)) { - DebugUtils.error(this.clusterName, host, "Cannot update status: Server host '" + host + "' is not registered in the cluster."); + List activeNodes = new ArrayList<>(); + for (ServerNode n : cluster.values()) { + if (n != null && n.status() == NodeStatus.ONLINE && !n.telemetryOnly() + && (n.routingProtocol() == hexacloud.core.model.RoutingProtocol.HTTP + || n.routingProtocol() == hexacloud.core.model.RoutingProtocol.GRPC)) { + activeNodes.add(n); + } + } + if (activeNodes.isEmpty()) { + return null; + } + int selectedIndex = (roundRobinIdx.getAndIncrement() & Integer.MAX_VALUE) % activeNodes.size(); + return activeNodes.get(selectedIndex); + } finally { + lock.unlock(); + } + } + // this method ok + public void updateStatusServer(String nodeId, NodeStatus status) { + lock.lock(); + try { + if (!this.cluster.containsKey(nodeId)) { + DebugUtils.error(this.clusterName, nodeId, "Cannot update status: Server node '" + nodeId + "' is not registered in the cluster."); return; } - this.cluster.computeIfPresent(host, (key, serverNode) -> serverNode.withStatus(status)); + this.cluster.computeIfPresent(nodeId, (key, serverNode) -> serverNode.withStatus(status)); } finally { lock.unlock(); } @@ -281,10 +301,7 @@ public NodeUpdateResult updateTelemetryServer(String host, int port, Double cpuU if (latencyMs != null) updated.setLatencyMs(latencyMs); this.cluster.put(targetKey, updated); - if (!batchMode) { - ClusterStatePersistence.saveState(); - } - return new NodeUpdateResult(updated.getFullHost(), updated.pingProtocol().getFriendlyName(), statusChanged, telemetryUpdated); + return new NodeUpdateResult(updated.getFullHost(), updated.pingProtocol().getFriendlyName(), statusChanged, telemetryUpdated, current.getId()); } finally { lock.unlock(); } @@ -297,10 +314,10 @@ public void updateServerNode(ServerNode updatedNode) { if (updatedNode == null) return; lock.lock(); try { - String key = updatedNode.getFullHost(); + String key = updatedNode.getId(); if (this.cluster.containsKey(key)) { this.cluster.put(key, updatedNode); - DebugUtils.log("Updated server node configuration: " + updatedNode); + DebugUtils.info("Updated server node configuration: " + updatedNode); if (!batchMode) { ClusterStatePersistence.saveState(); } @@ -333,7 +350,7 @@ private void toggleAllServers(boolean start) { if (start) { registerServer(node); } else { - deregisterServer(node.getFullHost()); + deregisterServer(node.getId()); } } } @@ -356,7 +373,7 @@ private void centralizedRegister(int port, String host, NodeStatus status, boole DebugUtils.error(this.clusterName, null, "Cannot register a new server while there are stopped servers in the cluster. Please register all stopped servers first."); return; } - DebugUtils.log("Registering server on host: " + host + ", port: " + port); + DebugUtils.info("Registering server on host: " + host + ", port: " + port); host = validHost(host); addClusterNode(new ServerNode(host, port, status, isExternal)); } finally { @@ -372,7 +389,7 @@ private void addClusterNode(ServerNode node) { return; } - cluster.put(node.getFullHost(), node); + cluster.put(node.getId(), node); if (eventManager != null) { eventManager.dispatch(new NodeRegistered(node)); } @@ -399,11 +416,11 @@ private void removeClusterNode() { }); } - private void removeClusterNode(String fullHost) { - if (cluster.containsKey(fullHost)) { - cluster.remove(fullHost); + private void removeClusterNode(String nodeId) { + if (cluster.containsKey(nodeId)) { + cluster.remove(nodeId); if (eventManager != null) { - eventManager.dispatch(new hexacloud.core.cluster.event.ClusterEvent.NodeDeregistered(fullHost)); + eventManager.dispatch(new hexacloud.core.cluster.event.ClusterEvent.NodeDeregistered(nodeId)); } if (!batchMode) { ClusterStatePersistence.saveState(); @@ -440,8 +457,8 @@ private boolean validServer(ServerNode node) { return false; } - if (cluster.containsKey(node.getFullHost())) { - DebugUtils.error(this.clusterName, null, "Invalid server node: node '" + node.getFullHost() + "' is already registered"); + if (cluster.containsKey(node.getId())) { + DebugUtils.error(this.clusterName, null, "Invalid server node: node '" + node.getId() + "' is already registered"); return false; } @@ -454,7 +471,7 @@ private String findNodeKey(String host, int port) { for (ServerNode node : cluster.values()) { if (node == null) continue; if (node.getHostWithoutProtocol().equals(normalizedTargetHost) && node.port() == port) { - return node.getFullHost(); + return node.getId(); } } return null; diff --git a/java/src/hexacloud/core/cluster/ClusterManager.java b/java/src/hexacloud/core/cluster/ClusterManager.java index d7f3d76..74229d8 100644 --- a/java/src/hexacloud/core/cluster/ClusterManager.java +++ b/java/src/hexacloud/core/cluster/ClusterManager.java @@ -63,8 +63,8 @@ public ClusterManager deregisterAllServers() { } @Override - public ClusterManager deregisterServer(String fullHost) { - this.cluster.deregisterServer(fullHost); + public ClusterManager deregisterServer(String nodeId) { + this.cluster.deregisterServer(nodeId); return this; } @@ -86,10 +86,11 @@ public void onClusterEvent(ClusterEvent event) { if (statusEvent != null) { DebugUtils.info( this.cluster.getClusterName(), - statusEvent.host(), - "Node status changed: " + statusEvent.host() + " -> " + statusEvent.status() + statusEvent.nodeId(), + "Node status changed: " + statusEvent.nodeId() + " -> " + statusEvent.status() ); - this.cluster.updateStatusServer(statusEvent.host(), statusEvent.status()); + // statusEvent.nodeId() is the stable node identity and the map key. + this.cluster.updateStatusServer(statusEvent.nodeId(), statusEvent.status()); } }); } diff --git a/java/src/hexacloud/core/cluster/ClusterService.java b/java/src/hexacloud/core/cluster/ClusterService.java index 9a2d2aa..47eca88 100644 --- a/java/src/hexacloud/core/cluster/ClusterService.java +++ b/java/src/hexacloud/core/cluster/ClusterService.java @@ -48,10 +48,10 @@ public boolean updateTelemetry(TelemetryRequest request) { NodeStatus finalStatus = requestedStatus != null ? requestedStatus : NodeStatus.ONLINE; if (result.statusChanged()) { - cluster.dispatchEvent(new ClusterEvent.NodeStatusChanged(result.host(), finalStatus)); + cluster.dispatchEvent(new ClusterEvent.NodeStatusChanged(result.nodeId(), finalStatus, result.nodeId())); } if (result.telemetryUpdated()) { - cluster.dispatchEvent(new ClusterEvent.NodeTelemetryUpdated(result.host())); + cluster.dispatchEvent(new ClusterEvent.NodeTelemetryUpdated(result.host(), result.nodeId())); } if (request.getEventName() != null && !StrUtils.isBlank(request.getEventName())) { cluster.dispatchEvent(new ClusterEvent.NodeEventSubmitted( diff --git a/java/src/hexacloud/core/cluster/event/ClusterEvent.java b/java/src/hexacloud/core/cluster/event/ClusterEvent.java index 086c704..0fcd5bc 100644 --- a/java/src/hexacloud/core/cluster/event/ClusterEvent.java +++ b/java/src/hexacloud/core/cluster/event/ClusterEvent.java @@ -20,9 +20,9 @@ record NodeRegistered(ServerNode node) implements ClusterEvent {} record NodeDeregistered(String host) implements ClusterEvent {} - record NodeStatusChanged(String host, NodeStatus status) implements ClusterEvent {} + record NodeStatusChanged(String host, NodeStatus status, String nodeId) implements ClusterEvent {} - record NodeTelemetryUpdated(String host) implements ClusterEvent {} + record NodeTelemetryUpdated(String host, String nodeId) implements ClusterEvent {} record NodeEventSubmitted(String host, int port, PingProtocol protocol, EventFormat format, String event, Map attributes) implements ClusterEvent {} diff --git a/java/src/hexacloud/core/config/EnvLoader.java b/java/src/hexacloud/core/config/EnvLoader.java index 6d2d5d0..a9fa17c 100644 --- a/java/src/hexacloud/core/config/EnvLoader.java +++ b/java/src/hexacloud/core/config/EnvLoader.java @@ -66,7 +66,7 @@ private static boolean loadEnvPath(String path, Boolean stream) { DebugUtils.info("EnvLoader: Loaded configurations from file '" + path + "'"); return true; } catch( IOException ex ) { - DebugUtils.log("Envloader: Config file not found at path: " + path); + DebugUtils.info("Envloader: Config file not found at path: " + path); return false; } } diff --git a/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java b/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java index d835bbd..8d15663 100644 --- a/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java +++ b/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java @@ -34,16 +34,14 @@ private String getStateDirectory() { if (dir == null) { dir = System.getenv("HEXACLOUD_STATE_DIR"); } - if (dir == null || dir.trim().isEmpty()) { dir = ".state"; } - File dirFile = new File(dir); if (!dirFile.exists()) { dirFile.mkdirs(); + DebugUtils.info("Created directory " + dirFile.getAbsolutePath()); } - return dir; } @@ -87,7 +85,7 @@ private void saveClusterState(Cluster cluster) { java.util.List nodeKeys = new java.util.ArrayList<>(); for (ServerNode node : cluster.getCluster()) { - String nodeKey = node.getFullHost(); + String nodeKey = node.getId(); nodeKeys.add(nodeKey); writer.println("# === BEGIN NODE " + nodeKey + " ==="); @@ -115,7 +113,7 @@ private void saveClusterState(Cluster cluster) { writer.println("# === END NODE LIST ==="); writer.println(); - DebugUtils.log("DevOps Panel: Saved active configurations state to " + filePath); + DebugUtils.info("DevOps Panel: Saved active configurations state to " + filePath); } catch (IOException e) { DebugUtils.error("DevOps Panel: Failed to save state file for cluster " + name, e); } @@ -124,6 +122,7 @@ private void saveClusterState(Cluster cluster) { @Override public synchronized void loadState() { loading = true; + DebugUtils.info("DevOps Panel: Loading active configurations from "); try { List filesToLoad = new ArrayList<>(); @@ -131,11 +130,11 @@ public synchronized void loadState() { findStateFiles(stateDir, filesToLoad); if (filesToLoad.isEmpty()) { - DebugUtils.log("DevOps Panel: No *-state.properties configuration files found in '" + stateDir.getPath() + "'. Checking classpath resources..."); + DebugUtils.info("DevOps Panel: No *-state.properties configuration files found in '" + stateDir.getPath() + "'. Checking classpath resources..."); boolean loadedFromClasspath = tryLoadFromClasspath("c1"); if (!loadedFromClasspath) { - DebugUtils.log("DevOps Panel: No default state files found on classpath. Starting clean."); + DebugUtils.info("DevOps Panel: No default state files found on classpath. Starting clean."); stateLoaded = false; return; } @@ -206,7 +205,7 @@ private void loadClusterStateFile(File file, String name) { return; } loadClusterStateProperties(props, name); - DebugUtils.log("DevOps Panel: Configuration state restored for cluster '" + name + "' from " + file.getPath()); + DebugUtils.info("DevOps Panel: Configuration state restored for cluster '" + name + "' from " + file.getPath()); } private void loadClusterStateProperties(Properties props, String name) { @@ -269,7 +268,7 @@ private void loadClusterStateProperties(Properties props, String name) { ); boolean alreadyRegistered = cluster.getCluster().stream() - .anyMatch(n -> n.getFullHost().equals(node.getFullHost())); + .anyMatch(n -> n.getId().equals(node.getId())); if (!alreadyRegistered) { cluster.registerLoadedServer(node); } diff --git a/java/src/hexacloud/core/event/EventBusManager.java b/java/src/hexacloud/core/event/EventBusManager.java index 4768a2c..a34353c 100644 --- a/java/src/hexacloud/core/event/EventBusManager.java +++ b/java/src/hexacloud/core/event/EventBusManager.java @@ -34,7 +34,9 @@ public void dispatch(T event) { Class eventType = event.getClass(); List> listeners = channels.get(eventType); - DebugUtils.log("Dispatching event: " + event); + if (this == GLOBAL) { + DebugUtils.info("Dispatching event: " + event); + } // Run interceptors for (EventListener interceptor : interceptors) { diff --git a/java/src/hexacloud/core/model/NodeUpdateResult.java b/java/src/hexacloud/core/model/NodeUpdateResult.java index 2d6c5ef..740816c 100644 --- a/java/src/hexacloud/core/model/NodeUpdateResult.java +++ b/java/src/hexacloud/core/model/NodeUpdateResult.java @@ -1,3 +1,3 @@ package hexacloud.core.model; -public record NodeUpdateResult(String host, String protocol, boolean statusChanged, boolean telemetryUpdated) {} +public record NodeUpdateResult(String host, String protocol, boolean statusChanged, boolean telemetryUpdated, String nodeId) {} diff --git a/java/src/hexacloud/core/model/RoutingProtocol.java b/java/src/hexacloud/core/model/RoutingProtocol.java new file mode 100644 index 0000000..2f5a952 --- /dev/null +++ b/java/src/hexacloud/core/model/RoutingProtocol.java @@ -0,0 +1,15 @@ +package hexacloud.core.model; + +/** + * Defines how the gateway routes traffic TO this ServerNode. + * Independent of PingProtocol (which controls health-check behavior). + * + * HTTP — node accepts HTTP/HTTPS reverse-proxy traffic (default). + * TCP — node accepts raw TCP tunneled traffic only. + * GRPC — node accepts gRPC traffic (HTTP/2 with content-type enforcement). + */ +public enum RoutingProtocol { + HTTP, + TCP, + GRPC +} diff --git a/java/src/hexacloud/core/model/ServerNode.java b/java/src/hexacloud/core/model/ServerNode.java index 4d6cbbb..d647bcb 100644 --- a/java/src/hexacloud/core/model/ServerNode.java +++ b/java/src/hexacloud/core/model/ServerNode.java @@ -2,9 +2,11 @@ /** * Represents a registered service node inside a cluster. - * Contains connection coordinates, status metadata, and health-check configurations. + * Contains connection coordinates, status metadata, health-check configurations, + * and routing protocol declaration. */ public class ServerNode { + private final String id; private final String name; private final String host; private final int port; @@ -16,6 +18,7 @@ public class ServerNode { private final String pingHeaderValue; private final boolean isDynamic; private final boolean telemetryOnly; + private final RoutingProtocol routingProtocol; private int latencyMs = 0; private double cpuUsage = 0.0; @@ -23,10 +26,11 @@ public class ServerNode { private String runtime = ""; /** - * Primary constructor including node name, isDynamic, and telemetryOnly flags. + * Primary constructor including node name, isDynamic, telemetryOnly flags, and routingProtocol. */ public ServerNode(String name, String host, int port, NodeStatus status, boolean isExternal, - PingProtocol pingProtocol, String pingPath, String pingHeaderName, String pingHeaderValue, boolean isDynamic, boolean telemetryOnly) { + PingProtocol pingProtocol, String pingPath, String pingHeaderName, String pingHeaderValue, + boolean isDynamic, boolean telemetryOnly, RoutingProtocol routingProtocol) { this.name = name != null && !name.isEmpty() ? name : (host + ":" + port); this.host = host; this.port = port; @@ -38,6 +42,16 @@ public ServerNode(String name, String host, int port, NodeStatus status, boolean this.pingHeaderValue = pingHeaderValue; this.isDynamic = isDynamic; this.telemetryOnly = telemetryOnly; + this.routingProtocol = routingProtocol != null ? routingProtocol : RoutingProtocol.HTTP; + this.id = name != null && !name.isEmpty() ? name : (host + ":" + port); + } + + /** + * Constructor including node name, isDynamic and telemetryOnly flags. + */ + public ServerNode(String name, String host, int port, NodeStatus status, boolean isExternal, + PingProtocol pingProtocol, String pingPath, String pingHeaderName, String pingHeaderValue, boolean isDynamic, boolean telemetryOnly) { + this(name, host, port, status, isExternal, pingProtocol, pingPath, pingHeaderName, pingHeaderValue, isDynamic, telemetryOnly, RoutingProtocol.HTTP); } /** @@ -45,7 +59,7 @@ public ServerNode(String name, String host, int port, NodeStatus status, boolean */ public ServerNode(String name, String host, int port, NodeStatus status, boolean isExternal, PingProtocol pingProtocol, String pingPath, String pingHeaderName, String pingHeaderValue, boolean isDynamic) { - this(name, host, port, status, isExternal, pingProtocol, pingPath, pingHeaderName, pingHeaderValue, isDynamic, false); + this(name, host, port, status, isExternal, pingProtocol, pingPath, pingHeaderName, pingHeaderValue, isDynamic, false, RoutingProtocol.HTTP); } /** @@ -53,7 +67,7 @@ public ServerNode(String name, String host, int port, NodeStatus status, boolean */ public ServerNode(String name, String host, int port, NodeStatus status, boolean isExternal, PingProtocol pingProtocol, String pingPath, String pingHeaderName, String pingHeaderValue) { - this(name, host, port, status, isExternal, pingProtocol, pingPath, pingHeaderName, pingHeaderValue, false); + this(name, host, port, status, isExternal, pingProtocol, pingPath, pingHeaderName, pingHeaderValue, false, false, RoutingProtocol.HTTP); } /** @@ -61,12 +75,12 @@ public ServerNode(String name, String host, int port, NodeStatus status, boolean */ public ServerNode(String host, int port, NodeStatus status, boolean isExternal, PingProtocol pingProtocol, String pingPath, String pingHeaderName, String pingHeaderValue) { - this(host + ":" + port, host, port, status, isExternal, pingProtocol, pingPath, pingHeaderName, pingHeaderValue); + this(host + ":" + port, host, port, status, isExternal, pingProtocol, pingPath, pingHeaderName, pingHeaderValue, false, false, RoutingProtocol.HTTP); } public ServerNode(String name, String host, int port, NodeStatus status, boolean isExternal, boolean pingEnabled, String pingPath, String pingHeaderName, String pingHeaderValue) { - this(name, host, port, status, isExternal, pingEnabled ? PingProtocol.HTTP : PingProtocol.NONE, pingPath, pingHeaderName, pingHeaderValue); + this(name, host, port, status, isExternal, pingEnabled ? PingProtocol.HTTP : PingProtocol.NONE, pingPath, pingHeaderName, pingHeaderValue, false, false, RoutingProtocol.HTTP); } /** @@ -74,18 +88,18 @@ public ServerNode(String name, String host, int port, NodeStatus status, boolean */ public ServerNode(String host, int port, NodeStatus status, boolean isExternal, boolean pingEnabled, String pingPath, String pingHeaderName, String pingHeaderValue) { - this(host + ":" + port, host, port, status, isExternal, pingEnabled ? PingProtocol.HTTP : PingProtocol.NONE, pingPath, pingHeaderName, pingHeaderValue); + this(host + ":" + port, host, port, status, isExternal, pingEnabled ? PingProtocol.HTTP : PingProtocol.NONE, pingPath, pingHeaderName, pingHeaderValue, false, false, RoutingProtocol.HTTP); } public ServerNode(String name, String host, int port, NodeStatus status, boolean isExternal) { - this(name, host, port, status, isExternal, PingProtocol.HTTP, "/", null, null); + this(name, host, port, status, isExternal, PingProtocol.HTTP, "/", null, null, false, false, RoutingProtocol.HTTP); } /** * Constructor for default health-check settings. */ public ServerNode(String host, int port, NodeStatus status, boolean isExternal) { - this(host + ":" + port, host, port, status, isExternal, PingProtocol.HTTP, "/", null, null); + this(host + ":" + port, host, port, status, isExternal, PingProtocol.HTTP, "/", null, null, false, false, RoutingProtocol.HTTP); } /** @@ -198,9 +212,17 @@ public boolean telemetryOnly() { return telemetryOnly; } + /** + * Returns the protocol used to route traffic TO this node. + * Defaults to HTTP. TCP nodes only receive raw TCP tunnel traffic. + */ + public RoutingProtocol routingProtocol() { + return routingProtocol; + } + public ServerNode withDynamic(boolean isDynamic) { ServerNode node = new ServerNode(this.name, this.host, this.port, this.status, this.isExternal, - this.pingProtocol, this.pingPath, this.pingHeaderName, this.pingHeaderValue, isDynamic, this.telemetryOnly); + this.pingProtocol, this.pingPath, this.pingHeaderName, this.pingHeaderValue, isDynamic, this.telemetryOnly, this.routingProtocol); node.setLatencyMs(this.latencyMs); node.setCpuUsage(this.cpuUsage); node.setRamUsage(this.ramUsage); @@ -213,7 +235,7 @@ public ServerNode withDynamic(boolean isDynamic) { */ public ServerNode withStatus(NodeStatus newStatus) { ServerNode node = new ServerNode(this.name, this.host, this.port, newStatus, this.isExternal, - this.pingProtocol, this.pingPath, this.pingHeaderName, this.pingHeaderValue, this.isDynamic, this.telemetryOnly); + this.pingProtocol, this.pingPath, this.pingHeaderName, this.pingHeaderValue, this.isDynamic, this.telemetryOnly, this.routingProtocol); node.setLatencyMs(this.latencyMs); node.setCpuUsage(this.cpuUsage); node.setRamUsage(this.ramUsage); @@ -226,7 +248,21 @@ public ServerNode withStatus(NodeStatus newStatus) { */ public ServerNode withPingProtocol(PingProtocol newProtocol) { ServerNode node = new ServerNode(this.name, this.host, this.port, this.status, this.isExternal, - newProtocol, this.pingPath, this.pingHeaderName, this.pingHeaderValue, this.isDynamic, this.telemetryOnly); + newProtocol, this.pingPath, this.pingHeaderName, this.pingHeaderValue, this.isDynamic, this.telemetryOnly, this.routingProtocol); + node.setLatencyMs(this.latencyMs); + node.setCpuUsage(this.cpuUsage); + node.setRamUsage(this.ramUsage); + node.setRuntime(this.runtime); + return node; + } + + /** + * Create a new immutable ServerNode instance with an updated routing protocol. + */ + public ServerNode withRoutingProtocol(RoutingProtocol newProtocol) { + ServerNode node = new ServerNode(this.name, this.host, this.port, this.status, this.isExternal, + this.pingProtocol, this.pingPath, this.pingHeaderName, this.pingHeaderValue, + this.isDynamic, this.telemetryOnly, newProtocol != null ? newProtocol : RoutingProtocol.HTTP); node.setLatencyMs(this.latencyMs); node.setCpuUsage(this.cpuUsage); node.setRamUsage(this.ramUsage); @@ -249,6 +285,10 @@ public String getHostWithoutProtocol() { return host.replaceAll("^[a-zA-Z]+://", ""); } + public String getId() { + return id; + } + @Override public String toString() { return "ServerNode{" + @@ -257,6 +297,7 @@ public String toString() { ", status=" + status + ", isExternal=" + isExternal + ", pingProtocol=" + pingProtocol + + ", routingProtocol=" + routingProtocol + ", pingPath='" + pingPath + '\'' + '}'; } diff --git a/java/src/hexacloud/core/ports/GatewayBuilderPort.java b/java/src/hexacloud/core/ports/GatewayBuilderPort.java index 8acf005..dd2a024 100644 --- a/java/src/hexacloud/core/ports/GatewayBuilderPort.java +++ b/java/src/hexacloud/core/ports/GatewayBuilderPort.java @@ -164,8 +164,40 @@ public interface GatewayBuilderPort { */ GatewayBuilderPort enableTcpProxy(boolean enabled); + /** + * Configure L4 TCP proxy socket timeout. + */ + GatewayBuilderPort tcpSoTimeout(int timeoutMs); + + /** + * Configure L4 TCP proxy socket keep-alive. + */ + GatewayBuilderPort tcpKeepAlive(boolean enabled); + + /** * Configure an SSL/TLS context provider for HTTPS/TLS termination. */ GatewayBuilderPort sslContext(hexacloud.core.ports.SslContextPort sslContextPort); + + /** + * Delegate authentication to an external HTTP service (auth_request pattern). + * The auth service must return 2xx to allow, 401/403 to deny. + * + * @param authServiceUrl Full URL of the auth endpoint (e.g., "http://auth:9000/verify"). + */ + GatewayBuilderPort authService(String authServiceUrl); + + /** + * Delegate authentication to an external HTTP service with a custom timeout. + * + * @param authServiceUrl Full URL of the auth endpoint. + * @param timeoutMs Request timeout in milliseconds. + */ + GatewayBuilderPort authService(String authServiceUrl, int timeoutMs); + + /** + * Configure packages to scan for controllers and event listeners. + */ + GatewayBuilderPort scanPackages(String... packages); } diff --git a/java/src/hexacloud/core/ports/NodeBuilderPort.java b/java/src/hexacloud/core/ports/NodeBuilderPort.java index d9419a9..57d878a 100644 --- a/java/src/hexacloud/core/ports/NodeBuilderPort.java +++ b/java/src/hexacloud/core/ports/NodeBuilderPort.java @@ -32,6 +32,12 @@ public interface NodeBuilderPort { */ NodeBuilderPort telemetryOnly(boolean value); + /** + * Set the transport protocol used to route traffic to this node. + * Defaults to HTTP. Set to TCP for raw TCP-only worker nodes. + */ + NodeBuilderPort routingProtocol(hexacloud.core.model.RoutingProtocol protocol); + /** * Register the node in the cluster and return the parent GatewayBuilderPort for fluent chaining. */ diff --git a/java/src/hexacloud/core/ports/TerminalUiPort.java b/java/src/hexacloud/core/ports/TerminalUiPort.java index 4449ebb..6008fed 100644 --- a/java/src/hexacloud/core/ports/TerminalUiPort.java +++ b/java/src/hexacloud/core/ports/TerminalUiPort.java @@ -40,6 +40,12 @@ public interface TerminalUiPort { */ TerminalUiPort tokenManagementEnabled(boolean enabled); + /** + * Set whether the TUI redirects System.out and System.err to the internal logs panel. + * Defaults to false (optional/disabled by default when embedded). + */ + TerminalUiPort redirectSystemOut(boolean redirect); + /** * Seed the TUI with an already started RunningGatewayPort instance. */ diff --git a/java/src/hexacloud/core/server/ServerManager.java b/java/src/hexacloud/core/server/ServerManager.java index 17a56d2..6c1880d 100644 --- a/java/src/hexacloud/core/server/ServerManager.java +++ b/java/src/hexacloud/core/server/ServerManager.java @@ -20,7 +20,7 @@ public class ServerManager implements ServerOperations { - private final Cluster cluster; + private final List clusters; protected final ClusterEventBusManager eventManager; private final RouteRegistry routeRegistry; private final List activeTransports = new ArrayList<>(); @@ -35,81 +35,118 @@ public class ServerManager implements ServerOperations { private hexacloud.core.server.HttpEngine httpEngine = hexacloud.core.server.HttpEngine.JDK_DEFAULT; private hexacloud.core.server.PerformanceProfile performanceProfile = hexacloud.core.server.PerformanceProfile.STANDARD; private hexacloud.core.ports.SslContextPort sslContextPort; + private int tcpSoTimeout = 30000; + private boolean tcpKeepAlive = true; - public ServerManager(Cluster cluster, ClusterEventBusManager eventManager) { - this.cluster = cluster; + /** + * Primary constructor accepting all clusters. Used by LocalGatewayAdapter. + */ + public ServerManager(List clusters, ClusterEventBusManager eventManager) { + this.clusters = clusters != null ? clusters : new ArrayList<>(); this.eventManager = eventManager; this.routeRegistry = new RouteRegistry(); - if (cluster != null) { + for (Cluster cluster : this.clusters) { this.routeRegistry.registerController(new ClusterController(cluster)); } - autoRegisterControllers(); } - private void autoRegisterControllers() { - try { - List> controllers = hexacloud.core.utils.common.PathUtils.scanClasspathForImplementations(hexacloud.core.server.route.RouteController.class); - for (Class clazz : controllers) { - if (clazz.getName().equals(ClusterController.class.getName())) { - continue; - } - - try { - hexacloud.core.server.route.RouteController controller = null; + /** + * Convenience constructor for single-cluster usage (backward compatible). + */ + public ServerManager(Cluster cluster, ClusterEventBusManager eventManager) { + this(cluster != null ? List.of(cluster) : List.of(), eventManager); + } + + public ServerManager(int port, Cluster cluster, ClusterEventBusManager eventManager) { + this(cluster != null ? List.of(cluster) : List.of(), eventManager); + this.port = port; + } + + public void autoRegisterControllers(List scanPackages) { + List packages = new ArrayList<>(); + if (scanPackages != null) { + packages.addAll(scanPackages); + } + if (packages.isEmpty()) { + String basePkg = hexacloud.core.utils.common.PathUtils.getAppBasePackage(); + if (!basePkg.isEmpty()) { + packages.add(basePkg); + } + packages.add("hexacloud"); + } + + for (String pkg : packages) { + try { + List> controllers = + hexacloud.core.utils.reflection.ClassScanner.scanPackage(pkg, hexacloud.core.server.route.RouteController.class); + for (Class clazz : controllers) { + if (clazz.getName().equals(ClusterController.class.getName())) { + continue; + } + try { - if (cluster != null) { - java.lang.reflect.Constructor ctor = clazz.getDeclaredConstructor(Cluster.class); + hexacloud.core.server.route.RouteController controller = null; + Cluster firstCluster = clusters.isEmpty() ? null : clusters.get(0); + try { + if (firstCluster != null) { + java.lang.reflect.Constructor ctor = clazz.getDeclaredConstructor(Cluster.class); + ctor.setAccessible(true); + controller = ctor.newInstance(firstCluster); + } + } catch (NoSuchMethodException e) { + java.lang.reflect.Constructor ctor = clazz.getDeclaredConstructor(); ctor.setAccessible(true); - controller = (hexacloud.core.server.route.RouteController) ctor.newInstance(cluster); + controller = ctor.newInstance(); } - } catch (NoSuchMethodException e) { - java.lang.reflect.Constructor ctor = clazz.getDeclaredConstructor(); - ctor.setAccessible(true); - controller = (hexacloud.core.server.route.RouteController) ctor.newInstance(); - } - if (controller != null) { - this.routeRegistry.registerController(controller); - if (this.cluster != null) { - this.cluster.getRouteRegistry().registerController(controller); + if (controller != null) { + this.routeRegistry.registerController(controller); + for (Cluster c : this.clusters) { + c.getRouteRegistry().registerController(controller); + } + DebugUtils.info("RouteScanner: Auto-discovered and registered controller: " + clazz.getName()); } - DebugUtils.log("RouteScanner: Auto-discovered and registered controller: " + clazz.getName()); + } catch (Exception e) { + DebugUtils.error("RouteScanner: Failed to auto-instantiate controller " + clazz.getName(), e); } - } catch (Exception e) { - DebugUtils.error("RouteScanner: Failed to auto-instantiate controller " + clazz.getName(), e); } + } catch (Exception e) { + DebugUtils.error("RouteScanner: Failed to scan package " + pkg + " for RouteControllers", e); } - } catch (Exception e) { - DebugUtils.error("RouteScanner: Failed to scan classpath for RouteControllers", e); } } - public ServerManager(int port, Cluster cluster, ClusterEventBusManager eventManager) { - this(cluster, eventManager); - this.port = port; - } - public ServerManager enableTelnet(boolean enabled) { this.telnetEnabled = enabled; - DebugUtils.log("ServerManager: Telnet transport " + (enabled ? "AUTHORIZED" : "DISABLED")); + DebugUtils.info("ServerManager: Telnet transport " + (enabled ? "AUTHORIZED" : "DISABLED")); return this; } public ServerManager enableHttp(boolean enabled) { this.httpEnabled = enabled; - DebugUtils.log("ServerManager: HTTP transport " + (enabled ? "AUTHORIZED" : "DISABLED")); + DebugUtils.info("ServerManager: HTTP transport " + (enabled ? "AUTHORIZED" : "DISABLED")); return this; } public ServerManager enableWs(boolean enabled) { this.wsEnabled = enabled; - DebugUtils.log("ServerManager: WebSocket transport " + (enabled ? "AUTHORIZED" : "DISABLED")); + DebugUtils.info("ServerManager: WebSocket transport " + (enabled ? "AUTHORIZED" : "DISABLED")); return this; } public ServerManager enableTcpProxy(boolean enabled) { this.tcpProxyEnabled = enabled; - DebugUtils.log("ServerManager: TCP Proxy transport " + (enabled ? "AUTHORIZED" : "DISABLED")); + DebugUtils.info("ServerManager: TCP Proxy transport " + (enabled ? "AUTHORIZED" : "DISABLED")); + return this; + } + + public ServerManager tcpSoTimeout(int timeoutMs) { + this.tcpSoTimeout = timeoutMs; + return this; + } + + public ServerManager tcpKeepAlive(boolean enabled) { + this.tcpKeepAlive = enabled; return this; } @@ -168,14 +205,14 @@ public List getCustomFilters() { @Override public ServerManager listen(int port) { - DebugUtils.log("ServerManager: Starting authorized protocol listeners on base port " + port + "..."); + DebugUtils.info("ServerManager: Starting authorized protocol listeners on base port " + port + "..."); // Stop any running transports before starting new ones stopTransports(); if(telnetEnabled) { ServerTransport telnet = new TelnetTransport(); - telnet.listen(port, routeRegistry, cluster, customFilters); + telnet.listen(port, routeRegistry, clusters, customFilters); activeTransports.add(telnet); } @@ -192,21 +229,23 @@ public ServerManager listen(int port) { } http.setPerformanceProfile(this.performanceProfile); // HTTP runs on port + 1 - http.listen(port + 1, routeRegistry, cluster, customFilters); + http.listen(port + 1, routeRegistry, clusters, customFilters); activeTransports.add(http); } if(wsEnabled) { ServerTransport ws = new WsTransport(); // WS runs on port + 2 - ws.listen(port + 2, routeRegistry, cluster, customFilters); + ws.listen(port + 2, routeRegistry, clusters, customFilters); activeTransports.add(ws); } if(tcpProxyEnabled) { - ServerTransport tcpProxy = new TcpProxyTransport(); + TcpProxyTransport tcpProxy = new TcpProxyTransport(); + tcpProxy.setSoTimeout(this.tcpSoTimeout); + tcpProxy.setKeepAlive(this.tcpKeepAlive); // TCP Proxy runs on port + 3 - tcpProxy.listen(port + 3, routeRegistry, cluster, customFilters); + tcpProxy.listen(port + 3, routeRegistry, clusters, customFilters); activeTransports.add(tcpProxy); } @@ -242,14 +281,13 @@ private void stopTransports() { */ public ServerManager registerRouteController(hexacloud.core.server.route.RouteController controller) { this.routeRegistry.registerController(controller); - if (this.cluster != null) { - this.cluster.getRouteRegistry().registerController(controller); + for (Cluster c : this.clusters) { + c.getRouteRegistry().registerController(controller); } return this; } public void addRouteRule(RouteRule rule) { - DebugUtils.info("new route rule: " + rule); if (rule == null) { return; } diff --git a/java/src/hexacloud/core/server/ServerTransport.java b/java/src/hexacloud/core/server/ServerTransport.java index 27620da..a8fcfc0 100644 --- a/java/src/hexacloud/core/server/ServerTransport.java +++ b/java/src/hexacloud/core/server/ServerTransport.java @@ -1,13 +1,12 @@ package hexacloud.core.server; import hexacloud.core.cluster.Cluster; -import hexacloud.core.server.route.RouteRegistry; - import hexacloud.core.server.filter.HttpFilter; +import hexacloud.core.server.route.RouteRegistry; import java.util.List; public interface ServerTransport { - void listen(int port, RouteRegistry registry, Cluster cluster, List customFilters); + void listen(int port, RouteRegistry registry, List clusters, List customFilters); void stop(); boolean isRunning(); default void setPerformanceProfile(PerformanceProfile profile) {} diff --git a/java/src/hexacloud/core/server/filter/HttpRequest.java b/java/src/hexacloud/core/server/filter/HttpRequest.java index f54474d..9c89b46 100644 --- a/java/src/hexacloud/core/server/filter/HttpRequest.java +++ b/java/src/hexacloud/core/server/filter/HttpRequest.java @@ -14,4 +14,5 @@ public interface HttpRequest { String getClientIp(); void setAttribute(String key, Object value); Object getAttribute(String key); + java.io.InputStream getBody() throws Exception; } diff --git a/java/src/hexacloud/core/server/filter/HttpResponse.java b/java/src/hexacloud/core/server/filter/HttpResponse.java index 1cc5c6c..a4ceb9b 100644 --- a/java/src/hexacloud/core/server/filter/HttpResponse.java +++ b/java/src/hexacloud/core/server/filter/HttpResponse.java @@ -7,5 +7,6 @@ public interface HttpResponse { void setStatus(int statusCode); void setContentType(String contentType); PrintWriter getWriter() throws Exception; + java.io.OutputStream getOutputStream() throws Exception; boolean isCommitted(); } diff --git a/java/src/hexacloud/core/server/filter/builtin/CorsFilter.java b/java/src/hexacloud/core/server/filter/builtin/CorsFilter.java new file mode 100644 index 0000000..f19ac99 --- /dev/null +++ b/java/src/hexacloud/core/server/filter/builtin/CorsFilter.java @@ -0,0 +1,23 @@ +package hexacloud.core.server.filter.builtin; + +import hexacloud.core.server.filter.HttpFilter; +import hexacloud.core.server.filter.HttpFilterChain; +import hexacloud.core.server.filter.HttpRequest; +import hexacloud.core.server.filter.HttpResponse; + +public class CorsFilter implements HttpFilter { + + @Override + public void doFilter(HttpRequest request, HttpResponse response, HttpFilterChain chain) throws Exception { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE"); + response.setHeader("Access-Control-Allow-Headers", "X-Cluster-Token, Content-Type, Authorization"); + + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + response.setStatus(204); + return; // Short-circuit preflight + } + + chain.doFilter(request, response); + } +} diff --git a/java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java b/java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java new file mode 100644 index 0000000..de0f819 --- /dev/null +++ b/java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java @@ -0,0 +1,119 @@ +package hexacloud.core.server.filter.builtin; + +import hexacloud.core.server.filter.*; +import hexacloud.core.utils.common.DebugUtils; + +import java.io.PrintWriter; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * ExternalAuthFilter — delegates authentication to an external HTTP auth service. + * + *

Inspired by Nginx's {@code auth_request} directive. For every incoming request, + * this filter calls the configured {@code authServiceUrl} forwarding relevant headers + * (Authorization, X-Cluster-Token, X-Real-IP, X-Forwarded-For, Cookie). + * The auth service must return:

+ *
    + *
  • 2xx — authentication granted, request continues the filter chain.
  • + *
  • 401/403 — authentication denied, gateway returns 401 to the client.
  • + *
  • Any other error / timeout — gateway returns 502 to the client.
  • + *
+ * + *

Usage (fluent builder):

+ *
+ *   gateway.authService("http://auth-service:9000/verify")
+ * 
+ */ +@Order(20) +public class ExternalAuthFilter implements HttpFilter { + + private static final int DEFAULT_TIMEOUT_MS = 3000; + + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(DEFAULT_TIMEOUT_MS)) + .build(); + + private final String authServiceUrl; + private final int timeoutMs; + + /** + * @param authServiceUrl Full URL of the auth endpoint (e.g., "http://auth:9000/verify"). + * @param timeoutMs Request timeout in milliseconds. + */ + public ExternalAuthFilter(String authServiceUrl, int timeoutMs) { + this.authServiceUrl = authServiceUrl; + this.timeoutMs = timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; + } + + public ExternalAuthFilter(String authServiceUrl) { + this(authServiceUrl, DEFAULT_TIMEOUT_MS); + } + + @Override + public void doFilter(hexacloud.core.server.filter.HttpRequest request, hexacloud.core.server.filter.HttpResponse response, HttpFilterChain chain) throws Exception { + HttpRequest.Builder authRequest = HttpRequest.newBuilder() + .uri(URI.create(authServiceUrl)) + .timeout(Duration.ofMillis(timeoutMs)) + .GET(); + + // Forward standard auth-related headers to the auth service + forwardHeader(request, authRequest, "Authorization"); + forwardHeader(request, authRequest, "X-Cluster-Token"); + forwardHeader(request, authRequest, "X-Real-IP"); + forwardHeader(request, authRequest, "X-Forwarded-For"); + forwardHeader(request, authRequest, "Cookie"); + // Forward original request path so the auth service can apply path-based rules + authRequest.header("X-Original-URI", request.getPath()); + + int statusCode; + try { + HttpResponse authResponse = HTTP_CLIENT.send( + authRequest.build(), + HttpResponse.BodyHandlers.discarding() + ); + statusCode = authResponse.statusCode(); + } catch (Exception ex) { + DebugUtils.error("ExternalAuthFilter: auth service call failed for " + authServiceUrl, ex); + response.setStatus(502); + try (PrintWriter writer = response.getWriter()) { + writer.print("502 Bad Gateway - Auth service unavailable"); + } + return; + } + + if (statusCode >= 200 && statusCode < 300) { + // Auth granted + chain.doFilter(request, response); + } else if (statusCode == 401 || statusCode == 403) { + response.setStatus(401); + try (PrintWriter writer = response.getWriter()) { + writer.print("401 Unauthorized - Auth service denied access"); + } + } else { + DebugUtils.error("ExternalAuthFilter: unexpected auth service response: " + statusCode + " from " + authServiceUrl, null); + response.setStatus(502); + try (PrintWriter writer = response.getWriter()) { + writer.print("502 Bad Gateway - Unexpected auth service response"); + } + } + } + + private void forwardHeader(hexacloud.core.server.filter.HttpRequest request, HttpRequest.Builder builder, String headerName) { + String value = request.getHeader(headerName); + if (value != null && !value.isEmpty()) { + builder.header(headerName, value); + } + } + + public String getAuthServiceUrl() { + return authServiceUrl; + } + + public int getTimeoutMs() { + return timeoutMs; + } +} diff --git a/java/src/hexacloud/core/server/route/ClusterController.java b/java/src/hexacloud/core/server/route/ClusterController.java index d4ebd92..7bc2977 100644 --- a/java/src/hexacloud/core/server/route/ClusterController.java +++ b/java/src/hexacloud/core/server/route/ClusterController.java @@ -19,7 +19,7 @@ public ClusterController(Cluster cluster) { this.clusterService = new ClusterService(cluster); } - @RouteMapping("GET_NODES") + @RouteMapping("/v1/get_nodes") public void getNodes(String args, PrintWriter out) { StringBuilder sb = new StringBuilder(); for(ServerNode node : this.cluster.getCluster()) { @@ -28,7 +28,7 @@ public void getNodes(String args, PrintWriter out) { out.println(sb.toString()); } - @RouteMapping("REGISTER") + @RouteMapping("/v1/register") public void register(String args, PrintWriter out) { try { int regPort = Integer.parseInt(args); @@ -39,7 +39,7 @@ public void register(String args, PrintWriter out) { } } - @RouteMapping("TELEMETRY") + @RouteMapping("/v1/telemetry") public void telemetry(String args, PrintWriter out) { if (args == null || args.trim().isEmpty()) { out.println("ERROR: Missing arguments. Expected format: [key=value]... or host=...&port=..."); @@ -60,7 +60,7 @@ public void telemetry(String args, PrintWriter out) { } } - @RouteMapping("DEREGISTER") + @RouteMapping("/v1/deregister") public void deregister(String args, PrintWriter out) { if (args == null || args.trim().isEmpty()) { out.println("ERROR: Missing host address."); @@ -70,7 +70,7 @@ public void deregister(String args, PrintWriter out) { out.println("SUCCESS: Node " + args.trim() + " deregistered."); } - @RouteMapping("LIST_CLUSTERS") + @RouteMapping("/v1/list_clusters") public void listClusters(String args, PrintWriter out) { StringBuilder sb = new StringBuilder(); for(Cluster c : ClusterRegistry.getInstance().getClusters()) { @@ -79,7 +79,7 @@ public void listClusters(String args, PrintWriter out) { out.println(sb.toString()); } - @RouteMapping("CREATE_CLUSTER") + @RouteMapping("/v1/create_cluster") public void createCluster(String args, PrintWriter out) { if(args == null || args.trim().isEmpty()) { out.println("ERROR: Missing cluster name."); @@ -90,7 +90,7 @@ public void createCluster(String args, PrintWriter out) { out.println("SUCCESS: Cluster '" + clusterName + "' created."); } - @RouteMapping("GET_CLUSTER_CONFIG") + @RouteMapping("/v1/get_cluster_config") public void getClusterConfig(String args, PrintWriter out) { StringBuilder sb = new StringBuilder(); sb.append("requireToken=").append(cluster.isRequireToken()).append(";"); @@ -101,7 +101,7 @@ public void getClusterConfig(String args, PrintWriter out) { out.println(sb.toString()); } - @RouteMapping("GET_GLOBAL_CONFIG") + @RouteMapping("/v1/get_global_config") public void getGlobalConfig(String args, PrintWriter out) { StringBuilder sb = new StringBuilder(); sb.append("maxClusterSize=").append(hexacloud.core.config.ClusterConfig.MAX_CLUSTER_SIZE).append(";"); @@ -111,13 +111,13 @@ public void getGlobalConfig(String args, PrintWriter out) { out.println(sb.toString()); } - @RouteMapping("SET_ALLOWED_IPS") + @RouteMapping("/v1/set_allowed_ips") public void setAllowedIps(String args, PrintWriter out) { cluster.setAllowedIps(args.trim()); out.println("SUCCESS: Allowed IPs updated."); } - @RouteMapping("SET_TIMEOUT") + @RouteMapping("/v1/set_timeout") public void setTimeout(String args, PrintWriter out) { try { int timeout = Integer.parseInt(args.trim()); @@ -128,7 +128,7 @@ public void setTimeout(String args, PrintWriter out) { } } - @RouteMapping("SET_RATE_LIMIT") + @RouteMapping("/v1/set_rate_limit") public void setRateLimit(String args, PrintWriter out) { try { String[] parts = args.trim().split(" "); @@ -145,7 +145,7 @@ public void setRateLimit(String args, PrintWriter out) { } } - @RouteMapping("GET_NODES_JSON") + @RouteMapping("/v1/get_nodes_json") public void getNodesJson(String args, PrintWriter out) { String json = JsonSerializer.serialize(this.cluster.getCluster()); out.println(json); diff --git a/java/src/hexacloud/core/server/route/PathResolver.java b/java/src/hexacloud/core/server/route/PathResolver.java new file mode 100644 index 0000000..4907d22 --- /dev/null +++ b/java/src/hexacloud/core/server/route/PathResolver.java @@ -0,0 +1,105 @@ +package hexacloud.core.server.route; + +import java.util.List; + +public class PathResolver { + + public static RouteResolution resolve(String path, String host, RouteRegistry registry) { + if (path == null) { + return new RouteResolution(null, null, false, null, null); + } + + String matchingPath = path.trim(); + while (matchingPath.contains("//")) { + matchingPath = matchingPath.replace("//", "/"); + } + + // 1. Resolve local route key using slash and prefix tolerance + String localRouteKey = findLocalRouteKey(matchingPath, registry); + if (localRouteKey != null) { + return new RouteResolution(null, null, false, localRouteKey, null); + } + + // 2. Resolve proxy paths (/clusters/{name}/...) + int clustersIdx = matchingPath.indexOf("/clusters/"); + if (clustersIdx != -1) { + String prefix = matchingPath.substring(0, clustersIdx); + String pathWithoutClusters = matchingPath.substring(clustersIdx + 10); + int slashIdx = pathWithoutClusters.indexOf('/'); + String targetClusterName; + String clusterSubpath; + if (slashIdx != -1) { + targetClusterName = pathWithoutClusters.substring(0, slashIdx); + clusterSubpath = pathWithoutClusters.substring(slashIdx); + } else { + targetClusterName = pathWithoutClusters; + clusterSubpath = "/"; + } + return new RouteResolution(targetClusterName, clusterSubpath, false, null, prefix); + } + + // 3. Match Ingress rules + List rules = registry.getRouteRulesList(); + if (rules != null && !rules.isEmpty()) { + for (RouteRule rule : rules) { + if (rule.matches(host, matchingPath)) { + return new RouteResolution(rule.getClusterName(), rule.rewritePath(matchingPath), true, null, null); + } + } + } + + return new RouteResolution(null, null, false, null, null); + } + + private static String findLocalRouteKey(String matchingPath, RouteRegistry registry) { + String routeKey = matchingPath.toUpperCase(); + + // Try exact match + if (registry.getRoutes().containsKey(routeKey)) { + return routeKey; + } + + // Try stripping leading slash + if (routeKey.startsWith("/") && routeKey.length() > 1) { + String stripped = routeKey.substring(1); + if (registry.getRoutes().containsKey(stripped)) { + return stripped; + } + } + + // Try adding leading slash + if (!routeKey.startsWith("/")) { + String withSlash = "/" + routeKey; + if (registry.getRoutes().containsKey(withSlash)) { + return withSlash; + } + } + + // Try prefix/unprefix matching with "/V1" + if (routeKey.startsWith("/V1/") || routeKey.equals("/V1")) { + String unv1 = routeKey.equals("/V1") ? "/" : routeKey.substring(3); + if (registry.getRoutes().containsKey(unv1)) { + return unv1; + } + if (unv1.startsWith("/") && unv1.length() > 1) { + String stripped = unv1.substring(1); + if (registry.getRoutes().containsKey(stripped)) { + return stripped; + } + } + } else { + String withV1 = "/V1" + (routeKey.startsWith("/") ? routeKey : "/" + routeKey); + if (registry.getRoutes().containsKey(withV1)) { + return withV1; + } + if (withV1.startsWith("/") && withV1.length() > 1) { + String stripped = withV1.substring(1); + if (registry.getRoutes().containsKey(stripped)) { + return stripped; + } + } + } + + return null; + } +} diff --git a/java/src/hexacloud/core/server/route/RouteMapping.java b/java/src/hexacloud/core/server/route/RouteMapping.java index 399a81d..13540cc 100644 --- a/java/src/hexacloud/core/server/route/RouteMapping.java +++ b/java/src/hexacloud/core/server/route/RouteMapping.java @@ -10,4 +10,5 @@ public @interface RouteMapping { String value(); boolean isPublic() default false; + boolean fastPath() default false; } diff --git a/java/src/hexacloud/core/server/route/RouteRegistry.java b/java/src/hexacloud/core/server/route/RouteRegistry.java index a2425ec..6674fe7 100644 --- a/java/src/hexacloud/core/server/route/RouteRegistry.java +++ b/java/src/hexacloud/core/server/route/RouteRegistry.java @@ -10,8 +10,19 @@ public class RouteRegistry { + private final String name; private final Map> routes = new HashMap<>(); + + public RouteRegistry() { + this("Global"); + } + + public RouteRegistry(String name) { + this.name = name; + } + private final java.util.Set publicRoutes = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private final java.util.Set fastPathRoutes = java.util.concurrent.ConcurrentHashMap.newKeySet(); private final java.util.List routeRules = new java.util.concurrent.CopyOnWriteArrayList<>(); public void addRouteRule(RouteRule rule) { @@ -33,6 +44,11 @@ public boolean isRoutePublic(String routeName) { return publicRoutes.contains(routeName.toUpperCase()); } + public boolean isRouteFastPath(String routeName) { + if (routeName == null) return false; + return fastPathRoutes.contains(routeName.toUpperCase()); + } + public void registerController(RouteController controller) { if(controller == null) return; @@ -44,6 +60,9 @@ public void registerController(RouteController controller) { if (mapping.isPublic()) { publicRoutes.add(command); } + if (mapping.fastPath()) { + fastPathRoutes.add(command); + } Class[] paramTypes = method.getParameterTypes(); if(paramTypes.length == 2 && paramTypes[0] == String.class && paramTypes[1] == PrintWriter.class) { @@ -72,7 +91,7 @@ public void registerController(RouteController controller) { }; } routes.put(command, handler); - DebugUtils.log("RouteScanner: Registered command '" + command + "' mapping to method " + clazz.getSimpleName() + "." + method.getName()); + DebugUtils.info("RouteScanner: [" + name + "] Registered command '" + command + "' mapping to method " + clazz.getSimpleName() + "." + method.getName()); } else { DebugUtils.error("RouteScanner: Failed to register method " + clazz.getSimpleName() + "." + method.getName() + " -> Must accept parameters (String, PrintWriter)"); } diff --git a/java/src/hexacloud/core/server/route/RouteResolution.java b/java/src/hexacloud/core/server/route/RouteResolution.java new file mode 100644 index 0000000..032da59 --- /dev/null +++ b/java/src/hexacloud/core/server/route/RouteResolution.java @@ -0,0 +1,30 @@ +package hexacloud.core.server.route; + +public class RouteResolution { + private final String targetClusterName; + private final String targetSubpath; + private final boolean matchedRouteRule; + private final String localRouteName; + private final String versionPrefix; + + public RouteResolution(String targetClusterName, String targetSubpath, boolean matchedRouteRule, String localRouteName, String versionPrefix) { + this.targetClusterName = targetClusterName; + this.targetSubpath = targetSubpath; + this.matchedRouteRule = matchedRouteRule; + this.localRouteName = localRouteName; + this.versionPrefix = versionPrefix != null ? versionPrefix : ""; + } + + public boolean isProxy() { return targetClusterName != null; } + public boolean isLocal() { return localRouteName != null; } + public String targetClusterName() { return targetClusterName; } + public String targetSubpath() { return targetSubpath; } + public boolean matchedRouteRule() { return matchedRouteRule; } + public String localRouteName() { return localRouteName; } + public String versionPrefix() { return versionPrefix; } + + public String resolveTargetRouteKey() { + if (targetSubpath == null) return null; + return (versionPrefix + targetSubpath).toUpperCase(); + } +} diff --git a/java/src/hexacloud/core/server/route/RouteRule.java b/java/src/hexacloud/core/server/route/RouteRule.java index eb4d24f..0ae46e9 100644 --- a/java/src/hexacloud/core/server/route/RouteRule.java +++ b/java/src/hexacloud/core/server/route/RouteRule.java @@ -17,7 +17,7 @@ public RouteRule(String host, String pathPattern, String clusterName, String tar this.pathPattern = pathPattern; this.clusterName = clusterName; this.targetPath = normalizeTargetPath(targetPath); - DebugUtils.info("New rule: " + this); + DebugUtils.info("[RouteRule] New RouteRule: " + this); } public String getHost() { diff --git a/java/src/hexacloud/core/tui/TerminalUI.java b/java/src/hexacloud/core/tui/TerminalUI.java index f9a703d..1528b07 100644 --- a/java/src/hexacloud/core/tui/TerminalUI.java +++ b/java/src/hexacloud/core/tui/TerminalUI.java @@ -33,6 +33,7 @@ public class TerminalUI implements hexacloud.core.ports.TerminalUiPort { private boolean nodeManagementEnabled = true; private boolean nodeConfigurationEnabled = true; private boolean tokenManagementEnabled = true; + private boolean redirectSystemOut = false; private boolean isToggleMode = false; private static final Map activeGateways = new ConcurrentHashMap<>(); @@ -123,6 +124,10 @@ public boolean nodeConfigurationEnabled() { return nodeConfigurationEnabled; } + public boolean redirectSystemOut() { + return redirectSystemOut; + } + @Override public boolean tokenManagementEnabled() { return tokenManagementEnabled; @@ -170,6 +175,12 @@ public hexacloud.core.ports.TerminalUiPort tokenManagementEnabled(boolean enable return this; } + @Override + public hexacloud.core.ports.TerminalUiPort redirectSystemOut(boolean redirect) { + this.redirectSystemOut = redirect; + return this; + } + @Override public hexacloud.core.ports.TerminalUiPort seedGateway(RunningGatewayPort gateway) { if (gateway != null) { @@ -228,7 +239,9 @@ public boolean isGatewayActive(String clusterName) { */ public void run() { state.running = true; - DebugUtils.setTuiModeActive(true); + if (redirectSystemOut) { + DebugUtils.setTuiModeActive(true); + } NativeTerminal.initTerminal(); registerShutdownHook(); @@ -379,7 +392,9 @@ private void cleanup(hexacloud.core.event.EventListener> scanClasspathForImplementations(Class return implementations; } - private static String getAppBasePackage() { - String command = System.getProperty("sun.java.command"); - if (command == null || command.trim().isEmpty()) { - return ""; + /** + * Resolves the main application's base package name. + * Uses stack trace examination to find the entrypoint class, falling back to system properties. + */ + public static String getAppBasePackage() { + for (StackTraceElement element : Thread.currentThread().getStackTrace()) { + if ("main".equals(element.getMethodName())) { + String mainClass = element.getClassName(); + int lastDot = mainClass.lastIndexOf('.'); + if (lastDot != -1) { + return mainClass.substring(0, lastDot); + } + } } - String mainClass = command.split(" ")[0]; - int lastDot = mainClass.lastIndexOf('.'); - if (lastDot != -1) { - return mainClass.substring(0, lastDot); + String command = System.getProperty("sun.java.command"); + if (command != null && !command.trim().isEmpty()) { + String mainClass = command.split(" ")[0]; + if (!mainClass.endsWith(".jar")) { + int lastDot = mainClass.lastIndexOf('.'); + if (lastDot != -1) { + return mainClass.substring(0, lastDot); + } + } } return ""; } diff --git a/java/src/hexacloud/core/utils/network/HttpHeaderUtils.java b/java/src/hexacloud/core/utils/network/HttpHeaderUtils.java new file mode 100644 index 0000000..85a19f6 --- /dev/null +++ b/java/src/hexacloud/core/utils/network/HttpHeaderUtils.java @@ -0,0 +1,27 @@ +package hexacloud.core.utils.network; + +import java.net.http.HttpRequest.Builder; +import hexacloud.core.server.filter.HttpRequest; + +public class HttpHeaderUtils { + public static void injectTraceabilityHeaders(Builder reqBuilder, HttpRequest r, boolean isSsl) { + String clientIp = r.getClientIp(); + String existingXff = r.getHeader("X-Forwarded-For"); + String xff = (existingXff == null || existingXff.trim().isEmpty()) ? clientIp : (existingXff + ", " + clientIp); + reqBuilder.header("X-Forwarded-For", xff); + + String proto = isSsl ? "https" : "http"; + if (!isSsl) { + String existingProto = r.getHeader("X-Forwarded-Proto"); + if (existingProto != null && !existingProto.trim().isEmpty()) { + proto = existingProto; + } + } + reqBuilder.header("X-Forwarded-Proto", proto); + + String originalHost = r.getHeader("Host"); + if (originalHost != null && !originalHost.trim().isEmpty()) { + reqBuilder.header("X-Forwarded-Host", originalHost); + } + } +} diff --git a/java/src/hexacloud/core/utils/network/HttpProxyClient.java b/java/src/hexacloud/core/utils/network/HttpProxyClient.java new file mode 100644 index 0000000..16903c8 --- /dev/null +++ b/java/src/hexacloud/core/utils/network/HttpProxyClient.java @@ -0,0 +1,9 @@ +package hexacloud.core.utils.network; + +import java.io.InputStream; +import java.util.Map; +import java.util.List; + +public interface HttpProxyClient { + ProxyResponse execute(String targetUrl, String method, Map> headers, InputStream body, int timeoutMs) throws Exception; +} diff --git a/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java b/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java new file mode 100644 index 0000000..fb8e707 --- /dev/null +++ b/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java @@ -0,0 +1,98 @@ +package hexacloud.core.utils.network; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.List; + +public class JdkHttpProxyClient implements HttpProxyClient { + private final HttpClient client; + + public JdkHttpProxyClient() { + this.client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER) + .executor(java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) + .build(); + } + + @Override + public ProxyResponse execute(String targetUrl, String method, Map> headers, InputStream body, int timeoutMs) throws Exception { + boolean hasBody = false; + if (headers != null) { + List contentLengths = headers.get("Content-Length"); + if (contentLengths == null || contentLengths.isEmpty()) { + // Try case-insensitive lookup + for (Map.Entry> entry : headers.entrySet()) { + if ("Content-Length".equalsIgnoreCase(entry.getKey())) { + contentLengths = entry.getValue(); + break; + } + } + } + if (contentLengths != null && !contentLengths.isEmpty()) { + try { + long len = Long.parseLong(contentLengths.get(0).trim()); + hasBody = len > 0; + } catch (Exception ignored) {} + } else { + List transferEncodings = headers.get("Transfer-Encoding"); + if (transferEncodings == null || transferEncodings.isEmpty()) { + for (Map.Entry> entry : headers.entrySet()) { + if ("Transfer-Encoding".equalsIgnoreCase(entry.getKey())) { + transferEncodings = entry.getValue(); + break; + } + } + } + if (transferEncodings != null && !transferEncodings.isEmpty()) { + hasBody = true; + } + } + } + if (!hasBody) { + hasBody = "POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method); + } + + HttpRequest.BodyPublisher publisher = (hasBody && body != null) + ? HttpRequest.BodyPublishers.ofInputStream(() -> body) + : HttpRequest.BodyPublishers.noBody(); + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .method(method, publisher) + .timeout(Duration.ofMillis(timeoutMs > 0 ? timeoutMs : 10000)); + + if (headers != null) { + for (Map.Entry> entry : headers.entrySet()) { + String key = entry.getKey(); + if (key == null || key.equalsIgnoreCase("Host") || key.equalsIgnoreCase("Content-Length") + || key.equalsIgnoreCase("Connection") || key.equalsIgnoreCase("Upgrade") + || key.equalsIgnoreCase("Transfer-Encoding") || key.equalsIgnoreCase("Keep-Alive") + || key.equalsIgnoreCase("Proxy-Connection")) { + continue; + } + if (key.equalsIgnoreCase("X-Forwarded-For")) { + key = "X-Forwarded-For"; + } else if (key.equalsIgnoreCase("X-Forwarded-Host")) { + key = "X-Forwarded-Host"; + } else if (key.equalsIgnoreCase("X-Forwarded-Proto")) { + key = "X-Forwarded-Proto"; + } else if (key.equalsIgnoreCase("Content-Type")) { + key = "Content-Type"; + } + if (!entry.getValue().isEmpty()) { + builder.header(key, String.join(", ", entry.getValue())); + } + } + } + + HttpResponse response = client.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + return new ProxyResponse(response.statusCode(), response.headers().map(), response.body()); + } +} diff --git a/java/src/hexacloud/core/utils/network/ProxyResponse.java b/java/src/hexacloud/core/utils/network/ProxyResponse.java new file mode 100644 index 0000000..e736f17 --- /dev/null +++ b/java/src/hexacloud/core/utils/network/ProxyResponse.java @@ -0,0 +1,21 @@ +package hexacloud.core.utils.network; + +import java.io.InputStream; +import java.util.Map; +import java.util.List; + +public class ProxyResponse { + private final int statusCode; + private final Map> headers; + private final InputStream bodyStream; + + public ProxyResponse(int statusCode, Map> headers, InputStream bodyStream) { + this.statusCode = statusCode; + this.headers = headers; + this.bodyStream = bodyStream; + } + + public int statusCode() { return statusCode; } + public Map> headers() { return headers; } + public InputStream bodyStream() { return bodyStream; } +} diff --git a/java/src/hexacloud/core/utils/reflection/ClassScanner.java b/java/src/hexacloud/core/utils/reflection/ClassScanner.java new file mode 100644 index 0000000..acc9801 --- /dev/null +++ b/java/src/hexacloud/core/utils/reflection/ClassScanner.java @@ -0,0 +1,140 @@ +package hexacloud.core.utils.reflection; + +import java.io.File; +import java.io.IOException; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.lang.reflect.Modifier; + +/** + * Helper class that uses ClassLoader to search packages for implementations of a given interface. + * Works in both standard filesystem paths (IDE) and JAR files (Docker). + */ +public class ClassScanner { + + private ClassScanner() {} + + /** + * Scans the given package and its subpackages for implementations of the specified interface/class. + * Excludes interfaces and abstract classes. + * + * @param packageName the root package to scan (e.g., "hexacloud.core.server.route") + * @param targetInterface the target interface or class to find implementations for + * @param the interface/class type + * @return a list of concrete classes implementing the target interface + */ + public static List> scanPackage(String packageName, Class targetInterface) { + List> classes = new ArrayList<>(); + String packagePath = packageName.replace('.', '/'); + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = ClassScanner.class.getClassLoader(); + } + + try { + Enumeration resources = classLoader.getResources(packagePath); + while (resources.hasMoreElements()) { + URL resource = resources.nextElement(); + String protocol = resource.getProtocol(); + + if ("file".equals(protocol)) { + try { + File directory = new File(resource.toURI()); + scanDirectory(directory, packageName, targetInterface, classes); + } catch (Exception e) { + // Fallback to URL decoding if toURI fails + String filePath = URLDecoder.decode(resource.getFile(), "UTF-8"); + scanDirectory(new File(filePath), packageName, targetInterface, classes); + } + } else if ("jar".equals(protocol)) { + scanJar(resource, packagePath, targetInterface, classes); + } + } + } catch (IOException e) { + // Ignore package scanning errors + } + + return classes; + } + + private static void scanDirectory(File directory, String packageName, Class targetInterface, List> classes) { + if (!directory.exists() || !directory.isDirectory()) { + return; + } + + File[] files = directory.listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + if (file.isDirectory()) { + scanDirectory(file, packageName + "." + file.getName(), targetInterface, classes); + } else if (file.getName().endsWith(".class")) { + String className = packageName + "." + file.getName().substring(0, file.getName().length() - 6); + tryLoadClass(className, targetInterface, classes); + } + } + } + + private static void scanJar(URL resource, String packagePath, Class targetInterface, List> classes) { + String packagePathWithSlash = packagePath.endsWith("/") ? packagePath : packagePath + "/"; + try { + String jarPath = resource.getPath(); + if (jarPath.startsWith("file:")) { + int bangIndex = jarPath.indexOf('!'); + if (bangIndex != -1) { + String fileUrlStr = jarPath.substring(0, bangIndex); + try { + File file = new File(new java.net.URI(fileUrlStr)); + try (JarFile jar = new JarFile(file)) { + scanJarEntries(jar, packagePathWithSlash, targetInterface, classes); + return; // Scanned successfully using local JarFile + } + } catch (Exception e) { + // Fallback + } + } + } + + // Fallback via JarURLConnection + JarURLConnection jarConnection = (JarURLConnection) resource.openConnection(); + JarFile jarFile = jarConnection.getJarFile(); + scanJarEntries(jarFile, packagePathWithSlash, targetInterface, classes); + } catch (IOException e) { + // Ignore jar reading errors + } + } + + private static void scanJarEntries(JarFile jarFile, String packagePathWithSlash, Class targetInterface, List> classes) { + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String name = entry.getName(); + if (name.startsWith(packagePathWithSlash) && name.endsWith(".class")) { + String className = name.substring(0, name.length() - 6).replace('/', '.'); + tryLoadClass(className, targetInterface, classes); + } + } + } + + @SuppressWarnings("unchecked") + private static void tryLoadClass(String className, Class targetInterface, List> classes) { + try { + Class clazz = Class.forName(className); + if (targetInterface.isAssignableFrom(clazz) + && !clazz.isInterface() + && !Modifier.isAbstract(clazz.getModifiers())) { + classes.add((Class) clazz); + } + } catch (Throwable t) { + // Ignore classes that fail to load + } + } +} diff --git a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java index 7d2d68d..81c026c 100644 --- a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java +++ b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java @@ -35,11 +35,13 @@ class LocalGatewayAdapter implements GatewayBuilderPort, RunningGatewayPort { private hexacloud.core.server.HttpEngine httpEngine = hexacloud.core.server.HttpEngine.JDK_DEFAULT; private hexacloud.core.server.PerformanceProfile performanceProfile = hexacloud.core.server.PerformanceProfile.STANDARD; private hexacloud.core.ports.SslContextPort sslContextPort; + private int tcpSoTimeout = 30000; + private boolean tcpKeepAlive = true; + private final List scanPackages = new ArrayList<>(); public LocalGatewayAdapter(String gatewayName) { - DebugUtils.log("Creating LocalGatewayAdapter for gateway: " + gatewayName); + DebugUtils.info("Creating LocalGatewayAdapter for gateway: " + gatewayName); this.clusterEventManager = new ClusterEventBusManager(); - autoRegisterEventListeners(); // Load configurations state from file on startup ClusterStatePersistence.loadState(); @@ -79,6 +81,18 @@ public LocalGatewayAdapter sslContext(hexacloud.core.ports.SslContextPort sslCon return this; } + @Override + public LocalGatewayAdapter scanPackages(String... packages) { + if (packages != null) { + for (String pkg : packages) { + if (pkg != null && !pkg.trim().isEmpty()) { + this.scanPackages.add(pkg.trim()); + } + } + } + return this; + } + @Override public LocalGatewayAdapter pingInterval(int intervalInSeconds) { schedulerPing.setInterval(intervalInSeconds); @@ -164,27 +178,41 @@ public LocalGatewayAdapter stopPingScheduler() { private void ensureServerManagerInitialized() { if(this.serverManager == null) { - this.serverManager = new ServerManager(getCluster(), this.clusterEventManager); + this.serverManager = new ServerManager(getClusters(), this.clusterEventManager); this.serverManager.setHttpEngine(this.httpEngine); this.serverManager.setPerformanceProfile(this.performanceProfile); this.serverManager.enableTcpProxy(this.tcpProxyEnabled); + this.serverManager.tcpSoTimeout(this.tcpSoTimeout); + this.serverManager.tcpKeepAlive(this.tcpKeepAlive); } } private void autoRegisterEventListeners() { - try { - java.util.List> controllers = hexacloud.core.utils.common.PathUtils.scanClasspathForImplementations(hexacloud.core.event.EventController.class); - for (Class clazz : controllers) { - try { - hexacloud.core.event.EventController listener = (hexacloud.core.event.EventController) clazz.getDeclaredConstructor().newInstance(); - this.clusterEventManager.registerListener(listener); - DebugUtils.log("EventScanner: Auto-discovered and registered listener: " + clazz.getName()); - } catch (Exception e) { - DebugUtils.error("EventScanner: Failed to auto-instantiate listener " + clazz.getName(), e); + List packages = new ArrayList<>(this.scanPackages); + if (packages.isEmpty()) { + String basePkg = hexacloud.core.utils.common.PathUtils.getAppBasePackage(); + if (!basePkg.isEmpty()) { + packages.add(basePkg); + } + packages.add("hexacloud"); + } + + for (String pkg : packages) { + try { + List> listeners = + hexacloud.core.utils.reflection.ClassScanner.scanPackage(pkg, hexacloud.core.event.EventController.class); + for (Class clazz : listeners) { + try { + hexacloud.core.event.EventController listener = clazz.getDeclaredConstructor().newInstance(); + this.clusterEventManager.registerListener(listener); + DebugUtils.info("EventScanner: Auto-discovered and registered listener: " + clazz.getName()); + } catch (Exception e) { + DebugUtils.error("EventScanner: Failed to auto-instantiate listener " + clazz.getName(), e); + } } + } catch (Exception e) { + DebugUtils.error("EventScanner: Failed to scan package " + pkg + " for EventControllers", e); } - } catch (Exception e) { - DebugUtils.error("EventScanner: Failed to scan classpath for EventControllers", e); } } @@ -213,11 +241,13 @@ public LocalGatewayAdapter enableWs(boolean enabled) { public LocalGatewayAdapter listen(int port) { this.port = port; ensureServerManagerInitialized(); + autoRegisterEventListeners(); + this.serverManager.autoRegisterControllers(this.scanPackages); this.serverManager.setSslContext(this.sslContextPort); for (Cluster cluster : getClusters()) { cluster.endBootstrapPhase(); // Transition clusters to runtime } - DebugUtils.log("LocalGatewayAdapter: Starting server listeners on port " + port); + DebugUtils.info("LocalGatewayAdapter: Starting server listeners on port " + port); this.serverManager.listen(port); this.running = true; @@ -344,6 +374,19 @@ public LocalGatewayAdapter registerFilter(hexacloud.core.server.filter.HttpFilte return this; } + @Override + public LocalGatewayAdapter authService(String authServiceUrl) { + return authService(authServiceUrl, 3000); + } + + @Override + public LocalGatewayAdapter authService(String authServiceUrl, int timeoutMs) { + if (authServiceUrl == null || authServiceUrl.trim().isEmpty()) { + throw new IllegalArgumentException("authServiceUrl must not be null or empty"); + } + return registerFilter(new hexacloud.core.server.filter.builtin.ExternalAuthFilter(authServiceUrl, timeoutMs)); + } + @Override public LocalGatewayAdapter rateLimit(int requests, int durationSeconds) { requireActiveCluster().setRateLimit(requests, durationSeconds); @@ -444,6 +487,22 @@ public LocalGatewayAdapter enableTcpProxy(boolean enabled) { return this; } + @Override + public LocalGatewayAdapter tcpSoTimeout(int timeoutMs) { + this.tcpSoTimeout = timeoutMs; + ensureServerManagerInitialized(); + this.serverManager.tcpSoTimeout(timeoutMs); + return this; + } + + @Override + public LocalGatewayAdapter tcpKeepAlive(boolean enabled) { + this.tcpKeepAlive = enabled; + ensureServerManagerInitialized(); + this.serverManager.tcpKeepAlive(enabled); + return this; + } + private Cluster requireActiveCluster() { Cluster cluster = getCluster(); if (cluster == null) { diff --git a/java/src/hexacloud/infra/gateway/NodeBuilder.java b/java/src/hexacloud/infra/gateway/NodeBuilder.java index 4924021..f6ff48f 100644 --- a/java/src/hexacloud/infra/gateway/NodeBuilder.java +++ b/java/src/hexacloud/infra/gateway/NodeBuilder.java @@ -18,6 +18,7 @@ public class NodeBuilder implements NodeBuilderPort { private String pingHeaderValue = null; private boolean isExternal = false; private boolean telemetryOnly = false; + private hexacloud.core.model.RoutingProtocol routingProtocol = hexacloud.core.model.RoutingProtocol.HTTP; public NodeBuilder(hexacloud.core.ports.GatewayBuilderPort parent, Cluster cluster, String host, int port) { this(parent, cluster, null, host, port); @@ -68,13 +69,19 @@ public NodeBuilderPort telemetryOnly(boolean value) { return this; } + @Override + public NodeBuilderPort routingProtocol(hexacloud.core.model.RoutingProtocol protocol) { + if (protocol != null) this.routingProtocol = protocol; + return this; + } + @Override public hexacloud.core.ports.GatewayBuilderPort register() { ServerNode node = new ServerNode( name, host, port, NodeStatus.OFFLINE, isExternal, pingEnabled ? hexacloud.core.model.PingProtocol.HTTP : hexacloud.core.model.PingProtocol.NONE, pingPath, pingHeaderName, pingHeaderValue, false, telemetryOnly - ); + ).withRoutingProtocol(this.routingProtocol); cluster.registerServer(node); return parent; } diff --git a/java/src/hexacloud/infra/network/ThreadPingScheduler.java b/java/src/hexacloud/infra/network/ThreadPingScheduler.java index 8307dc6..6f52297 100644 --- a/java/src/hexacloud/infra/network/ThreadPingScheduler.java +++ b/java/src/hexacloud/infra/network/ThreadPingScheduler.java @@ -80,11 +80,12 @@ private void pingClusterNode(ServerNode node) { NodeStatus status = result.status(); boolean statusChanged = node.status() != status; if (statusChanged) { - eventManager.dispatch(new NodeStatusChanged(node.getFullHost(), status)); + eventManager.dispatch(new NodeStatusChanged(node.getFullHost(), status, node.getId())); + DebugUtils.info("Node " + node.getFullHost() + " status updated to " + status + " (" + node.getId() + ")"); } if (result.hasTelemetry()){ - eventManager.dispatch(new hexacloud.core.cluster.event.ClusterEvent.NodeTelemetryUpdated(node.getFullHost())); + eventManager.dispatch(new hexacloud.core.cluster.event.ClusterEvent.NodeTelemetryUpdated(node.getFullHost(), node.getId())); } }); } diff --git a/java/src/hexacloud/infra/server/DefaultHttpErrorHandler.java b/java/src/hexacloud/infra/server/DefaultHttpErrorHandler.java new file mode 100644 index 0000000..92dd3e7 --- /dev/null +++ b/java/src/hexacloud/infra/server/DefaultHttpErrorHandler.java @@ -0,0 +1,35 @@ +package hexacloud.infra.server; + +import hexacloud.core.server.filter.HttpResponse; +import java.io.PrintWriter; + +public class DefaultHttpErrorHandler implements HttpErrorHandler { + + @Override + public void handleException(HttpResponse res, Exception ex) { + res.setStatus(502); + res.setContentType("text/plain"); + try (PrintWriter out = res.getWriter()) { + out.print("502 Bad Gateway - Connection failed: " + ex.getMessage()); + } catch (Exception ignored) {} + } + + @Override + public void handleStatus(HttpResponse res, int statusCode, String message) { + res.setStatus(statusCode); + res.setContentType("text/plain"); + try (PrintWriter out = res.getWriter()) { + out.print(statusCode + " " + getStatusText(statusCode) + " - " + message); + } catch (Exception ignored) {} + } + + private String getStatusText(int code) { + switch (code) { + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 502: return "Bad Gateway"; + case 503: return "Service Unavailable"; + default: return "Internal Server Error"; + } + } +} diff --git a/java/src/hexacloud/infra/server/HttpErrorHandler.java b/java/src/hexacloud/infra/server/HttpErrorHandler.java new file mode 100644 index 0000000..c82cb71 --- /dev/null +++ b/java/src/hexacloud/infra/server/HttpErrorHandler.java @@ -0,0 +1,8 @@ +package hexacloud.infra.server; + +import hexacloud.core.server.filter.HttpResponse; + +public interface HttpErrorHandler { + void handleException(HttpResponse res, Exception ex); + void handleStatus(HttpResponse res, int statusCode, String message); +} diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index da80b17..2d771d2 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -1,25 +1,17 @@ package hexacloud.infra.server; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; - import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.io.PrintWriter; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; -import java.util.stream.Collectors; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; import hexacloud.core.cluster.Cluster; import hexacloud.core.cluster.ClusterRegistry; -import hexacloud.core.model.NodeStatus; -import hexacloud.core.model.ServerNode; import hexacloud.core.server.ServerTransport; import hexacloud.core.server.filter.HttpFilter; import hexacloud.core.server.filter.HttpFilterChainImpl; @@ -29,8 +21,10 @@ import hexacloud.core.server.filter.builtin.IpRestrictionFilter; import hexacloud.core.server.filter.builtin.RateLimitFilter; import hexacloud.core.server.filter.builtin.TokenAuthFilter; +import hexacloud.core.server.filter.builtin.CorsFilter; import hexacloud.core.server.route.RouteRegistry; -import hexacloud.core.server.route.RouteRule; +import hexacloud.core.server.route.RouteResolution; +import hexacloud.core.server.route.PathResolver; import hexacloud.core.utils.common.DebugUtils; import hexacloud.core.utils.concurrent.ThreadManager; import hexacloud.infra.server.filter.HttpRequestImpl; @@ -45,31 +39,31 @@ public class HttpTransport implements ServerTransport { private HttpServer server; private boolean running = false; - private final ConcurrentHashMap roundRobinIndices = new ConcurrentHashMap<>(); - private static final java.util.concurrent.ConcurrentLinkedQueue BUFFER_POOL = new java.util.concurrent.ConcurrentLinkedQueue<>(); - private final ConcurrentHashMap routeCache = new ConcurrentHashMap<>(); - private final java.net.http.HttpClient httpClient = java.net.http.HttpClient.newBuilder() - .version(java.net.http.HttpClient.Version.HTTP_2) - .connectTimeout(java.time.Duration.ofMillis(5000)) - .executor(ThreadManager.newVirtualThreadPool()) - .build(); + private final HttpErrorHandler errorHandler = new DefaultHttpErrorHandler(); + private final ReverseProxyService reverseProxyService = new ReverseProxyService(new hexacloud.core.utils.network.JdkHttpProxyClient(), errorHandler); private hexacloud.core.server.PerformanceProfile performanceProfile = hexacloud.core.server.PerformanceProfile.STANDARD; private final List activeFilters = new CopyOnWriteArrayList<>(); private hexacloud.core.ports.SslContextPort sslContextPort; - private void rebuildFilters(Cluster cluster, List customFilters) { + private void rebuildFilters(List clusters, List customFilters) { activeFilters.clear(); - if (cluster != null) { - String allowedIps = cluster.getAllowedIps(); - if (allowedIps != null && !allowedIps.trim().isEmpty()) { - activeFilters.add(new IpRestrictionFilter(cluster)); - } - if (cluster.getRateLimitRequests() > 0 && cluster.getRateLimitDurationSeconds() > 0) { - activeFilters.add(new RateLimitFilter(cluster)); - } - if (cluster.isRequireToken()) { - activeFilters.add(new TokenAuthFilter(cluster)); + + // CORS filter is always the first filter in the chain + activeFilters.add(new CorsFilter()); + + if (clusters != null) { + for (Cluster cluster : clusters) { + String allowedIps = cluster.getAllowedIps(); + if (allowedIps != null && !allowedIps.trim().isEmpty()) { + activeFilters.add(new IpRestrictionFilter(cluster)); + } + if (cluster.getRateLimitRequests() > 0 && cluster.getRateLimitDurationSeconds() > 0) { + activeFilters.add(new RateLimitFilter(cluster)); + } + if (cluster.isRequireToken()) { + activeFilters.add(new TokenAuthFilter(cluster)); + } } } activeFilters.addAll(customFilters); @@ -94,10 +88,10 @@ public void setSslContext(hexacloud.core.ports.SslContextPort sslContextPort) { } @Override - public void listen(int port, RouteRegistry registry, Cluster cluster, List customFilters) { + public void listen(int port, RouteRegistry registry, List clusters, List customFilters) { try { - rebuildFilters(cluster, customFilters); - DebugUtils.log("HTTP Transport (JDK) starting on port " + port + " with profile: " + performanceProfile); + rebuildFilters(clusters, customFilters); + DebugUtils.info("HTTP Transport (JDK) starting on port " + port + " with profile: " + performanceProfile); if (sslContextPort != null && sslContextPort.isSslEnabled()) { com.sun.net.httpserver.HttpsServer httpsServer = com.sun.net.httpserver.HttpsServer.create( new java.net.InetSocketAddress(port), 2048 @@ -122,41 +116,18 @@ public void handle(HttpExchange exchange) throws IOException { } try { - String fastPath = exchange.getRequestURI().getPath(); - String fastMatchingPath = fastPath.startsWith("/v1/") ? fastPath.substring(3) : (fastPath.equals("/v1") ? "/" : fastPath); - - RouteHandlerInfo fastRouteInfo = null; - boolean isProxy = false; - - if (fastMatchingPath.startsWith("/clusters/")) { - isProxy = true; - } else if (registry.getRouteRulesList() != null && !registry.getRouteRulesList().isEmpty()) { - String requestHost = exchange.getRequestHeaders().getFirst("Host"); - RouteRule matchedRule = null; - List rules = registry.getRouteRulesList(); - if (rules != null) { - for (RouteRule rule : rules) { - if (rule.matches(requestHost, fastMatchingPath)) { - matchedRule = rule; - break; - } - } - } - isProxy = (matchedRule != null); - } - - // Fast-path for direct custom routes when no filters are active - if (!isProxy && activeFilters.isEmpty()) { - if (fastRouteInfo == null) { - fastRouteInfo = routeCache.computeIfAbsent(fastMatchingPath, path -> { - String routeName = toRouteName(path); - BiConsumer handler = registry.getRoutes().get(routeName); - return new RouteHandlerInfo(handler, routeName); - }); - } - - if (fastRouteInfo.handler != null) { - if (fastRouteInfo.routeName.equals("GET_NODES_JSON")) { + String path = exchange.getRequestURI().getPath(); + String matchingPath = path.startsWith("/v1/") ? path.substring(3) : (path.equals("/v1") ? "/" : path); + + RouteResolution fastResolution = PathResolver.resolve(matchingPath, exchange.getRequestHeaders().getFirst("Host"), registry); + boolean canUseFastPath = fastResolution.isLocal() + && registry.isRouteFastPath(fastResolution.localRouteName()) + && (activeFilters.isEmpty() || (activeFilters.size() == 1 && activeFilters.get(0) instanceof CorsFilter)); + + if (canUseFastPath) { + BiConsumer handler = registry.getRoutes().get(fastResolution.localRouteName()); + if (handler != null) { + if (fastResolution.localRouteName().equals("/V1/GET_NODES_JSON")) { exchange.getResponseHeaders().set("Content-Type", "application/json"); } else { exchange.getResponseHeaders().set("Content-Type", "text/plain"); @@ -165,325 +136,103 @@ public void handle(HttpExchange exchange) throws IOException { try (PrintWriter out = new PrintWriter(new java.io.BufferedWriter(new java.io.OutputStreamWriter(exchange.getResponseBody(), java.nio.charset.StandardCharsets.UTF_8)))) { String query = exchange.getRequestURI().getQuery(); String args = query != null ? query : ""; - fastRouteInfo.handler.accept(args, out); + handler.accept(args, out); } return; } } - - // 1. Instantiate Wrappers HttpRequestImpl req = new HttpRequestImpl(exchange); HttpResponseImpl res = new HttpResponseImpl(exchange); - // 3. Final Route execution handler - BiConsumer routeHandler = (r, s) -> { - try { - String rawPath = r.getPath(); - String matchingPath = rawPath; - if (matchingPath.startsWith("/v1/")) { - matchingPath = matchingPath.substring(3); - } else if (matchingPath.equals("/v1")) { - matchingPath = "/"; - } - - String targetClusterName = null; - String clusterSubpath = null; - boolean matchedRouteRule = false; - - if (matchingPath.startsWith("/clusters/")) { - String pathWithoutClusters = matchingPath.substring("/clusters/".length()); - int slashIdx = pathWithoutClusters.indexOf('/'); - if (slashIdx != -1) { - targetClusterName = pathWithoutClusters.substring(0, slashIdx); - clusterSubpath = pathWithoutClusters.substring(slashIdx); - } else { - targetClusterName = pathWithoutClusters; - clusterSubpath = "/"; - } - } else { - String routeName = toRouteName(matchingPath); - if (!registry.getRoutes().containsKey(routeName)) { - String requestHost = r.getHeader("Host"); - RouteRule matchedRule = null; - List rules = registry.getRouteRulesList(); - if (rules != null) { - for (RouteRule rule : rules) { - if (rule.matches(requestHost, matchingPath)) { - matchedRule = rule; - break; - } - } - } - if (matchedRule != null) { - targetClusterName = matchedRule.getClusterName(); - clusterSubpath = matchedRule.rewritePath(matchingPath); - matchedRouteRule = true; - } - } - } - - if (targetClusterName != null) { - Cluster targetCluster = ClusterRegistry.getInstance().getCluster(targetClusterName); - if (targetCluster == null) { - s.setStatus(404); - try (PrintWriter out = s.getWriter()) { - out.print("404 Not Found - Unknown Cluster: " + targetClusterName); - } - return; - } - - RouteRegistry targetRegistry = targetCluster.getRouteRegistry(); - String routeName = clusterSubpath.length() > 1 ? clusterSubpath.substring(1).toUpperCase() : ""; - - // Check built-in cluster management routes - BiConsumer handler = targetRegistry.getRoutes().get(routeName); - if (handler != null) { - if (routeName.equals("GET_NODES_JSON")) { - s.setContentType("application/json"); - } else { - s.setContentType("text/plain"); - } - if (!s.isCommitted()) { - s.setStatus(200); - } - try (PrintWriter out = s.getWriter()) { - String query = r.getQuery(); - String args = query != null ? query : ""; - handler.accept(args, out); - } - return; - } - - // Layer 7 Reverse Proxy Load Balancing - if (!matchedRouteRule && targetCluster.getRoutingMode() == Cluster.RoutingMode.TELEMETRY_ONLY) { - s.setStatus(403); - try (PrintWriter out = s.getWriter()) { - out.print("403 Forbidden - Load balancing is disabled for cluster: " + targetClusterName); - } - return; - } - - List activeNodes = targetCluster.getCluster().stream() - .filter(n -> n != null && n.status() == NodeStatus.ONLINE && !n.telemetryOnly()) - .collect(Collectors.toList()); - - if (activeNodes.isEmpty()) { - s.setStatus(503); - try (PrintWriter out = s.getWriter()) { - out.print("503 Service Unavailable - No active nodes in cluster: " + targetClusterName); - } - return; - } - - // Thread-safe Round-Robin selection - AtomicInteger rrIdx = roundRobinIndices.computeIfAbsent(targetClusterName, k -> new AtomicInteger(0)); - int selectedIndex = (rrIdx.getAndIncrement() & Integer.MAX_VALUE) % activeNodes.size(); - ServerNode targetNode = activeNodes.get(selectedIndex); - - // Forward HTTP request to backend node - String targetUrlStr = targetNode.getFullHost() + clusterSubpath; - String query = r.getQuery(); - if (query != null && !query.isEmpty()) { - targetUrlStr += "?" + query; - } + RouteResolution resolution = PathResolver.resolve(req.getPath(), req.getHeader("Host"), registry); - long startTime = System.currentTimeMillis(); - java.net.http.HttpRequest.Builder reqBuilder = java.net.http.HttpRequest.newBuilder() - .uri(java.net.URI.create(targetUrlStr)); + // Inline default CorsFilter optimization + if (activeFilters.size() == 1 && activeFilters.get(0) instanceof CorsFilter) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE"); + res.setHeader("Access-Control-Allow-Headers", "X-Cluster-Token, Content-Type, Authorization"); - int timeout = targetCluster.getTimeoutMs() > 0 ? targetCluster.getTimeoutMs() : 5000; - reqBuilder.timeout(java.time.Duration.ofMillis(timeout)); - - // Copy request headers - Map> reqHeaders = r.getHeaders(); - if (reqHeaders != null) { - for (Map.Entry> entry : reqHeaders.entrySet()) { - String hName = entry.getKey(); - if (hName == null || hName.equalsIgnoreCase("Host") || hName.equalsIgnoreCase("Content-Length") || hName.equalsIgnoreCase("Connection") || hName.equalsIgnoreCase("Upgrade")) { - continue; - } - for (String val : entry.getValue()) { - reqBuilder.header(hName, val); - } - } - } - - // Forward request body if present - String method = r.getMethod(); - java.net.http.HttpRequest.BodyPublisher bodyPublisher; - boolean hasBody = "POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method); - if (hasBody) { - bodyPublisher = java.net.http.HttpRequest.BodyPublishers.ofInputStream(() -> exchange.getRequestBody()); - } else { - bodyPublisher = java.net.http.HttpRequest.BodyPublishers.noBody(); - } - reqBuilder.method(method, bodyPublisher); - - java.net.http.HttpRequest proxyRequest = reqBuilder.build(); - - int respCode = 502; - java.net.http.HttpResponse proxyResponse = null; - try { - proxyResponse = httpClient.send(proxyRequest, java.net.http.HttpResponse.BodyHandlers.ofInputStream()); - respCode = proxyResponse.statusCode(); - } catch (Exception ex) { - respCode = 502; - } - - long latencyMs = System.currentTimeMillis() - startTime; - - // Passive Telemetry extraction - Double cpuVal = null; - Double ramVal = null; - if (proxyResponse != null) { - cpuVal = parseHeaderDouble(proxyResponse.headers(), "X-Telemetry-CPU", "X-Node-CPU"); - ramVal = parseHeaderDouble(proxyResponse.headers(), "X-Telemetry-RAM", "X-Node-RAM"); - } - - targetCluster.updateTelemetryServer(targetNode.host(), targetNode.port(), cpuVal, ramVal, null, (int) latencyMs, null); - if (cpuVal != null) targetNode.setCpuUsage(cpuVal); - if (ramVal != null) targetNode.setRamUsage(ramVal); - targetNode.setLatencyMs((int) latencyMs); - - // Copy response headers to client response - if (proxyResponse != null) { - for (Map.Entry> entry : proxyResponse.headers().map().entrySet()) { - String hName = entry.getKey(); - if (hName == null || hName.equalsIgnoreCase("Transfer-Encoding") || hName.equalsIgnoreCase("Content-Length") || hName.equalsIgnoreCase("Connection")) { - continue; - } - for (String val : entry.getValue()) { - exchange.getResponseHeaders().add(hName, val); - } - } - } - - // Send response status and body - if (proxyResponse != null) { - long contentLength = proxyResponse.headers().firstValueAsLong("Content-Length").orElse(-1L); - if (respCode == 204 || respCode == 304 || contentLength == 0) { - exchange.sendResponseHeaders(respCode, -1); - } else { - // Chunked streaming for body - exchange.sendResponseHeaders(respCode, 0); - byte[] buf = BUFFER_POOL.poll(); - if (buf == null) { - buf = new byte[8192]; - } - try (InputStream in = proxyResponse.body(); - OutputStream os = exchange.getResponseBody()) { - int len; - while ((len = in.read(buf)) != -1) { - os.write(buf, 0, len); - } - os.flush(); - } finally { - BUFFER_POOL.offer(buf); - } - } - } else { - if (respCode == 502) { - byte[] respBytes = "502 Bad Gateway - Connection failed".getBytes(java.nio.charset.StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "text/plain"); - exchange.sendResponseHeaders(502, respBytes.length); - try (OutputStream os = exchange.getResponseBody()) { - os.write(respBytes); - os.flush(); - } - } else { - exchange.sendResponseHeaders(respCode, -1); - } - } + if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { + res.setStatus(204); + return; + } - } else { - final String finalLookupPath = matchingPath; - RouteHandlerInfo routeInfo = routeCache.computeIfAbsent(finalLookupPath, path -> { - String routeName = toRouteName(path); - BiConsumer handler = registry.getRoutes().get(routeName); - return new RouteHandlerInfo(handler, routeName); - }); + executeRoute(req, res, resolution, registry); + return; + } - if (routeInfo.handler != null) { - if (routeInfo.routeName.equals("GET_NODES_JSON")) { - s.setContentType("application/json"); - } else { - s.setContentType("text/plain"); - } - if (!s.isCommitted()) { - s.setStatus(200); - } - try (PrintWriter out = s.getWriter()) { - String query = r.getQuery(); - String args = query != null ? query : ""; - routeInfo.handler.accept(args, out); - } - } else { - s.setStatus(404); - try (PrintWriter out = s.getWriter()) { - out.print("404 Not Found - Unknown Route: " + matchingPath); - } - } - } + BiConsumer routeHandler = (r, s) -> { + try { + executeRoute(r, s, resolution, registry); } catch (Exception e) { throw new RuntimeException(e); } }; - // 4. Run chain - if (!activeFilters.isEmpty()) { - HttpFilterChainImpl chain = new HttpFilterChainImpl(activeFilters, routeHandler); - chain.doFilter(req, res); - } else { - routeHandler.accept(req, res); - } + HttpFilterChainImpl chain = new HttpFilterChainImpl(activeFilters, routeHandler); + chain.doFilter(req, res); } catch (Exception e) { DebugUtils.error("HttpTransport: Exception caught in filter chain pipeline: " + e.getMessage(), e); - if (!exchange.getResponseHeaders().containsKey("Content-Type")) { - exchange.getResponseHeaders().set("Content-Type", "text/plain"); - } try { - exchange.sendResponseHeaders(500, 0); - try (OutputStream os = exchange.getResponseBody(); - PrintWriter out = new PrintWriter(os, true)) { - out.println("500 Internal Server Error - Execution failure: " + e.getMessage()); - } + HttpResponseImpl res = new HttpResponseImpl(exchange); + errorHandler.handleException(res, e); } catch (Exception ignored) {} } } }); - - new Thread(() -> { - server.start(); - running = true; - DebugUtils.info("HTTP Transport successfully bound and listening on port " + port); - }, "HttpServer-Listener-" + port).start(); - - } catch(IOException e) { - DebugUtils.error("HTTP Transport failed to start on port " + port, e); + + server.start(); + running = true; + } catch (Exception e) { + DebugUtils.error("HttpTransport: Failed to start HTTP server on port " + port, e); + throw new RuntimeException("HttpTransport start failed", e); } } - private Double parseHeaderDouble(java.net.http.HttpHeaders headers, String... headerNames) { - for (String hName : headerNames) { - java.util.Optional valOpt = headers.firstValue(hName); - if (valOpt.isPresent()) { - String val = valOpt.get(); - if (!val.trim().isEmpty()) { - try { - return Double.parseDouble(val.replace("%", "").trim()); - } catch (NumberFormatException ignored) {} + private void executeRoute(HttpRequest r, HttpResponse s, RouteResolution resolution, RouteRegistry registry) throws Exception { + if (resolution.isProxy()) { + Cluster targetCluster = ClusterRegistry.getInstance().getCluster(resolution.targetClusterName()); + if (targetCluster == null) { + errorHandler.handleStatus(s, 404, "Unknown Cluster: " + resolution.targetClusterName()); + return; + } + + // Check if there is an internal cluster administration route + RouteRegistry clusterRegistry = targetCluster.getRouteRegistry(); + String clusterRouteKey = resolution.resolveTargetRouteKey(); + if (clusterRegistry != null && clusterRouteKey != null && clusterRegistry.getRoutes().containsKey(clusterRouteKey)) { + BiConsumer handler = clusterRegistry.getRoutes().get(clusterRouteKey); + if (clusterRouteKey.equals("/V1/GET_NODES_JSON")) { + s.setContentType("application/json"); + } else { + s.setContentType("text/plain"); + } + try (PrintWriter out = s.getWriter()) { + String query = r.getQuery(); + String args = query != null ? query : ""; + handler.accept(args, out); } + return; } - } - return null; - } - private static String toRouteName(String path) { - if (path == null || path.equals("/") || path.isEmpty()) { - return "/"; + reverseProxyService.proxyRequest(r, s, targetCluster, resolution.targetSubpath(), targetCluster.getTimeoutMs(), resolution.matchedRouteRule()); + + } else if (resolution.isLocal()) { + BiConsumer handler = registry.getRoutes().get(resolution.localRouteName()); + if (resolution.localRouteName().equals("/V1/GET_NODES_JSON")) { + s.setContentType("application/json"); + } else { + s.setContentType("text/plain"); + } + try (PrintWriter out = s.getWriter()) { + String query = r.getQuery(); + String args = query != null ? query : ""; + handler.accept(args, out); + } + } else { + errorHandler.handleStatus(s, 404, "Unknown Route: " + r.getPath()); } - return path.startsWith("/") ? path.substring(1).toUpperCase() : path.toUpperCase(); } @Override @@ -491,7 +240,7 @@ public void stop() { if(server != null) { server.stop(0); running = false; - DebugUtils.log("HTTP Transport stopped."); + DebugUtils.info("HTTP Transport stopped."); } } @@ -499,14 +248,4 @@ public void stop() { public boolean isRunning() { return running; } - - private static class RouteHandlerInfo { - final BiConsumer handler; - final String routeName; - - RouteHandlerInfo(BiConsumer handler, String routeName) { - this.handler = handler; - this.routeName = routeName; - } - } } diff --git a/java/src/hexacloud/infra/server/ReverseProxyService.java b/java/src/hexacloud/infra/server/ReverseProxyService.java new file mode 100644 index 0000000..aae8e9f --- /dev/null +++ b/java/src/hexacloud/infra/server/ReverseProxyService.java @@ -0,0 +1,148 @@ +package hexacloud.infra.server; + +import hexacloud.core.cluster.Cluster; +import hexacloud.core.model.ServerNode; +import hexacloud.core.server.filter.HttpRequest; +import hexacloud.core.server.filter.HttpResponse; +import hexacloud.core.utils.common.DebugUtils; +import hexacloud.core.utils.network.HttpProxyClient; +import hexacloud.core.utils.network.JdkHttpProxyClient; +import hexacloud.core.utils.network.ProxyResponse; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class ReverseProxyService { + private final HttpProxyClient proxyClient; + private final HttpErrorHandler errorHandler; + private static final java.util.concurrent.ConcurrentLinkedQueue BUFFER_POOL = new java.util.concurrent.ConcurrentLinkedQueue<>(); + + public ReverseProxyService(HttpProxyClient proxyClient, HttpErrorHandler errorHandler) { + this.proxyClient = proxyClient != null ? proxyClient : new JdkHttpProxyClient(); + this.errorHandler = errorHandler != null ? errorHandler : new DefaultHttpErrorHandler(); + } + + public void proxyRequest(HttpRequest req, HttpResponse res, Cluster targetCluster, String subpath, int timeoutMs) { + proxyRequest(req, res, targetCluster, subpath, timeoutMs, false); + } + + public void proxyRequest(HttpRequest req, HttpResponse res, Cluster targetCluster, String subpath, int timeoutMs, boolean matchedRouteRule) { + if (!matchedRouteRule && targetCluster.getRoutingMode() == Cluster.RoutingMode.TELEMETRY_ONLY) { + errorHandler.handleStatus(res, 403, "Forbidden - Load balancing is disabled for cluster: " + targetCluster.getClusterName()); + return; + } + + ServerNode targetNode = targetCluster.selectNode(); + if (targetNode == null) { + errorHandler.handleStatus(res, 503, "No active nodes in cluster: " + targetCluster.getClusterName()); + return; + } + + long startTime = System.currentTimeMillis(); + + String targetUrl = targetNode.getFullHost() + (subpath.startsWith("/") ? subpath : "/" + subpath); + if (targetUrl.startsWith("http://localhost")) { + targetUrl = targetUrl.replaceFirst("http://localhost", "http://127.0.0.1"); + } else if (targetUrl.startsWith("https://localhost")) { + targetUrl = targetUrl.replaceFirst("https://localhost", "https://127.0.0.1"); + } + String query = req.getQuery(); + if (query != null && !query.isEmpty()) { + targetUrl += "?" + query; + } + + Map> headers = new java.util.TreeMap<>(String.CASE_INSENSITIVE_ORDER); + if (req.getHeaders() != null) { + for (Map.Entry> entry : req.getHeaders().entrySet()) { + headers.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + } + + // Add traceability headers + String clientIp = req.getClientIp(); + if (clientIp != null) { + headers.computeIfAbsent("X-Forwarded-For", k -> new ArrayList<>()).add(clientIp); + } + headers.computeIfAbsent("X-Forwarded-Host", k -> new ArrayList<>()).add(req.getHeader("Host")); + headers.computeIfAbsent("X-Forwarded-Proto", k -> new ArrayList<>()).add("http"); + + try (InputStream bodyIn = req.getBody()) { + ProxyResponse response = proxyClient.execute(targetUrl, req.getMethod(), headers, bodyIn, timeoutMs); + + long startTimeForTelemetry = System.currentTimeMillis(); + long latencyMs = startTimeForTelemetry - startTime; + + // Passive Telemetry extraction + Double cpuVal = parseHeaderDouble(response.headers(), "X-Telemetry-CPU", "X-Node-CPU"); + Double ramVal = parseHeaderDouble(response.headers(), "X-Telemetry-RAM", "X-Node-RAM"); + + targetCluster.updateTelemetryServer(targetNode.host(), targetNode.port(), cpuVal, ramVal, null, (int) latencyMs, null); + if (cpuVal != null) targetNode.setCpuUsage(cpuVal); + if (ramVal != null) targetNode.setRamUsage(ramVal); + targetNode.setLatencyMs((int) latencyMs); + + res.setStatus(response.statusCode()); + + // Forward headers + for (Map.Entry> entry : response.headers().entrySet()) { + String key = entry.getKey(); + if (key == null || key.equalsIgnoreCase("Transfer-Encoding") + || key.equalsIgnoreCase("Connection") + || key.equalsIgnoreCase("Keep-Alive") + || key.equalsIgnoreCase("Upgrade") + || key.equalsIgnoreCase("Proxy-Connection")) { + continue; + } + for (String val : entry.getValue()) { + res.setHeader(key, val); + } + } + + try (InputStream in = response.bodyStream(); OutputStream out = res.getOutputStream()) { + byte[] buffer = BUFFER_POOL.poll(); + if (buffer == null) { + buffer = new byte[8192]; + } + try { + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + out.flush(); + } finally { + BUFFER_POOL.offer(buffer); + } + } + } catch (Exception e) { + DebugUtils.error("ReverseProxyService: Proxy request failed to " + targetUrl, e); + errorHandler.handleException(res, e); + } + } + + private Double parseHeaderDouble(Map> headers, String... headerNames) { + if (headers == null) return null; + for (String hName : headerNames) { + List vals = headers.get(hName); + if (vals == null || vals.isEmpty()) { + for (Map.Entry> entry : headers.entrySet()) { + if (hName.equalsIgnoreCase(entry.getKey())) { + vals = entry.getValue(); + break; + } + } + } + if (vals != null && !vals.isEmpty()) { + String val = vals.get(0); + if (val != null && !val.trim().isEmpty()) { + try { + return Double.parseDouble(val.replace("%", "").trim()); + } catch (NumberFormatException ignored) {} + } + } + } + return null; + } +} diff --git a/java/src/hexacloud/infra/server/TcpProxyTransport.java b/java/src/hexacloud/infra/server/TcpProxyTransport.java index 2fd1bdf..c39b9c7 100644 --- a/java/src/hexacloud/infra/server/TcpProxyTransport.java +++ b/java/src/hexacloud/infra/server/TcpProxyTransport.java @@ -33,24 +33,40 @@ public class TcpProxyTransport implements ServerTransport { private volatile boolean active = true; private final AtomicInteger roundRobinIndex = new AtomicInteger(0); private final Set activeSockets = ConcurrentHashMap.newKeySet(); + private static final int MAX_POOL_SIZE = 512; + private static final java.util.concurrent.atomic.AtomicInteger POOL_SIZE = new java.util.concurrent.atomic.AtomicInteger(0); private static final java.util.concurrent.ConcurrentLinkedQueue BUFFER_POOL = new java.util.concurrent.ConcurrentLinkedQueue<>(); + private int tcpSoTimeout = 30000; + private boolean tcpKeepAlive = true; + + public void setSoTimeout(int timeoutMs) { + this.tcpSoTimeout = timeoutMs; + } + + public void setKeepAlive(boolean enabled) { + this.tcpKeepAlive = enabled; + } + private void configureSocket(Socket socket) { try { socket.setTcpNoDelay(true); - socket.setKeepAlive(true); + socket.setKeepAlive(tcpKeepAlive); + if (tcpSoTimeout > 0) { + socket.setSoTimeout(tcpSoTimeout); + } socket.setReceiveBufferSize(65536); socket.setSendBufferSize(65536); } catch (Exception ignored) {} } @Override - public void listen(int port, RouteRegistry registry, Cluster cluster, List customFilters) { - new Thread(() -> serverListen(port, cluster), "TcpProxyServer-Listener-" + port).start(); + public void listen(int port, RouteRegistry registry, List clusters, List customFilters) { + new Thread(() -> serverListen(port, clusters), "TcpProxyServer-Listener-" + port).start(); } - private void serverListen(int port, Cluster cluster) { - DebugUtils.log("TcpProxyTransport starting to listen on port " + port); + private void serverListen(int port, List clusters) { + DebugUtils.info("TcpProxyTransport starting to listen on port " + port); try { serverSocket = new ServerSocket(port); running = true; @@ -67,12 +83,12 @@ private void serverListen(int port, Cluster cluster) { Socket clientSocket = serverSocket.accept(); configureSocket(clientSocket); activeSockets.add(clientSocket); - ThreadManager.startVirtual("TcpProxy-Handler-" + clientSocket.getRemoteSocketAddress(), () -> handleConnection(clientSocket, cluster)); + ThreadManager.startVirtual("TcpProxy-Handler-" + clientSocket.getRemoteSocketAddress(), () -> handleConnection(clientSocket, clusters)); } catch (IOException ex) { if (active && !serverSocket.isClosed()) { DebugUtils.error("TcpProxyTransport transient error accepting connection on port " + port, ex); try { - Thread.sleep(50); // Pause to prevent busy-spinning + Thread.sleep(50); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; @@ -85,21 +101,19 @@ private void serverListen(int port, Cluster cluster) { } } - private void handleConnection(Socket clientSocket, Cluster cluster) { + private void handleConnection(Socket clientSocket, List clusters) { Socket nodeSocket = null; try { - if (cluster == null || cluster.getRoutingMode() == Cluster.RoutingMode.TELEMETRY_ONLY) { - DebugUtils.log("TcpProxyTransport: Cluster is null or routing is disabled for cluster."); - closeQuietly(clientSocket); - return; - } - - List activeNodes = cluster.getCluster().stream() - .filter(n -> n != null && n.status() == NodeStatus.ONLINE) + // Collect all ONLINE TCP nodes across all clusters + List activeNodes = clusters.stream() + .filter(c -> c != null && c.getRoutingMode() != Cluster.RoutingMode.TELEMETRY_ONLY) + .flatMap(c -> c.getCluster().stream()) + .filter(n -> n != null && n.status() == NodeStatus.ONLINE + && n.routingProtocol() == hexacloud.core.model.RoutingProtocol.TCP) .collect(Collectors.toList()); if (activeNodes.isEmpty()) { - DebugUtils.log("TcpProxyTransport: No active nodes in cluster " + cluster.getClusterName()); + DebugUtils.info("TcpProxyTransport: No active TCP nodes available."); closeQuietly(clientSocket); return; } @@ -113,15 +127,20 @@ private void handleConnection(Socket clientSocket, Cluster cluster) { // Connect to backend node and measure latency long startTime = System.currentTimeMillis(); - int timeout = cluster.getTimeoutMs() > 0 ? cluster.getTimeoutMs() : 5000; + int timeout = 5000; // default timeout; per-cluster timeout applies at cluster level nodeSocket = new Socket(); configureSocket(nodeSocket); nodeSocket.connect(new InetSocketAddress(targetHost, targetPort), timeout); long latencyMs = System.currentTimeMillis() - startTime; - // Update passive telemetry & latency metric + // Update passive telemetry & latency metric on the owning cluster selectedNode.setLatencyMs((int) latencyMs); - cluster.updateTelemetryServer(selectedNode.host(), selectedNode.port(), null, null, null, (int) latencyMs, null); + for (Cluster c : clusters) { + if (c.getCluster().stream().anyMatch(n -> n != null && n.getId().equals(selectedNode.getId()))) { + c.updateTelemetryServer(selectedNode.host(), selectedNode.port(), null, null, null, (int) latencyMs, null); + break; + } + } activeSockets.add(nodeSocket); @@ -132,8 +151,8 @@ private void handleConnection(Socket clientSocket, Cluster cluster) { OutputStream nodeOut = finalNodeSocket.getOutputStream(); // Bidirectional tunneling using virtual threads - Thread t1 = ThreadManager.startVirtual("TcpProxy-ClientToNode", () -> tunnel(clientIn, nodeOut, finalNodeSocket)); - Thread t2 = ThreadManager.startVirtual("TcpProxy-NodeToClient", () -> tunnel(nodeIn, clientOut, clientSocket)); + Thread t1 = ThreadManager.startVirtual("TcpProxy-ClientToNode", () -> tunnel(clientIn, nodeOut, clientSocket, finalNodeSocket)); + Thread t2 = ThreadManager.startVirtual("TcpProxy-NodeToClient", () -> tunnel(nodeIn, clientOut, finalNodeSocket, clientSocket)); // Wait for both tunneling threads to finish so cleanup can unregister active sockets try { @@ -141,7 +160,7 @@ private void handleConnection(Socket clientSocket, Cluster cluster) { t2.join(); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - DebugUtils.log("TcpProxyTransport: Connection handler thread was interrupted."); + DebugUtils.info("TcpProxyTransport: Connection handler thread was interrupted."); } } catch (Exception e) { @@ -156,9 +175,11 @@ private void handleConnection(Socket clientSocket, Cluster cluster) { } } - private void tunnel(InputStream in, OutputStream out, Socket outSocket) { + private void tunnel(InputStream in, OutputStream out, Socket inSocket, Socket outSocket) { byte[] buffer = BUFFER_POOL.poll(); - if (buffer == null) { + if (buffer != null) { + POOL_SIZE.decrementAndGet(); + } else { buffer = new byte[8192]; } try { @@ -169,12 +190,13 @@ private void tunnel(InputStream in, OutputStream out, Socket outSocket) { } } catch (IOException ignored) { } finally { - BUFFER_POOL.offer(buffer); - try { - if (outSocket != null && !outSocket.isClosed() && !outSocket.isOutputShutdown()) { - outSocket.shutdownOutput(); - } - } catch (IOException ignored) {} + if (POOL_SIZE.incrementAndGet() <= MAX_POOL_SIZE) { + BUFFER_POOL.offer(buffer); + } else { + POOL_SIZE.decrementAndGet(); + } + closeQuietly(inSocket); + closeQuietly(outSocket); } } @@ -201,7 +223,7 @@ public void stop() { closeQuietly(s); } activeSockets.clear(); - DebugUtils.log("TcpProxyTransport stopped."); + DebugUtils.info("TcpProxyTransport stopped."); } @Override diff --git a/java/src/hexacloud/infra/server/TelnetTransport.java b/java/src/hexacloud/infra/server/TelnetTransport.java index 6ce72bc..79e05c6 100644 --- a/java/src/hexacloud/infra/server/TelnetTransport.java +++ b/java/src/hexacloud/infra/server/TelnetTransport.java @@ -28,19 +28,20 @@ public class TelnetTransport implements ServerTransport { private final ExecutorService threadPool = ThreadManager.newVirtualThreadPool(); @Override - public void listen(int port, RouteRegistry registry, hexacloud.core.cluster.Cluster cluster, List customFilters) { - new Thread(() -> serverListen(port, registry, cluster), "TelnetServer-Listener-" + port).start(); + public void listen(int port, RouteRegistry registry, java.util.List clusters, List customFilters) { + hexacloud.core.cluster.Cluster defaultCluster = clusters != null && !clusters.isEmpty() ? clusters.get(0) : null; + new Thread(() -> serverListen(port, registry, defaultCluster), "TelnetServer-Listener-" + port).start(); } private void serverListen(int port, RouteRegistry registry, hexacloud.core.cluster.Cluster cluster) { - DebugUtils.log("Telnet Transport starting to listen on port " + port); + DebugUtils.info("Telnet Transport starting to listen on port " + port); try { serverSocket = new ServerSocket(port); running = true; DebugUtils.info("Telnet Transport successfully bound and listening on port " + port); while(clusterActive) { Socket socket = serverSocket.accept(); - DebugUtils.log("Telnet Transport accepted new connection from " + socket.getRemoteSocketAddress()); + DebugUtils.info("Telnet Transport accepted new connection from " + socket.getRemoteSocketAddress()); threadPool.execute(() -> conn(socket, registry, cluster)); } } catch(IOException ex) { @@ -58,11 +59,11 @@ private void conn(Socket socket, RouteRegistry registry, hexacloud.core.cluster. String line = in.readLine(); if(line == null || line.trim().isEmpty()) { - DebugUtils.log("Telnet received empty connection request from " + socket.getRemoteSocketAddress()); + DebugUtils.info("Telnet received empty connection request from " + socket.getRemoteSocketAddress()); return; } - DebugUtils.log("Telnet received raw command line: '" + line + "'"); + DebugUtils.info("Telnet received raw command line: '" + line + "'"); String[] tokens = line.split(" ", 3); String command; @@ -118,9 +119,9 @@ private void conn(Socket socket, RouteRegistry registry, hexacloud.core.cluster. return; } - DebugUtils.log("Telnet: Executing route handler for command '" + command + "' with args '" + args + "'"); + DebugUtils.info("Telnet: Executing route handler for command '" + command + "' with args '" + args + "'"); handler.accept(args, out); - DebugUtils.log("Telnet: Successfully completed request handler for command '" + command + "'"); + DebugUtils.info("Telnet: Successfully completed request handler for command '" + command + "'"); } catch(IOException ex) { DebugUtils.error("Failed to process request from client", ex); @@ -145,7 +146,7 @@ public void stop() { } } threadPool.shutdownNow(); - DebugUtils.log("Telnet Transport stopped."); + DebugUtils.info("Telnet Transport stopped."); } @Override diff --git a/java/src/hexacloud/infra/server/UndertowHttpRequestImpl.java b/java/src/hexacloud/infra/server/UndertowHttpRequestImpl.java index 0f18195..6f3addf 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpRequestImpl.java +++ b/java/src/hexacloud/infra/server/UndertowHttpRequestImpl.java @@ -98,4 +98,13 @@ public void setAttribute(String key, Object value) { public Object getAttribute(String key) { return attributes == null ? null : attributes.get(key); } + + @Override + public java.io.InputStream getBody() throws Exception { + if (exchange.isBlocking()) { + return exchange.getInputStream(); + } + exchange.startBlocking(); + return exchange.getInputStream(); + } } diff --git a/java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java b/java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java index 5f888b1..8894ac9 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java +++ b/java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java @@ -41,7 +41,7 @@ public PrintWriter getWriter() throws Exception { if (!exchange.isBlocking()) { exchange.startBlocking(); } - writer = new PrintWriter(new java.io.BufferedWriter(new java.io.OutputStreamWriter(exchange.getOutputStream(), java.nio.charset.StandardCharsets.UTF_8))); + writer = new PrintWriter(new java.io.OutputStreamWriter(exchange.getOutputStream(), java.nio.charset.StandardCharsets.UTF_8)); } return writer; } @@ -50,6 +50,17 @@ public PrintWriter getWriter() throws Exception { public boolean isCommitted() { return exchange.isResponseStarted(); } + + @Override + public java.io.OutputStream getOutputStream() throws Exception { + if (!statusSet && !exchange.isResponseStarted()) { + exchange.setStatusCode(200); + } + if (!exchange.isBlocking()) { + exchange.startBlocking(); + } + return exchange.getOutputStream(); + } public void flushBuffer() { if (writer != null) { diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index fa27033..325d68c 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -4,15 +4,12 @@ import io.undertow.UndertowOptions; import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; -import io.undertow.util.Headers; -import io.undertow.util.HttpString; import hexacloud.core.cluster.Cluster; import hexacloud.core.cluster.ClusterRegistry; -import hexacloud.core.model.ServerNode; -import hexacloud.core.model.NodeStatus; import hexacloud.core.server.ServerTransport; import hexacloud.core.server.route.RouteRegistry; -import hexacloud.core.server.route.RouteRule; +import hexacloud.core.server.route.RouteResolution; +import hexacloud.core.server.route.PathResolver; import hexacloud.core.server.filter.HttpFilter; import hexacloud.core.server.filter.HttpRequest; import hexacloud.core.server.filter.HttpResponse; @@ -20,57 +17,46 @@ import hexacloud.core.server.filter.builtin.IpRestrictionFilter; import hexacloud.core.server.filter.builtin.RateLimitFilter; import hexacloud.core.server.filter.builtin.TokenAuthFilter; +import hexacloud.core.server.filter.builtin.CorsFilter; +import hexacloud.core.server.filter.HttpFilterChainImpl; import hexacloud.core.utils.common.DebugUtils; import hexacloud.core.utils.concurrent.ThreadManager; -import hexacloud.core.server.filter.HttpFilterChainImpl; -import java.io.InputStream; -import java.io.OutputStream; import java.io.PrintWriter; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; -import java.util.stream.Collectors; public class UndertowHttpTransport implements ServerTransport { private Undertow server; private boolean running = false; - private final ConcurrentHashMap roundRobinIndices = new ConcurrentHashMap<>(); - private final ConcurrentHashMap routeCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap headerCache = new ConcurrentHashMap<>(128); - private static final java.util.concurrent.ConcurrentLinkedQueue BUFFER_POOL = new java.util.concurrent.ConcurrentLinkedQueue<>(); - private static final HttpString CORS_ALLOW_ORIGIN = HttpString.tryFromString("Access-Control-Allow-Origin"); - private static final HttpString CORS_ALLOW_METHODS = HttpString.tryFromString("Access-Control-Allow-Methods"); - private static final HttpString CORS_ALLOW_HEADERS = HttpString.tryFromString("Access-Control-Allow-Headers"); - private final ExecutorService virtualExecutor = ThreadManager.newVirtualThreadPool(); - private static final ThreadLocal FAST_WRITER = ThreadLocal.withInitial(FastPrintWriter::new); - private final java.net.http.HttpClient httpClient = java.net.http.HttpClient.newBuilder() - .version(java.net.http.HttpClient.Version.HTTP_2) - .connectTimeout(java.time.Duration.ofMillis(5000)) - .executor(virtualExecutor) - .build(); + private java.util.concurrent.ExecutorService virtualExecutor; + private final HttpErrorHandler errorHandler = new DefaultHttpErrorHandler(); + private final ReverseProxyService reverseProxyService = new ReverseProxyService(new hexacloud.core.utils.network.JdkHttpProxyClient(), errorHandler); private hexacloud.core.server.PerformanceProfile performanceProfile = hexacloud.core.server.PerformanceProfile.STANDARD; private final List activeFilters = new CopyOnWriteArrayList<>(); private hexacloud.core.ports.SslContextPort sslContextPort; - private void rebuildFilters(Cluster cluster, List customFilters) { + private void rebuildFilters(List clusters, List customFilters) { activeFilters.clear(); - if (cluster != null) { - String allowedIps = cluster.getAllowedIps(); - if (allowedIps != null && !allowedIps.trim().isEmpty()) { - activeFilters.add(new IpRestrictionFilter(cluster)); - } - if (cluster.getRateLimitRequests() > 0 && cluster.getRateLimitDurationSeconds() > 0) { - activeFilters.add(new RateLimitFilter(cluster)); - } - if (cluster.isRequireToken()) { - activeFilters.add(new TokenAuthFilter(cluster)); + + // CORS filter is always the first filter in the chain + activeFilters.add(new CorsFilter()); + + if (clusters != null) { + for (Cluster cluster : clusters) { + String allowedIps = cluster.getAllowedIps(); + if (allowedIps != null && !allowedIps.trim().isEmpty()) { + activeFilters.add(new IpRestrictionFilter(cluster)); + } + if (cluster.getRateLimitRequests() > 0 && cluster.getRateLimitDurationSeconds() > 0) { + activeFilters.add(new RateLimitFilter(cluster)); + } + if (cluster.isRequireToken()) { + activeFilters.add(new TokenAuthFilter(cluster)); + } } } activeFilters.addAll(customFilters); @@ -95,15 +81,14 @@ public void setSslContext(hexacloud.core.ports.SslContextPort sslContextPort) { } @Override - public void listen(int port, RouteRegistry registry, Cluster cluster, List customFilters) { + public void listen(int port, RouteRegistry registry, List clusters, List customFilters) { try { - rebuildFilters(cluster, customFilters); - // Configure Default ByteBuffer Pool to avoid pool starvation under high concurrency + rebuildFilters(clusters, customFilters); io.undertow.connector.ByteBufferPool bufferPool = new io.undertow.server.DefaultByteBufferPool( - true, - 16384, + false, + 8192, -1, - 24, + 2, 0 ); Undertow.Builder builder = Undertow.builder() @@ -115,7 +100,6 @@ public void listen(int port, RouteRegistry registry, Cluster cluster, List { - String routeName = toRouteName(path); - BiConsumer handler = registry.getRoutes().get(routeName); - return new RouteHandlerInfo(handler, routeName); - }); - isProxy = fastRouteInfo.handler == null; - } - - // Fast-path for direct custom routes when no filters are active - if (!isProxy && activeFilters.isEmpty()) { - if (fastRouteInfo == null) { - fastRouteInfo = routeCache.computeIfAbsent(fastMatchingPath, path -> { - String routeName = toRouteName(path); - BiConsumer handler = registry.getRoutes().get(routeName); - return new RouteHandlerInfo(handler, routeName); - }); - } - if (fastRouteInfo.handler != null) { - processRequest(exchange, registry, cluster, customFilters); - return; - } - } - - if (exchange.isInIoThread()) { - java.util.concurrent.Executor executor = exchange.getConnection().getWorker(); - exchange.dispatch(executor, () -> { - try { - processRequest(exchange, registry, cluster, customFilters); - } catch (Exception e) { - handleError(exchange, e); - } - }); - return; - } - processRequest(exchange, registry, cluster, customFilters); - } - }) - .build(); - - server.start(); - running = true; - DebugUtils.info("HTTP Transport (Undertow) successfully bound and listening on port " + port); - } catch (Exception e) { - DebugUtils.error("HTTP Transport (Undertow) failed to start on port " + port, e); - } - } - - private void processRequest(HttpServerExchange exchange, RouteRegistry registry, Cluster cluster, List customFilters) { - // Set CORS headers - exchange.getResponseHeaders().put(CORS_ALLOW_ORIGIN, "*"); - exchange.getResponseHeaders().put(CORS_ALLOW_METHODS, "GET, POST, OPTIONS, PUT, DELETE"); - exchange.getResponseHeaders().put(CORS_ALLOW_HEADERS, "X-Cluster-Token, Content-Type, Authorization"); - - if (io.undertow.util.Methods.OPTIONS.equals(exchange.getRequestMethod())) { - exchange.setStatusCode(204); - return; - } - - try { - String fastPath = exchange.getRequestPath(); - String fastMatchingPath = fastPath.startsWith("/v1/") ? fastPath.substring(3) : (fastPath.equals("/v1") ? "/" : fastPath); - boolean isProxy = fastMatchingPath.startsWith("/clusters/") || (registry.getRouteRulesList() != null && !registry.getRouteRulesList().isEmpty()); - - // Fast-path for direct custom routes when no filters are active - if (!isProxy && activeFilters.isEmpty()) { - RouteHandlerInfo routeInfo = routeCache.computeIfAbsent(fastMatchingPath, path -> { - String routeName = toRouteName(path); - BiConsumer handler = registry.getRoutes().get(routeName); - return new RouteHandlerInfo(handler, routeName); - }); - - if (routeInfo.handler != null) { - if (routeInfo.routeName.equals("GET_NODES_JSON")) { - exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); - } else { - exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); - } - exchange.setStatusCode(200); - - FastPrintWriter out = FAST_WRITER.get(); - out.reset(); - String query = exchange.getQueryString(); - String args = query != null ? query : ""; - routeInfo.handler.accept(args, out); - - byte[] responseBytes = out.toBytes(); - exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, String.valueOf(responseBytes.length)); - - exchange.getResponseSender().send(java.nio.ByteBuffer.wrap(responseBytes)); - return; - } - } - - // 1. Wrap request and response - UndertowHttpRequestImpl req = new UndertowHttpRequestImpl(exchange); - UndertowHttpResponseImpl res = new UndertowHttpResponseImpl(exchange); - - // 2. Build active filter chain - // Filters are pre-compiled in this.activeFilters list - - // 3. Define Route execution handler - BiConsumer routeHandler = (r, s) -> { - try { - String rawPath = r.getPath(); - String matchingPath = rawPath; - if (matchingPath.startsWith("/v1/")) { - matchingPath = matchingPath.substring(3); - } else if (matchingPath.equals("/v1")) { - matchingPath = "/"; - } - - String targetClusterName = null; - String clusterSubpath = null; - boolean matchedRouteRule = false; - - if (matchingPath.startsWith("/clusters/")) { - String pathWithoutClusters = matchingPath.substring("/clusters/".length()); - int slashIdx = pathWithoutClusters.indexOf('/'); - if (slashIdx != -1) { - targetClusterName = pathWithoutClusters.substring(0, slashIdx); - clusterSubpath = pathWithoutClusters.substring(slashIdx); - } else { - targetClusterName = pathWithoutClusters; - clusterSubpath = "/"; - } - } else { - String routeName = toRouteName(matchingPath); - if (!registry.getRoutes().containsKey(routeName)) { - String requestHost = r.getHeader("Host"); - RouteRule matchedRule = null; - List rules = registry.getRouteRulesList(); - if (rules != null) { - for (RouteRule rule : rules) { - if (rule.matches(requestHost, matchingPath)) { - matchedRule = rule; - break; - } - } - } - if (matchedRule != null) { - targetClusterName = matchedRule.getClusterName(); - clusterSubpath = matchedRule.rewritePath(matchingPath); - matchedRouteRule = true; - } - } - } - - if (targetClusterName != null) { - Cluster targetCluster = ClusterRegistry.getInstance().getCluster(targetClusterName); - if (targetCluster == null) { - s.setStatus(404); - try (PrintWriter out = s.getWriter()) { - out.print("404 Not Found - Unknown Cluster: " + targetClusterName); - } - return; - } - - RouteRegistry targetRegistry = targetCluster.getRouteRegistry(); - String routeName = clusterSubpath.length() > 1 ? clusterSubpath.substring(1).toUpperCase() : ""; - - // Check built-in cluster management routes - BiConsumer handler = targetRegistry.getRoutes().get(routeName); - if (handler != null) { - if (routeName.equals("GET_NODES_JSON")) { - s.setContentType("application/json"); - } else { - s.setContentType("text/plain"); - } - if (!s.isCommitted()) { - s.setStatus(200); - } - try (PrintWriter out = s.getWriter()) { - String query = r.getQuery(); - String args = query != null ? query : ""; - handler.accept(args, out); - } - return; - } - - // Layer 7 Reverse Proxy Load Balancing - if (!matchedRouteRule && targetCluster.getRoutingMode() == Cluster.RoutingMode.TELEMETRY_ONLY) { - s.setStatus(403); - try (PrintWriter out = s.getWriter()) { - out.print("403 Forbidden - Load balancing is disabled for cluster: " + targetClusterName); - } - return; - } - - List activeNodes = targetCluster.getCluster().stream() - .filter(n -> n != null && n.status() == NodeStatus.ONLINE && !n.telemetryOnly()) - .collect(Collectors.toList()); - - if (activeNodes.isEmpty()) { - s.setStatus(503); - try (PrintWriter out = s.getWriter()) { - out.print("503 Service Unavailable - No active nodes in cluster: " + targetClusterName); - } - return; - } - - // Round-Robin selection - AtomicInteger rrIdx = roundRobinIndices.computeIfAbsent(targetClusterName, k -> new AtomicInteger(0)); - int selectedIndex = (rrIdx.getAndIncrement() & Integer.MAX_VALUE) % activeNodes.size(); - ServerNode targetNode = activeNodes.get(selectedIndex); - - // Forward request - String targetUrlStr = targetNode.getFullHost() + clusterSubpath; - String query = r.getQuery(); - if (query != null && !query.isEmpty()) { - targetUrlStr += "?" + query; - } - - long startTime = System.currentTimeMillis(); - java.net.http.HttpRequest.Builder reqBuilder = java.net.http.HttpRequest.newBuilder() - .uri(java.net.URI.create(targetUrlStr)); - - int timeout = targetCluster.getTimeoutMs() > 0 ? targetCluster.getTimeoutMs() : 5000; - reqBuilder.timeout(java.time.Duration.ofMillis(timeout)); - - // Copy request headers - Map> reqHeaders = r.getHeaders(); - if (reqHeaders != null) { - for (Map.Entry> entry : reqHeaders.entrySet()) { - String hName = entry.getKey(); - if (hName == null || hName.equalsIgnoreCase("Host") || hName.equalsIgnoreCase("Content-Length") || hName.equalsIgnoreCase("Connection") || hName.equalsIgnoreCase("Upgrade")) { - continue; - } - for (String val : entry.getValue()) { - reqBuilder.header(hName, val); - } - } - } - - // Forward body if present - String method = r.getMethod(); - java.net.http.HttpRequest.BodyPublisher bodyPublisher; - boolean hasBody = "POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method); - if (hasBody) { - if (!exchange.isBlocking()) { - exchange.startBlocking(); - } - bodyPublisher = java.net.http.HttpRequest.BodyPublishers.ofInputStream(() -> { - try { - return exchange.getInputStream(); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - } else { - bodyPublisher = java.net.http.HttpRequest.BodyPublishers.noBody(); - } - reqBuilder.method(method, bodyPublisher); - - java.net.http.HttpRequest proxyRequest = reqBuilder.build(); - - int respCode = 502; - java.net.http.HttpResponse proxyResponse = null; - try { - proxyResponse = httpClient.send(proxyRequest, java.net.http.HttpResponse.BodyHandlers.ofInputStream()); - respCode = proxyResponse.statusCode(); - } catch (Exception ex) { - respCode = 502; - } - - long latencyMs = System.currentTimeMillis() - startTime; - - // Passive Telemetry extraction - Double cpuVal = null; - Double ramVal = null; - if (proxyResponse != null) { - cpuVal = parseHeaderDouble(proxyResponse.headers(), "X-Telemetry-CPU", "X-Node-CPU"); - ramVal = parseHeaderDouble(proxyResponse.headers(), "X-Telemetry-RAM", "X-Node-RAM"); - } - - targetCluster.updateTelemetryServer(targetNode.host(), targetNode.port(), cpuVal, ramVal, null, (int) latencyMs, null); - if (cpuVal != null) targetNode.setCpuUsage(cpuVal); - if (ramVal != null) targetNode.setRamUsage(ramVal); - targetNode.setLatencyMs((int) latencyMs); - - // Copy response headers to client - if (proxyResponse != null) { - for (Map.Entry> entry : proxyResponse.headers().map().entrySet()) { - String hName = entry.getKey(); - if (hName == null || hName.equalsIgnoreCase("Transfer-Encoding") || hName.equalsIgnoreCase("Content-Length") || hName.equalsIgnoreCase("Connection")) { - continue; - } - HttpString cachedHeader = headerCache.computeIfAbsent(hName, HttpString::tryFromString); - for (String val : entry.getValue()) { - exchange.getResponseHeaders().add(cachedHeader, val); - } - } - } + virtualExecutor = ThreadManager.newVirtualThreadPool(); + + builder.setHandler(new HttpHandler() { + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + String path = exchange.getRequestPath(); + String matchingPath = path.startsWith("/v1/") ? path.substring(3) : (path.equals("/v1") ? "/" : path); + + RouteResolution resolution = PathResolver.resolve(matchingPath, exchange.getRequestHeaders().getFirst(io.undertow.util.Headers.HOST), registry); + boolean canUseFastPath = resolution.isLocal() + && registry.isRouteFastPath(resolution.localRouteName()) + && (activeFilters.isEmpty() || (activeFilters.size() == 1 && activeFilters.get(0) instanceof CorsFilter)); + + if (canUseFastPath) { + processRequest(exchange, registry); + return; + } - // Send response - exchange.setStatusCode(respCode); - if (proxyResponse != null) { - if (!exchange.isBlocking()) { - exchange.startBlocking(); - } - byte[] buf = BUFFER_POOL.poll(); - if (buf == null) { - buf = new byte[8192]; + if (exchange.isInIoThread()) { + exchange.dispatch(virtualExecutor, () -> { + try { + processRequest(exchange, registry); + } catch (Exception e) { + handleError(exchange, e); } - try (InputStream in = proxyResponse.body(); - OutputStream os = exchange.getOutputStream()) { - int len; - while ((len = in.read(buf)) != -1) { - os.write(buf, 0, len); - } - os.flush(); - } finally { - BUFFER_POOL.offer(buf); - } - } else { - if (respCode == 502) { - byte[] respBytes = "502 Bad Gateway - Connection failed".getBytes(java.nio.charset.StandardCharsets.UTF_8); - exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); - if (!exchange.isBlocking()) { - exchange.startBlocking(); - } - try (OutputStream os = exchange.getOutputStream()) { - os.write(respBytes); - os.flush(); - } - } - } - - } else { - // Direct Custom Routes - final String finalLookupPath = matchingPath; - RouteHandlerInfo routeInfo = routeCache.computeIfAbsent(finalLookupPath, path -> { - String routeName = toRouteName(path); - BiConsumer handler = registry.getRoutes().get(routeName); - return new RouteHandlerInfo(handler, routeName); }); + return; + } + processRequest(exchange, registry); + } + }); - if (routeInfo.handler != null) { - if (routeInfo.routeName.equals("GET_NODES_JSON")) { - s.setContentType("application/json"); - } else { - s.setContentType("text/plain"); - } - if (!s.isCommitted()) { - s.setStatus(200); - } - try (PrintWriter out = s.getWriter()) { - String query = r.getQuery(); - String args = query != null ? query : ""; - routeInfo.handler.accept(args, out); - } - } else { - s.setStatus(404); - try (PrintWriter out = s.getWriter()) { - out.print("404 Not Found - Unknown Route: " + matchingPath); - } - } + server = builder.build(); + server.start(); + running = true; + DebugUtils.info("HTTP Transport (Undertow) successfully bound and listening on port " + port); + } catch (Exception e) { + DebugUtils.error("HTTP Transport (Undertow) failed to start on port " + port, e); + } + } + + private void processRequest(HttpServerExchange exchange, RouteRegistry registry) { + try { + UndertowHttpRequestImpl req = new UndertowHttpRequestImpl(exchange); + RouteResolution resolution = PathResolver.resolve(req.getPath(), req.getHeader("Host"), registry); + + boolean canUseFastPath = resolution.isLocal() + && registry.isRouteFastPath(resolution.localRouteName()) + && (activeFilters.isEmpty() || (activeFilters.size() == 1 && activeFilters.get(0) instanceof CorsFilter)); + + if (canUseFastPath) { + // Set CORS headers directly + exchange.getResponseHeaders().put(io.undertow.util.HttpString.tryFromString("Access-Control-Allow-Origin"), "*"); + exchange.getResponseHeaders().put(io.undertow.util.HttpString.tryFromString("Access-Control-Allow-Methods"), "GET, POST, OPTIONS, PUT, DELETE"); + exchange.getResponseHeaders().put(io.undertow.util.HttpString.tryFromString("Access-Control-Allow-Headers"), "X-Cluster-Token, Content-Type, Authorization"); + + if (io.undertow.util.Methods.OPTIONS.equals(exchange.getRequestMethod())) { + exchange.setStatusCode(204); + exchange.endExchange(); + return; + } + + BiConsumer handler = registry.getRoutes().get(resolution.localRouteName()); + if (handler != null) { + if (resolution.localRouteName().equals("/V1/GET_NODES_JSON")) { + exchange.getResponseHeaders().put(io.undertow.util.Headers.CONTENT_TYPE, "application/json"); + } else { + exchange.getResponseHeaders().put(io.undertow.util.Headers.CONTENT_TYPE, "text/plain"); } + exchange.setStatusCode(200); + + FastPrintWriter out = FAST_WRITER.get(); + out.reset(); + String query = req.getQuery(); + String args = query != null ? query : ""; + handler.accept(args, out); + + byte[] responseBytes = out.toBytes(); + exchange.getResponseHeaders().put(io.undertow.util.Headers.CONTENT_LENGTH, String.valueOf(responseBytes.length)); + exchange.getResponseSender().send(java.nio.ByteBuffer.wrap(responseBytes)); + return; + } + } + + UndertowHttpResponseImpl res = new UndertowHttpResponseImpl(exchange); + + // Inline default CorsFilter optimization + if (activeFilters.size() == 1 && activeFilters.get(0) instanceof CorsFilter) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE"); + res.setHeader("Access-Control-Allow-Headers", "X-Cluster-Token, Content-Type, Authorization"); + + if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { + res.setStatus(204); + res.flushBuffer(); + exchange.endExchange(); + return; + } + + executeRoute(req, res, resolution, registry); + res.flushBuffer(); + exchange.endExchange(); + return; + } + + BiConsumer routeHandler = (r, s) -> { + try { + executeRoute(r, s, resolution, registry); } catch (Exception e) { throw new RuntimeException(e); } }; - // 4. Run chain - if (!activeFilters.isEmpty()) { - HttpFilterChainImpl chain = new HttpFilterChainImpl(activeFilters, routeHandler); - chain.doFilter(req, res); - } else { - routeHandler.accept(req, res); - } + HttpFilterChainImpl chain = new HttpFilterChainImpl(activeFilters, routeHandler); + chain.doFilter(req, res); res.flushBuffer(); exchange.endExchange(); } catch (Exception e) { - handleError(exchange, e); + DebugUtils.error("UndertowHttpTransport: Exception caught in filter chain pipeline: " + e.getMessage(), e); + try { + UndertowHttpResponseImpl res = new UndertowHttpResponseImpl(exchange); + errorHandler.handleException(res, e); + res.flushBuffer(); + exchange.endExchange(); + } catch (Exception ignored) {} } } - private void handleError(HttpServerExchange exchange, Exception e) { - System.err.println("UndertowHttpTransport: Exception caught in pipeline: " + e.getMessage()); - e.printStackTrace(System.err); - DebugUtils.error("UndertowHttpTransport: Exception caught in pipeline: " + e.getMessage(), e); - if (!exchange.getResponseHeaders().contains(Headers.CONTENT_TYPE)) { - exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); - } - exchange.setStatusCode(500); - exchange.getResponseSender().send("500 Internal Server Error - Execution failure: " + e.getMessage()); - exchange.endExchange(); - } + private void executeRoute(HttpRequest r, HttpResponse s, RouteResolution resolution, RouteRegistry registry) throws Exception { + if (resolution.isProxy()) { + Cluster targetCluster = ClusterRegistry.getInstance().getCluster(resolution.targetClusterName()); + if (targetCluster == null) { + errorHandler.handleStatus(s, 404, "Unknown Cluster: " + resolution.targetClusterName()); + return; + } - private Double parseHeaderDouble(java.net.http.HttpHeaders headers, String... headerNames) { - for (String hName : headerNames) { - java.util.Optional valOpt = headers.firstValue(hName); - if (valOpt.isPresent()) { - String val = valOpt.get(); - if (!val.trim().isEmpty()) { - try { - return Double.parseDouble(val.replace("%", "").trim()); - } catch (NumberFormatException ignored) {} + // Check if there is an internal cluster administration route + RouteRegistry clusterRegistry = targetCluster.getRouteRegistry(); + String clusterRouteKey = resolution.resolveTargetRouteKey(); + if (clusterRegistry != null && clusterRouteKey != null && clusterRegistry.getRoutes().containsKey(clusterRouteKey)) { + BiConsumer handler = clusterRegistry.getRoutes().get(clusterRouteKey); + if (clusterRouteKey.equals("/V1/GET_NODES_JSON")) { + s.setContentType("application/json"); + } else { + s.setContentType("text/plain"); } + try (PrintWriter out = s.getWriter()) { + String query = r.getQuery(); + String args = query != null ? query : ""; + handler.accept(args, out); + } + return; + } + + reverseProxyService.proxyRequest(r, s, targetCluster, resolution.targetSubpath(), targetCluster.getTimeoutMs(), resolution.matchedRouteRule()); + + } else if (resolution.isLocal()) { + BiConsumer handler = registry.getRoutes().get(resolution.localRouteName()); + if (resolution.localRouteName().equals("/V1/GET_NODES_JSON")) { + s.setContentType("application/json"); + } else { + s.setContentType("text/plain"); + } + try (PrintWriter out = s.getWriter()) { + String query = r.getQuery(); + String args = query != null ? query : ""; + handler.accept(args, out); } + } else { + errorHandler.handleStatus(s, 404, "Unknown Route: " + r.getPath()); } - return null; + } + + private void handleError(HttpServerExchange exchange, Exception e) { + DebugUtils.error("UndertowHttpTransport: Exception caught in pipeline: " + e.getMessage(), e); + try { + UndertowHttpResponseImpl res = new UndertowHttpResponseImpl(exchange); + errorHandler.handleException(res, e); + res.flushBuffer(); + exchange.endExchange(); + } catch (Exception ignored) {} } @Override @@ -558,8 +310,10 @@ public void stop() { if (server != null) { server.stop(); running = false; - virtualExecutor.shutdown(); - DebugUtils.log("HTTP Transport (Undertow) stopped."); + if (virtualExecutor != null) { + virtualExecutor.shutdown(); + } + DebugUtils.info("HTTP Transport (Undertow) stopped."); } } @@ -568,22 +322,7 @@ public boolean isRunning() { return running; } - private static String toRouteName(String path) { - if (path == null || path.equals("/") || path.isEmpty()) { - return "/"; - } - return path.startsWith("/") ? path.substring(1).toUpperCase() : path.toUpperCase(); - } - - private static class RouteHandlerInfo { - final BiConsumer handler; - final String routeName; - - RouteHandlerInfo(BiConsumer handler, String routeName) { - this.handler = handler; - this.routeName = routeName; - } - } + private static final ThreadLocal FAST_WRITER = ThreadLocal.withInitial(FastPrintWriter::new); private static class FastPrintWriter extends java.io.PrintWriter { private static class StringBuilderWriter extends java.io.Writer { diff --git a/java/src/hexacloud/infra/server/WsTransport.java b/java/src/hexacloud/infra/server/WsTransport.java index 053e27d..4e50b1d 100644 --- a/java/src/hexacloud/infra/server/WsTransport.java +++ b/java/src/hexacloud/infra/server/WsTransport.java @@ -46,7 +46,7 @@ public class WsTransport implements ServerTransport { private volatile boolean running = false; @Override - public void listen(int port, RouteRegistry registry, hexacloud.core.cluster.Cluster cluster, List customFilters) { + public void listen(int port, RouteRegistry registry, java.util.List clusters, List customFilters) { threadPool.execute(() -> serverListen(port)); } @@ -247,7 +247,7 @@ public void stop() { } closeAllClients(); threadPool.shutdownNow(); - DebugUtils.log("WebSocket Transport stopped."); + DebugUtils.info("WebSocket Transport stopped."); } private void closeAllClients() { diff --git a/java/src/hexacloud/infra/server/filter/HttpRequestImpl.java b/java/src/hexacloud/infra/server/filter/HttpRequestImpl.java index 199090b..c4a26ee 100644 --- a/java/src/hexacloud/infra/server/filter/HttpRequestImpl.java +++ b/java/src/hexacloud/infra/server/filter/HttpRequestImpl.java @@ -39,4 +39,7 @@ public HttpRequestImpl(HttpExchange exchange) { @Override public Object getAttribute(String key) { return attributes == null ? null : attributes.get(key); } + @Override public java.io.InputStream getBody() throws Exception { + return exchange.getRequestBody(); + } } diff --git a/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java b/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java index 1777724..7bd703a 100644 --- a/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java +++ b/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java @@ -38,11 +38,19 @@ public PrintWriter getWriter() throws Exception { if (!committed) { setStatus(200); } - return new PrintWriter(new java.io.BufferedWriter(new java.io.OutputStreamWriter(exchange.getResponseBody(), java.nio.charset.StandardCharsets.UTF_8))); + return new PrintWriter(new java.io.OutputStreamWriter(exchange.getResponseBody(), java.nio.charset.StandardCharsets.UTF_8)); } @Override public boolean isCommitted() { return committed; } + + @Override + public java.io.OutputStream getOutputStream() throws Exception { + if (!committed) { + setStatus(200); + } + return exchange.getResponseBody(); + } } diff --git a/java/test/hexacloud/core/cluster/ClusterTest.java b/java/test/hexacloud/core/cluster/ClusterTest.java index b7ed94b..dce9cc5 100644 --- a/java/test/hexacloud/core/cluster/ClusterTest.java +++ b/java/test/hexacloud/core/cluster/ClusterTest.java @@ -65,7 +65,7 @@ public void testDeregisterServer() { cluster.registerServer(node); assertEquals(1, cluster.getCluster().size()); - cluster.deregisterServer("http://127.0.0.1:9000"); + cluster.deregisterServer("127.0.0.1:9000"); assertTrue(cluster.getCluster().isEmpty()); } diff --git a/java/test/hexacloud/core/config/StateOrchestrationTest.java b/java/test/hexacloud/core/config/StateOrchestrationTest.java index d46cd5f..2ae071c 100644 --- a/java/test/hexacloud/core/config/StateOrchestrationTest.java +++ b/java/test/hexacloud/core/config/StateOrchestrationTest.java @@ -59,7 +59,7 @@ public void testRemoteDeletionAndBootstrapPersistence() { assertEquals(2, cluster.getCluster().size()); // 2. User deletes node A remotely (simulated) - gateway.deregisterServer("http://localhost:7001"); + gateway.deregisterServer("node-a"); assertEquals(1, cluster.getCluster().size()); // Stops gateway diff --git a/java/test/hexacloud/core/server/route/PathResolverTest.java b/java/test/hexacloud/core/server/route/PathResolverTest.java new file mode 100644 index 0000000..12e0bb5 --- /dev/null +++ b/java/test/hexacloud/core/server/route/PathResolverTest.java @@ -0,0 +1,53 @@ +package hexacloud.core.server.route; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class PathResolverTest { + @Test + public void testCleanSlashesAndResolveLocal() { + RouteRegistry registry = new RouteRegistry(); + registry.registerController(new RouteController() { + @RouteMapping("/v1/list_clusters") + public void test(String args, java.io.PrintWriter out) {} + }); + + RouteResolution res = PathResolver.resolve("//v1//list_clusters", "localhost", registry); + assertTrue(res.isLocal()); + assertEquals("/V1/LIST_CLUSTERS", res.localRouteName()); + assertFalse(res.isProxy()); + } + + @Test + public void testResolveProxyWithVersionPrefix() { + RouteRegistry registry = new RouteRegistry(); + RouteResolution res = PathResolver.resolve("/v1/clusters/watata/get_nodes", "localhost", registry); + assertTrue(res.isProxy()); + assertEquals("watata", res.targetClusterName()); + assertEquals("/get_nodes", res.targetSubpath()); + assertEquals("/V1/GET_NODES", res.resolveTargetRouteKey()); + } + + @Test + public void testFastPathRouteRegistration() { + RouteRegistry registry = new RouteRegistry(); + registry.registerController(new RouteController() { + @RouteMapping(value = "/hello", fastPath = true) + public void test(String args, java.io.PrintWriter out) {} + }); + + RouteResolution res = PathResolver.resolve("/hello", "localhost", registry); + assertTrue(res.isLocal()); + assertTrue(registry.isRouteFastPath(res.localRouteName())); + + RouteRegistry otherRegistry = new RouteRegistry(); + otherRegistry.registerController(new RouteController() { + @RouteMapping(value = "/slow") + public void test(String args, java.io.PrintWriter out) {} + }); + + RouteResolution slowRes = PathResolver.resolve("/slow", "localhost", otherRegistry); + assertTrue(slowRes.isLocal()); + assertFalse(otherRegistry.isRouteFastPath(slowRes.localRouteName())); + } +} diff --git a/java/test/hexacloud/core/tui/TuiLogRedirectionTest.java b/java/test/hexacloud/core/tui/TuiLogRedirectionTest.java new file mode 100644 index 0000000..6c90081 --- /dev/null +++ b/java/test/hexacloud/core/tui/TuiLogRedirectionTest.java @@ -0,0 +1,27 @@ +package hexacloud.core.tui; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class TuiLogRedirectionTest { + + @Test + public void testDefaultRedirectionIsDisabled() { + TerminalUI ui = new TerminalUI("Test Display Name"); + assertFalse(ui.redirectSystemOut(), "redirectSystemOut should default to false"); + } + + @Test + public void testRedirectSystemOutConfiguration() { + TerminalUI ui = new TerminalUI("Test Display Name"); + assertFalse(ui.redirectSystemOut()); + + hexacloud.core.ports.TerminalUiPort port = ui.redirectSystemOut(true); + assertSame(ui, port); + assertTrue(ui.redirectSystemOut(), "redirectSystemOut should be updated to true when enabled"); + + ui.redirectSystemOut(false); + assertFalse(ui.redirectSystemOut(), "redirectSystemOut should be updated to false when disabled"); + } +} + diff --git a/java/test/hexacloud/core/utils/reflection/ClassScannerTest.java b/java/test/hexacloud/core/utils/reflection/ClassScannerTest.java new file mode 100644 index 0000000..d8a9330 --- /dev/null +++ b/java/test/hexacloud/core/utils/reflection/ClassScannerTest.java @@ -0,0 +1,33 @@ +package hexacloud.core.utils.reflection; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import java.util.List; +import hexacloud.core.server.route.RouteController; +import hexacloud.core.server.route.ClusterController; + +public class ClassScannerTest { + + @Test + public void testScanPackage() { + List> implementations = + ClassScanner.scanPackage("hexacloud.core.server.route", RouteController.class); + + assertNotNull(implementations); + + // Assert that we found ClusterController + boolean foundClusterController = false; + for (Class clazz : implementations) { + if (clazz.equals(ClusterController.class)) { + foundClusterController = true; + } + + // Assert that no interfaces or abstract classes are returned + assertFalse(clazz.isInterface(), "Should not return interface: " + clazz.getName()); + assertFalse(java.lang.reflect.Modifier.isAbstract(clazz.getModifiers()), + "Should not return abstract class: " + clazz.getName()); + } + + assertTrue(foundClusterController, "Should have found ClusterController in package hexacloud.core.server.route"); + } +} diff --git a/java/test/hexacloud/infra/gateway/LocalGatewayAdapterTest.java b/java/test/hexacloud/infra/gateway/LocalGatewayAdapterTest.java index c1280e6..ed13fb9 100644 --- a/java/test/hexacloud/infra/gateway/LocalGatewayAdapterTest.java +++ b/java/test/hexacloud/infra/gateway/LocalGatewayAdapterTest.java @@ -87,4 +87,58 @@ private static T readField(Object target, String fieldName, Class fieldTy field.setAccessible(true); return fieldType.cast(field.get(target)); } + + @Test + public void testCustomScanPackages() throws Exception { + // Positive case: Scan the package where our test classes reside + gateway.scanPackages("hexacloud.infra.gateway"); + gateway.createCluster("test-scan-cluster"); + gateway.listen(); + + TestScanListener.handled = false; + gateway.eventManager().dispatch(new TestCustomScanEvent()); + assertTrue(TestScanListener.handled, "Listener in scanned package should have received the event"); + + ServerManager serverManager = readField(gateway, "serverManager", ServerManager.class); + RouteRegistry routeRegistry = readField(serverManager, "routeRegistry", RouteRegistry.class); + assertTrue(routeRegistry.getRoutes().containsKey("TEST_SCAN_CMD"), "Controller in scanned package should be registered"); + + gateway.stop(); + + // Negative case: Scan a package that does NOT contain our test classes + LocalGatewayAdapter negativeGateway = (LocalGatewayAdapter) GatewayFactory.createGateway("negative-gateway"); + negativeGateway.scanPackages("hexacloud.core.ports"); + negativeGateway.createCluster("negative-cluster"); + negativeGateway.listen(); + + TestScanListener.handled = false; + negativeGateway.eventManager().dispatch(new TestCustomScanEvent()); + assertFalse(TestScanListener.handled, "Listener in unscanned package should NOT have received the event"); + + ServerManager negServerManager = readField(negativeGateway, "serverManager", ServerManager.class); + RouteRegistry negRouteRegistry = readField(negServerManager, "routeRegistry", RouteRegistry.class); + assertFalse(negRouteRegistry.getRoutes().containsKey("TEST_SCAN_CMD"), "Controller in unscanned package should NOT be registered"); + + negativeGateway.stop(); + } +} + +class TestCustomScanEvent implements hexacloud.core.event.Event {} + +class TestScanListener implements hexacloud.core.event.EventController { + public static boolean handled = false; + + @hexacloud.core.event.Subscribe + public void onEvent(TestCustomScanEvent event) { + handled = true; + } +} + +class TestScanController implements hexacloud.core.server.route.RouteController { + public TestScanController() {} + + @hexacloud.core.server.route.RouteMapping("TEST_SCAN_CMD") + public void handle(String args, java.io.PrintWriter out) { + out.println("Scanned!"); + } } diff --git a/java/test/hexacloud/infra/server/IngressRoutingTest.java b/java/test/hexacloud/infra/server/IngressRoutingTest.java index e9a0008..8a13a18 100644 --- a/java/test/hexacloud/infra/server/IngressRoutingTest.java +++ b/java/test/hexacloud/infra/server/IngressRoutingTest.java @@ -126,7 +126,7 @@ private String sendGetBody(String urlStr) throws Exception { public void testV1PrefixPeelingAndNodeFilterJdkTransport() throws Exception { RouteRegistry registry = new RouteRegistry(); jdkTransport = new HttpTransport(); - jdkTransport.listen(gatewayPort1, registry, testCluster, Collections.emptyList()); + jdkTransport.listen(gatewayPort1, registry, java.util.List.of(testCluster), Collections.emptyList()); // Test /v1/clusters/ingress-test-cluster/api URL url = URI.create("http://127.0.0.1:" + gatewayPort1 + "/v1/clusters/ingress-test-cluster/api").toURL(); @@ -147,7 +147,7 @@ public void testIngressRuleRoutingJdkTransport() throws Exception { registry.addRouteRule(new RouteRule("127.0.0.1", "/app/**", "ingress-test-cluster")); jdkTransport = new HttpTransport(); - jdkTransport.listen(gatewayPort1, registry, testCluster, Collections.emptyList()); + jdkTransport.listen(gatewayPort1, registry, java.util.List.of(testCluster), Collections.emptyList()); URL url = URI.create("http://127.0.0.1:" + gatewayPort1 + "/app/users").toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); @@ -167,7 +167,7 @@ public void testIngressRuleRoutingWithLocalhostHostAndAuthPatternJdkTransport() registry.addRouteRule(new RouteRule("localhost", "/auth/**", "ingress-test-cluster")); jdkTransport = new HttpTransport(); - jdkTransport.listen(gatewayPort1, registry, testCluster, Collections.emptyList()); + jdkTransport.listen(gatewayPort1, registry, java.util.List.of(testCluster), Collections.emptyList()); URL url = URI.create("http://localhost:" + gatewayPort1 + "/auth/a").toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); @@ -205,7 +205,7 @@ public void testIngressRuleRewritesBackendPathJdkTransport() throws Exception { registry.addRouteRule(new RouteRule("localhost", "/gateway/**", "rewrite-test-cluster", "/api")); jdkTransport = new HttpTransport(); - jdkTransport.listen(gatewayPort1, registry, testCluster, Collections.emptyList()); + jdkTransport.listen(gatewayPort1, registry, java.util.List.of(testCluster), Collections.emptyList()); assertEquals("/", sendGetBody("http://localhost:" + gatewayPort1 + "/auth/a")); assertEquals("/api/a", sendGetBody("http://localhost:" + gatewayPort1 + "/gateway/a")); @@ -238,7 +238,7 @@ public void testIngressRuleRewritesBackendPathUndertowTransport() throws Excepti registry.addRouteRule(new RouteRule("localhost", "/gateway/**", "rewrite-test-cluster-undertow", "/api")); undertowTransport = new UndertowHttpTransport(); - undertowTransport.listen(gatewayPort2, registry, testCluster, Collections.emptyList()); + undertowTransport.listen(gatewayPort2, registry, java.util.List.of(testCluster), Collections.emptyList()); assertEquals("/", sendGetBody("http://localhost:" + gatewayPort2 + "/auth/a")); assertEquals("/api/a", sendGetBody("http://localhost:" + gatewayPort2 + "/gateway/a")); @@ -270,7 +270,7 @@ public void testIngressRouteRuleBypassesTelemetryOnlyBlockUndertowTransport() th registry.addRouteRule(new RouteRule("localhost", "/auth/**", "route-rule-telemetry-only-cluster")); undertowTransport = new UndertowHttpTransport(); - undertowTransport.listen(gatewayPort2, registry, testCluster, Collections.emptyList()); + undertowTransport.listen(gatewayPort2, registry, java.util.List.of(testCluster), Collections.emptyList()); assertEquals("routed", sendGetBody("http://localhost:" + gatewayPort2 + "/auth/a")); } finally { @@ -284,7 +284,7 @@ public void testIngressRuleRoutingUndertowTransport() throws Exception { registry.addRouteRule(new RouteRule("127.0.0.1", "/app/**", "ingress-test-cluster")); undertowTransport = new UndertowHttpTransport(); - undertowTransport.listen(gatewayPort2, registry, testCluster, Collections.emptyList()); + undertowTransport.listen(gatewayPort2, registry, java.util.List.of(testCluster), Collections.emptyList()); URL url = URI.create("http://127.0.0.1:" + gatewayPort2 + "/app/users").toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); @@ -309,7 +309,7 @@ public void testTelemetryOnlyNodesExcludedJdkTransport() throws Exception { ClusterRegistry.getInstance().registerCluster(telemetryOnlyCluster); jdkTransport = new HttpTransport(); - jdkTransport.listen(gatewayPort1, registry, telemetryOnlyCluster, Collections.emptyList()); + jdkTransport.listen(gatewayPort1, registry, java.util.List.of(telemetryOnlyCluster), Collections.emptyList()); URL url = URI.create("http://127.0.0.1:" + gatewayPort1 + "/clusters/telemetry-only-cluster/data").toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); @@ -329,7 +329,7 @@ public void testTelemetryOnlyNodesExcludedUndertowTransport() throws Exception { ClusterRegistry.getInstance().registerCluster(telemetryOnlyCluster); undertowTransport = new UndertowHttpTransport(); - undertowTransport.listen(gatewayPort2, registry, telemetryOnlyCluster, Collections.emptyList()); + undertowTransport.listen(gatewayPort2, registry, java.util.List.of(telemetryOnlyCluster), Collections.emptyList()); URL url = URI.create("http://127.0.0.1:" + gatewayPort2 + "/clusters/telemetry-only-cluster-undertow/data").toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); @@ -350,7 +350,7 @@ public void testLocal(String args, PrintWriter out) { registry.addRouteRule(new RouteRule("127.0.0.1", "/**", "ingress-test-cluster")); jdkTransport = new HttpTransport(); - jdkTransport.listen(gatewayPort1, registry, testCluster, Collections.emptyList()); + jdkTransport.listen(gatewayPort1, registry, java.util.List.of(testCluster), Collections.emptyList()); URL url = URI.create("http://127.0.0.1:" + gatewayPort1 + "/test_local").toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); @@ -370,7 +370,7 @@ public void testRootDoesNotExposeGetNodesJdkTransport() throws Exception { registry.registerController(new ClusterController(testCluster)); jdkTransport = new HttpTransport(); - jdkTransport.listen(gatewayPort1, registry, testCluster, Collections.emptyList()); + jdkTransport.listen(gatewayPort1, registry, java.util.List.of(testCluster), Collections.emptyList()); URL rootUrl = URI.create("http://127.0.0.1:" + gatewayPort1 + "/").toURL(); HttpURLConnection rootConn = (HttpURLConnection) rootUrl.openConnection(); @@ -396,7 +396,7 @@ public void testRootDoesNotExposeGetNodesUndertowTransport() throws Exception { registry.registerController(new ClusterController(testCluster)); undertowTransport = new UndertowHttpTransport(); - undertowTransport.listen(gatewayPort2, registry, testCluster, Collections.emptyList()); + undertowTransport.listen(gatewayPort2, registry, java.util.List.of(testCluster), Collections.emptyList()); URL rootUrl = URI.create("http://127.0.0.1:" + gatewayPort2 + "/").toURL(); HttpURLConnection rootConn = (HttpURLConnection) rootUrl.openConnection(); @@ -428,7 +428,7 @@ public void testLocal(String args, PrintWriter out) { registry.addRouteRule(new RouteRule("127.0.0.1", "/**", "ingress-test-cluster")); undertowTransport = new UndertowHttpTransport(); - undertowTransport.listen(gatewayPort2, registry, testCluster, Collections.emptyList()); + undertowTransport.listen(gatewayPort2, registry, java.util.List.of(testCluster), Collections.emptyList()); URL url = URI.create("http://127.0.0.1:" + gatewayPort2 + "/test_local").toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); diff --git a/java/test/hexacloud/infra/server/L4ProxyTimeoutTest.java b/java/test/hexacloud/infra/server/L4ProxyTimeoutTest.java new file mode 100644 index 0000000..f431378 --- /dev/null +++ b/java/test/hexacloud/infra/server/L4ProxyTimeoutTest.java @@ -0,0 +1,65 @@ +package hexacloud.infra.server; + +import hexacloud.core.cluster.Cluster; +import hexacloud.core.model.NodeStatus; +import hexacloud.core.model.ServerNode; +import hexacloud.core.server.route.RouteRegistry; +import org.junit.jupiter.api.Test; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; + +public class L4ProxyTimeoutTest { + @Test + public void testTcpSoTimeoutTriggered() throws Exception { + int backendPort; + int proxyPort; + try (ServerSocket s1 = new ServerSocket(0); ServerSocket s2 = new ServerSocket(0)) { + backendPort = s1.getLocalPort(); + proxyPort = s2.getLocalPort(); + } + + ServerSocket backend = new ServerSocket(backendPort); + // Spin up backend that accepts but never sends data + Thread t = new Thread(() -> { + try (Socket s = backend.accept()) { + Thread.sleep(2000); + } catch (Exception ignored) {} + }); + t.start(); + + Cluster cluster = new Cluster("l4-timeout"); + ServerNode node = new ServerNode("tcp-node", "http://127.0.0.1", backendPort, NodeStatus.ONLINE, false) + .withRoutingProtocol(hexacloud.core.model.RoutingProtocol.TCP); + cluster.registerServer(node); + + TcpProxyTransport transport = new TcpProxyTransport(); + transport.setSoTimeout(500); // 500ms timeout + transport.listen(proxyPort, new RouteRegistry(), java.util.List.of(cluster), Collections.emptyList()); + + // Wait a bit for transport to start listening + long deadline = System.currentTimeMillis() + 2000; + while (!transport.isRunning() && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + assertTrue(transport.isRunning(), "Transport should be running"); + + long startTime = System.currentTimeMillis(); + try (Socket client = new Socket("127.0.0.1", proxyPort)) { + client.setSoTimeout(2000); + java.io.BufferedReader in = new java.io.BufferedReader(new java.io.InputStreamReader(client.getInputStream())); + in.readLine(); // Should timeout and throw SocketTimeoutException or throw connection closed + } catch (java.net.SocketException | SocketTimeoutException ex) { + // Success + } + + long duration = System.currentTimeMillis() - startTime; + assertTrue(duration < 1500, "Should terminate connection before 1.5s due to proxy timeout"); + + transport.stop(); + backend.close(); + t.join(); + } +} diff --git a/java/test/hexacloud/infra/server/L4RoutingTest.java b/java/test/hexacloud/infra/server/L4RoutingTest.java index aeb5cd8..c7db011 100644 --- a/java/test/hexacloud/infra/server/L4RoutingTest.java +++ b/java/test/hexacloud/infra/server/L4RoutingTest.java @@ -56,15 +56,17 @@ public void setUp() throws Exception { testCluster = new Cluster("l4-test-cluster"); testCluster.setRoutingMode(Cluster.RoutingMode.HYBRID); - ServerNode node1 = new ServerNode("node-1", "http://127.0.0.1", backendPort1, NodeStatus.ONLINE, false); - ServerNode node2 = new ServerNode("node-2", "http://127.0.0.1", backendPort2, NodeStatus.ONLINE, false); + ServerNode node1 = new ServerNode("node-1", "http://127.0.0.1", backendPort1, NodeStatus.ONLINE, false) + .withRoutingProtocol(hexacloud.core.model.RoutingProtocol.TCP); + ServerNode node2 = new ServerNode("node-2", "http://127.0.0.1", backendPort2, NodeStatus.ONLINE, false) + .withRoutingProtocol(hexacloud.core.model.RoutingProtocol.TCP); testCluster.registerServer(node1); testCluster.registerServer(node2); // Start TcpProxyTransport transport = new TcpProxyTransport(); - transport.listen(proxyPort, new RouteRegistry(), testCluster, new ArrayList<>()); + transport.listen(proxyPort, new RouteRegistry(), java.util.List.of(testCluster), new ArrayList<>()); waitUntilRunning(transport); } @@ -131,7 +133,7 @@ public void testL4Proxy() throws Exception { public void testNoActiveNodes() throws Exception { // Set all nodes offline for (ServerNode node : testCluster.getCluster()) { - testCluster.updateStatusServer(node.getFullHost(), NodeStatus.OFFLINE); + testCluster.updateStatusServer(node.getId(), NodeStatus.OFFLINE); } // Try connecting to proxy @@ -160,6 +162,50 @@ public void testServerManagerIntegration() throws Exception { manager.stop(); } + @Test + public void testBoundedBufferPool() throws Exception { + java.lang.reflect.Field poolField = TcpProxyTransport.class.getDeclaredField("BUFFER_POOL"); + poolField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.concurrent.ConcurrentLinkedQueue pool = + (java.util.concurrent.ConcurrentLinkedQueue) poolField.get(null); + + java.lang.reflect.Field sizeField = TcpProxyTransport.class.getDeclaredField("POOL_SIZE"); + sizeField.setAccessible(true); + java.util.concurrent.atomic.AtomicInteger poolSize = (java.util.concurrent.atomic.AtomicInteger) sizeField.get(null); + + try { + pool.clear(); + + java.lang.reflect.Field maxField = TcpProxyTransport.class.getDeclaredField("MAX_POOL_SIZE"); + maxField.setAccessible(true); + int maxPoolSize = maxField.getInt(null); + assertEquals(512, maxPoolSize, "MAX_POOL_SIZE must be 512"); + + java.lang.reflect.Method tunnelMethod = TcpProxyTransport.class.getDeclaredMethod( + "tunnel", + java.io.InputStream.class, + java.io.OutputStream.class, + Socket.class, + Socket.class + ); + tunnelMethod.setAccessible(true); + + pool.clear(); + poolSize.set(512); + + java.io.ByteArrayInputStream in = new java.io.ByteArrayInputStream(new byte[0]); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + tunnelMethod.invoke(transport, in, out, null, null); + + assertEquals(0, pool.size(), "BUFFER_POOL size should remain 0"); + assertEquals(512, poolSize.get(), "POOL_SIZE should remain 512"); + } finally { + poolSize.set(0); + pool.clear(); + } + } + private String sendTcpMessage(String host, int port, String message) throws Exception { try (Socket socket = new Socket(host, port)) { socket.setSoTimeout(3000); diff --git a/java/test/hexacloud/infra/server/L7RoutingTest.java b/java/test/hexacloud/infra/server/L7RoutingTest.java index c34ba2e..bb8e068 100644 --- a/java/test/hexacloud/infra/server/L7RoutingTest.java +++ b/java/test/hexacloud/infra/server/L7RoutingTest.java @@ -106,7 +106,7 @@ public void handle(HttpExchange exchange) throws IOException { }); transport = new HttpTransport(); - transport.listen(gatewayPort, registry, testCluster, new ArrayList<>()); + transport.listen(gatewayPort, registry, java.util.List.of(testCluster), new ArrayList<>()); waitUntilRunning(transport); } @@ -196,7 +196,7 @@ public void testForwardingHeadersAndBody() throws Exception { public void testNoActiveNodesServiceUnavailable() throws Exception { // Set all nodes offline for (ServerNode node : testCluster.getCluster()) { - testCluster.updateStatusServer(node.getFullHost(), NodeStatus.OFFLINE); + testCluster.updateStatusServer(node.getId(), NodeStatus.OFFLINE); } String urlStr = "http://127.0.0.1:" + gatewayPort + "/clusters/l7-test-cluster/api/data"; diff --git a/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java b/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java new file mode 100644 index 0000000..6c96cde --- /dev/null +++ b/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java @@ -0,0 +1,177 @@ +package hexacloud.infra.server; + +import hexacloud.core.cluster.Cluster; +import hexacloud.core.model.NodeStatus; +import hexacloud.core.model.ServerNode; +import hexacloud.core.ports.GatewayBuilderPort; +import hexacloud.core.ports.RunningGatewayPort; +import hexacloud.core.server.HttpEngine; +import hexacloud.core.server.ServerTransport; +import hexacloud.core.server.route.RouteRegistry; +import hexacloud.infra.gateway.GatewayFactory; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +public class L7TraceabilityHeadersTest { + + private int findFreePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } + } + + private void runTraceabilityTest(ServerTransport transport, boolean sendExistingXff) throws Exception { + AtomicReference clientIpHeader = new AtomicReference<>(); + AtomicReference hostHeader = new AtomicReference<>(); + AtomicReference protoHeader = new AtomicReference<>(); + + ServerSocket backendSocket = new ServerSocket(0); + int backendPort = backendSocket.getLocalPort(); + int gatewayPort = findFreePort(); + + Thread t = new Thread(() -> { + try (Socket s = backendSocket.accept(); + BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream())); + OutputStream out = s.getOutputStream()) { + String line; + while ((line = in.readLine()) != null && !line.isEmpty()) { + if (line.startsWith("X-Forwarded-For: ")) clientIpHeader.set(line.substring(17)); + if (line.startsWith("X-Forwarded-Host: ")) hostHeader.set(line.substring(18)); + if (line.startsWith("X-Forwarded-Proto: ")) protoHeader.set(line.substring(19)); + } + out.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK".getBytes()); + out.flush(); + } catch (Exception ignored) {} + }); + t.start(); + + Cluster cluster = new Cluster("trace-cluster-" + System.nanoTime()); + cluster.setRequireToken(false); + cluster.setRoutingMode(Cluster.RoutingMode.HYBRID); + ServerNode node = new ServerNode("trace-node", "http://127.0.0.1", backendPort, NodeStatus.ONLINE, false); + cluster.registerServer(node); + + transport.listen(gatewayPort, new RouteRegistry(), List.of(cluster), Collections.emptyList()); + + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + gatewayPort + "/clusters/" + cluster.getClusterName() + "/")); + + if (sendExistingXff) { + reqBuilder.header("X-Forwarded-For", "1.2.3.4"); + } + + HttpResponse resp = client.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + + t.join(3000); + + assertNotNull(clientIpHeader.get(), "X-Forwarded-For header should not be null"); + if (sendExistingXff) { + assertEquals("1.2.3.4, 127.0.0.1", clientIpHeader.get()); + } else { + assertEquals("127.0.0.1", clientIpHeader.get()); + } + assertEquals("127.0.0.1:" + gatewayPort, hostHeader.get()); + assertEquals("http", protoHeader.get()); + } finally { + transport.stop(); + backendSocket.close(); + } + } + + @Test + public void testHttpTransportTraceabilityHeaders() throws Exception { + runTraceabilityTest(new HttpTransport(), false); + } + + @Test + public void testUndertowHttpTransportTraceabilityHeaders() throws Exception { + runTraceabilityTest(new UndertowHttpTransport(), false); + } + + @Test + public void testUndertowHttpTransportWithGatewayEngine() throws Exception { + ServerSocket backendSocket = new ServerSocket(0); + int backendPort = backendSocket.getLocalPort(); + int basePort = findFreePort(); + int httpPort = basePort + 1; + + AtomicReference clientIpHeader = new AtomicReference<>(); + AtomicReference hostHeader = new AtomicReference<>(); + AtomicReference protoHeader = new AtomicReference<>(); + + Thread t = new Thread(() -> { + try (Socket s = backendSocket.accept(); + BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream())); + OutputStream out = s.getOutputStream()) { + String line; + while ((line = in.readLine()) != null && !line.isEmpty()) { + if (line.startsWith("X-Forwarded-For: ")) clientIpHeader.set(line.substring(17)); + if (line.startsWith("X-Forwarded-Host: ")) hostHeader.set(line.substring(18)); + if (line.startsWith("X-Forwarded-Proto: ")) protoHeader.set(line.substring(19)); + } + out.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK".getBytes()); + out.flush(); + } catch (Exception ignored) {} + }); + t.start(); + + String clusterName = "gw-trace-cluster-" + System.nanoTime(); + GatewayBuilderPort gatewayBuilder = GatewayFactory.createGateway("trace-gateway-" + System.nanoTime()); + gatewayBuilder.createCluster(clusterName); + gatewayBuilder.getCluster().setRequireToken(false); + gatewayBuilder.getCluster().setRoutingMode(Cluster.RoutingMode.HYBRID); + gatewayBuilder.registerServer(new ServerNode("trace-node", "http://127.0.0.1", backendPort, NodeStatus.ONLINE, false)); + gatewayBuilder.httpEngine(HttpEngine.UNDERTOW) + .enableHttp(true); + + RunningGatewayPort gateway = gatewayBuilder.listen(basePort); + + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + httpPort + "/clusters/" + clusterName + "/")) + .build(); + + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + + t.join(3000); + + assertNotNull(clientIpHeader.get(), "X-Forwarded-For header should not be null"); + assertEquals("127.0.0.1", clientIpHeader.get()); + assertEquals("127.0.0.1:" + httpPort, hostHeader.get()); + assertEquals("http", protoHeader.get()); + } finally { + gateway.stop(); + backendSocket.close(); + } + } + + @Test + public void testXForwardedForConcatenationHttpTransport() throws Exception { + runTraceabilityTest(new HttpTransport(), true); + } + + @Test + public void testXForwardedForConcatenationUndertowHttpTransport() throws Exception { + runTraceabilityTest(new UndertowHttpTransport(), true); + } +} diff --git a/java/test/hexacloud/infra/server/WsTransportTest.java b/java/test/hexacloud/infra/server/WsTransportTest.java index 587ced1..b80f785 100644 --- a/java/test/hexacloud/infra/server/WsTransportTest.java +++ b/java/test/hexacloud/infra/server/WsTransportTest.java @@ -25,7 +25,7 @@ public class WsTransportTest { public void testWebSocketHandshakeAndEventStream() throws Exception { int port = findFreePort(); WsTransport transport = new WsTransport(); - transport.listen(port, new RouteRegistry(), new Cluster("ws-test-cluster"), new java.util.ArrayList<>()); + transport.listen(port, new RouteRegistry(), java.util.List.of(new Cluster("ws-test-cluster")), new java.util.ArrayList<>()); waitUntilRunning(transport); @@ -49,7 +49,7 @@ public void testWebSocketHandshakeAndEventStream() throws Exception { String connectedFrame = readTextFrame(in); assertTrue(connectedFrame.contains("\"type\":\"Connected\"")); - EventBusManager.getGlobal().dispatch(new ClusterEvent.NodeTelemetryUpdated("http://127.0.0.1:7001")); + EventBusManager.getGlobal().dispatch(new ClusterEvent.NodeTelemetryUpdated("http://127.0.0.1:7001", "http://127.0.0.1:7001")); String eventFrame = readTextFrame(in); assertTrue(eventFrame.contains("\"type\":\"NodeTelemetryUpdated\"")); assertTrue(eventFrame.contains("http://127.0.0.1:7001")); diff --git a/pom.xml b/pom.xml index 9c5aaf8..f0ed683 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.hexacloud gatebridge-core - 1.4.8-release + 1.4.9-release jar GateBridge Core Framework