From 2ce67ec395ad6c12819f0f20f8aa681619442342 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Sat, 25 Jul 2026 15:08:41 -0300 Subject: [PATCH 01/43] feat: enhance NodeStatusChanged event to include node ID and update related methods --- .../core/cluster/event/ClusterEvent.java | 28 +++++++++++++------ java/src/hexacloud/core/cluster/Cluster.java | 12 ++++---- .../core/cluster/ClusterManager.java | 8 +++--- .../core/cluster/ClusterService.java | 4 +-- .../core/cluster/event/ClusterEvent.java | 4 +-- .../core/model/NodeUpdateResult.java | 2 +- java/src/hexacloud/core/model/ServerNode.java | 6 ++++ .../infra/network/ThreadPingScheduler.java | 5 ++-- .../hexacloud/infra/server/HttpTransport.java | 4 ++- .../infra/server/WsTransportTest.java | 2 +- 10 files changed, 48 insertions(+), 27 deletions(-) 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/hexacloud/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index 539d7d5..de1fff0 100644 --- a/java/src/hexacloud/core/cluster/Cluster.java +++ b/java/src/hexacloud/core/cluster/Cluster.java @@ -245,15 +245,15 @@ public List getCluster() { lock.unlock(); } } - - public void updateStatusServer(String host, NodeStatus status) { + // this method ok + public void updateStatusServer(String id, NodeStatus status) { 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."); + if (!this.cluster.containsKey(id)) { + DebugUtils.error(this.clusterName, id, "Cannot update status: Server host '" + id + "' is not registered in the cluster."); return; } - this.cluster.computeIfPresent(host, (key, serverNode) -> serverNode.withStatus(status)); + this.cluster.computeIfPresent(id, (key, serverNode) -> serverNode.withStatus(status)); } finally { lock.unlock(); } @@ -284,7 +284,7 @@ public NodeUpdateResult updateTelemetryServer(String host, int port, Double cpuU 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(); } diff --git a/java/src/hexacloud/core/cluster/ClusterManager.java b/java/src/hexacloud/core/cluster/ClusterManager.java index d7f3d76..a5f74f5 100644 --- a/java/src/hexacloud/core/cluster/ClusterManager.java +++ b/java/src/hexacloud/core/cluster/ClusterManager.java @@ -80,16 +80,16 @@ public ClusterManager listClusterNodes() { return this; } - @Override + @Override // this method ok public void onClusterEvent(ClusterEvent event) { Casts.as(event, NodeStatusChanged.class).ifPresent(statusEvent -> { 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()); + 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/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/ServerNode.java b/java/src/hexacloud/core/model/ServerNode.java index 4d6cbbb..cc84777 100644 --- a/java/src/hexacloud/core/model/ServerNode.java +++ b/java/src/hexacloud/core/model/ServerNode.java @@ -5,6 +5,7 @@ * Contains connection coordinates, status metadata, and health-check configurations. */ public class ServerNode { + private final String id; private final String name; private final String host; private final int port; @@ -38,6 +39,7 @@ public ServerNode(String name, String host, int port, NodeStatus status, boolean this.pingHeaderValue = pingHeaderValue; this.isDynamic = isDynamic; this.telemetryOnly = telemetryOnly; + this.id = name; } /** @@ -249,6 +251,10 @@ public String getHostWithoutProtocol() { return host.replaceAll("^[a-zA-Z]+://", ""); } + public String getId() { + return id; + } + @Override public String toString() { return "ServerNode{" + diff --git a/java/src/hexacloud/infra/network/ThreadPingScheduler.java b/java/src/hexacloud/infra/network/ThreadPingScheduler.java index 8307dc6..fb3e90b 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())); + System.out.println("Node " + node.getFullHost() + " " + 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/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index da80b17..b18df50 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -221,7 +221,8 @@ public void handle(HttpExchange exchange) throws IOException { } } } - + //debug... + System.out.println(targetClusterName); if (targetClusterName != null) { Cluster targetCluster = ClusterRegistry.getInstance().getCluster(targetClusterName); if (targetCluster == null) { @@ -264,6 +265,7 @@ public void handle(HttpExchange exchange) throws IOException { } List activeNodes = targetCluster.getCluster().stream() + .peek(node -> System.out.println("Node: " + node)) .filter(n -> n != null && n.status() == NodeStatus.ONLINE && !n.telemetryOnly()) .collect(Collectors.toList()); diff --git a/java/test/hexacloud/infra/server/WsTransportTest.java b/java/test/hexacloud/infra/server/WsTransportTest.java index 587ced1..5a19066 100644 --- a/java/test/hexacloud/infra/server/WsTransportTest.java +++ b/java/test/hexacloud/infra/server/WsTransportTest.java @@ -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")); From 9c780d9d0e5fa2cf0fdd6ef814422d0e093c4d8c Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Sat, 25 Jul 2026 22:40:49 -0300 Subject: [PATCH 02/43] docs: remove obsolete plans and documentation files docs: remove debug logs from ServerManager and RouteRule refactor: simplify RouteRule constructor logging --- .../plans/2026-07-21-apply-optimizations.md | 35 -------------- .../2026-07-21-benchmark-infrastructure.md | 46 ------------------- .../plans/2026-07-21-undertow-transport.md | 40 ---------------- .../hexacloud/core/server/ServerManager.java | 1 - .../core/server/route/RouteRule.java | 2 +- 5 files changed, 1 insertion(+), 123 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-21-apply-optimizations.md delete mode 100644 docs/superpowers/plans/2026-07-21-benchmark-infrastructure.md delete mode 100644 docs/superpowers/plans/2026-07-21-undertow-transport.md 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/java/src/hexacloud/core/server/ServerManager.java b/java/src/hexacloud/core/server/ServerManager.java index 17a56d2..cd3c130 100644 --- a/java/src/hexacloud/core/server/ServerManager.java +++ b/java/src/hexacloud/core/server/ServerManager.java @@ -249,7 +249,6 @@ public ServerManager registerRouteController(hexacloud.core.server.route.RouteCo } public void addRouteRule(RouteRule rule) { - DebugUtils.info("new route rule: " + rule); if (rule == null) { return; } 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() { From 30dccd841265465e9054bf59d7ccf7a7837473a6 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Mon, 27 Jul 2026 08:09:23 -0300 Subject: [PATCH 03/43] fix: resolve ServerNode state not updating on status events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cluster map uses node.getFullHost() as its key (e.g. 'http://localhost:8080'), but ClusterManager.onClusterEvent() was calling updateStatusServer() with statusEvent.nodeId() — which holds the node name — not the map key. Since containsKey(nodeId) always returned false, the in-memory state of the server was never updated after status-change events (ONLINE, UNSTABLE, etc). Fix: use statusEvent.host() as the lookup key, which is set to node.getFullHost() by the dispatcher (ThreadPingScheduler and ClusterService). --- java/src/hexacloud/core/cluster/ClusterManager.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/java/src/hexacloud/core/cluster/ClusterManager.java b/java/src/hexacloud/core/cluster/ClusterManager.java index a5f74f5..2db1f2c 100644 --- a/java/src/hexacloud/core/cluster/ClusterManager.java +++ b/java/src/hexacloud/core/cluster/ClusterManager.java @@ -80,7 +80,7 @@ public ClusterManager listClusterNodes() { return this; } - @Override // this method ok + @Override public void onClusterEvent(ClusterEvent event) { Casts.as(event, NodeStatusChanged.class).ifPresent(statusEvent -> { if (statusEvent != null) { @@ -89,7 +89,9 @@ public void onClusterEvent(ClusterEvent event) { statusEvent.nodeId(), "Node status changed: " + statusEvent.nodeId() + " -> " + statusEvent.status() ); - this.cluster.updateStatusServer(statusEvent.nodeId(), statusEvent.status()); + // statusEvent.host() == node.getFullHost(), which is the actual map key. + // statusEvent.nodeId() is the node name and must NOT be used as a lookup key. + this.cluster.updateStatusServer(statusEvent.host(), statusEvent.status()); } }); } From 7a770767a485e27984cddcce0721ae64d8e6a539 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Mon, 27 Jul 2026 08:16:04 -0300 Subject: [PATCH 04/43] refactor: migrate cluster map key from fullHost to nodeId Previously the ConcurrentHashMap used node.getFullHost() (e.g. 'http://localhost:8080') as its key. This caused NodeStatusChanged events to silently fail state updates because the event carried nodeId (the node name), not the fullHost. This commit migrates the key to node.getId() (the stable, immutable node name) across all layers: - Cluster.java: addClusterNode, validServer, updateStatusServer, updateServerNode, removeClusterNode, toggleAllServers, findNodeKey, endBootstrapPhase, registerServer, registerLoadedServer, staticNodes/persistedStaticNodes sets. - LocalFilePersistenceAdapter.java: saveClusterState uses node.getId() as the persistence key; loadClusterStateProperties duplicate-guard uses node.getId(). - ClusterManager.java: onClusterEvent now correctly passes statusEvent.nodeId() (the stable identity) to updateStatusServer. - TuiKeyHandler.java: deregisterServer uses node.getId(). - Tests updated: ClusterTest, L7RoutingTest, L4RoutingTest, StateOrchestrationTest all updated to use nodeId-based API. All 94 tests pass. --- java/src/hexacloud/core/cluster/Cluster.java | 53 +++++++++---------- .../core/cluster/ClusterManager.java | 9 ++-- .../config/LocalFilePersistenceAdapter.java | 4 +- .../src/hexacloud/core/tui/TuiKeyHandler.java | 2 +- .../hexacloud/core/cluster/ClusterTest.java | 2 +- .../core/config/StateOrchestrationTest.java | 2 +- .../hexacloud/infra/server/L4RoutingTest.java | 2 +- .../hexacloud/infra/server/L7RoutingTest.java | 2 +- 8 files changed, 37 insertions(+), 39 deletions(-) diff --git a/java/src/hexacloud/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index de1fff0..d2271a5 100644 --- a/java/src/hexacloud/core/cluster/Cluster.java +++ b/java/src/hexacloud/core/cluster/Cluster.java @@ -96,12 +96,12 @@ public void registerServer(ServerNode node) { 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 } } @@ -127,10 +127,9 @@ 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( @@ -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(); @@ -246,14 +245,14 @@ public List getCluster() { } } // this method ok - public void updateStatusServer(String id, NodeStatus status) { + public void updateStatusServer(String nodeId, NodeStatus status) { lock.lock(); try { - if (!this.cluster.containsKey(id)) { - DebugUtils.error(this.clusterName, id, "Cannot update status: Server host '" + id + "' is not registered in the cluster."); + 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(id, (key, serverNode) -> serverNode.withStatus(status)); + this.cluster.computeIfPresent(nodeId, (key, serverNode) -> serverNode.withStatus(status)); } finally { lock.unlock(); } @@ -297,7 +296,7 @@ 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); @@ -333,7 +332,7 @@ private void toggleAllServers(boolean start) { if (start) { registerServer(node); } else { - deregisterServer(node.getFullHost()); + deregisterServer(node.getId()); } } } @@ -372,7 +371,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 +398,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 +439,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 +453,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 2db1f2c..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; } @@ -89,9 +89,8 @@ public void onClusterEvent(ClusterEvent event) { statusEvent.nodeId(), "Node status changed: " + statusEvent.nodeId() + " -> " + statusEvent.status() ); - // statusEvent.host() == node.getFullHost(), which is the actual map key. - // statusEvent.nodeId() is the node name and must NOT be used as a lookup key. - 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/config/LocalFilePersistenceAdapter.java b/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java index d835bbd..be96bc2 100644 --- a/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java +++ b/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java @@ -87,7 +87,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 + " ==="); @@ -269,7 +269,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/tui/TuiKeyHandler.java b/java/src/hexacloud/core/tui/TuiKeyHandler.java index 03a98a5..f4cc206 100644 --- a/java/src/hexacloud/core/tui/TuiKeyHandler.java +++ b/java/src/hexacloud/core/tui/TuiKeyHandler.java @@ -223,7 +223,7 @@ private void deregisterSelectedNode() { Cluster c = ClusterRegistry.getInstance().getCluster(state.selectedClusterName); if (c != null && !state.nodes.isEmpty()) { ServerNode node = state.nodes.get(state.selectedNodeIndex); - c.deregisterServer(node.getFullHost()); + c.deregisterServer(node.getId()); tui.fetchNodeStatus(); state.selectedNodeIndex = 0; } 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/infra/server/L4RoutingTest.java b/java/test/hexacloud/infra/server/L4RoutingTest.java index aeb5cd8..51cb5a4 100644 --- a/java/test/hexacloud/infra/server/L4RoutingTest.java +++ b/java/test/hexacloud/infra/server/L4RoutingTest.java @@ -131,7 +131,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 diff --git a/java/test/hexacloud/infra/server/L7RoutingTest.java b/java/test/hexacloud/infra/server/L7RoutingTest.java index c34ba2e..8c73049 100644 --- a/java/test/hexacloud/infra/server/L7RoutingTest.java +++ b/java/test/hexacloud/infra/server/L7RoutingTest.java @@ -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"; From 434a552e118db99673630e70b2a6740c20361633 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Mon, 27 Jul 2026 08:18:51 -0300 Subject: [PATCH 05/43] refactor(Cluster.java): remove unused fullHost variable --- java/src/hexacloud/core/cluster/Cluster.java | 1 - 1 file changed, 1 deletion(-) diff --git a/java/src/hexacloud/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index d2271a5..6a3ca73 100644 --- a/java/src/hexacloud/core/cluster/Cluster.java +++ b/java/src/hexacloud/core/cluster/Cluster.java @@ -94,7 +94,6 @@ public void registerServer(ServerNode node) { String host = validHost(node.host()); if (host == null) return; - String fullHost = host + ":" + node.port(); if (bootstrapMode) { registeredStaticNodesThisRun.add(node.getId()); staticNodes.add(node.getId()); From ca81e8768ca7f9676cebc5b436f97866f63ac941 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Mon, 27 Jul 2026 18:10:36 -0300 Subject: [PATCH 06/43] =?UTF-8?q?feat:=20add=20RoutingProtocol=20to=20Serv?= =?UTF-8?q?erNode=20=E2=80=94=20separate=20routing=20from=20ping=20protoco?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each ServerNode now declares its routing protocol (HTTP, TCP, GRPC) independently of its PingProtocol. Default is HTTP for full backward compatibility. Fluent builder: gateway.registerNode(...).routingProtocol(TCP).register() --- .../hexacloud/core/model/RoutingProtocol.java | 15 +++++ java/src/hexacloud/core/model/ServerNode.java | 61 +++++++++++++++---- .../hexacloud/core/ports/NodeBuilderPort.java | 6 ++ .../hexacloud/infra/gateway/NodeBuilder.java | 9 ++- 4 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 java/src/hexacloud/core/model/RoutingProtocol.java 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 cc84777..4ff77f4 100644 --- a/java/src/hexacloud/core/model/ServerNode.java +++ b/java/src/hexacloud/core/model/ServerNode.java @@ -2,7 +2,8 @@ /** * 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; @@ -17,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; @@ -24,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; @@ -39,15 +42,24 @@ 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; } + /** + * 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); + } + /** * Constructor including node name and isDynamic flag. */ 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); } /** @@ -55,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); } /** @@ -63,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); } /** @@ -76,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); } /** @@ -200,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); @@ -215,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); @@ -228,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); @@ -263,6 +297,7 @@ public String toString() { ", status=" + status + ", isExternal=" + isExternal + ", pingProtocol=" + pingProtocol + + ", routingProtocol=" + routingProtocol + ", pingPath='" + pingPath + '\'' + '}'; } 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/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; } From 18d31c727c1421317b9820e4b8124199ac77ed4b Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Mon, 27 Jul 2026 18:20:14 -0300 Subject: [PATCH 07/43] feat: multi-cluster ServerManager + RoutingProtocol-aware transport routing - ServerTransport.listen() now receives List instead of single Cluster - ServerManager holds List, backward-compatible single-Cluster constructors preserved - HttpTransport + UndertowHttpTransport: filter chain built per cluster; HTTP nodes only route traffic if routingProtocol == HTTP or GRPC - TcpProxyTransport: routes to nodes across all clusters where routingProtocol == TCP - Cluster.registerServer/registerLoadedServer: preserve routingProtocol on node copy - Tests updated: transport.listen() calls wrapped with List.of(), L4 test nodes declared as RoutingProtocol.TCP --- java/src/hexacloud/core/cluster/Cluster.java | 4 +- .../hexacloud/core/server/ServerManager.java | 49 ++++++++++++------- .../core/server/ServerTransport.java | 5 +- .../infra/gateway/LocalGatewayAdapter.java | 2 +- .../hexacloud/infra/server/HttpTransport.java | 32 ++++++------ .../infra/server/TcpProxyTransport.java | 39 ++++++++------- .../infra/server/TelnetTransport.java | 5 +- .../infra/server/UndertowHttpTransport.java | 38 +++++++------- .../hexacloud/infra/server/WsTransport.java | 2 +- .../infra/server/IngressRoutingTest.java | 26 +++++----- .../hexacloud/infra/server/L4RoutingTest.java | 8 +-- .../hexacloud/infra/server/L7RoutingTest.java | 2 +- .../infra/server/WsTransportTest.java | 2 +- 13 files changed, 119 insertions(+), 95 deletions(-) diff --git a/java/src/hexacloud/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index 6a3ca73..04640e9 100644 --- a/java/src/hexacloud/core/cluster/Cluster.java +++ b/java/src/hexacloud/core/cluster/Cluster.java @@ -111,7 +111,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 { @@ -133,7 +133,7 @@ public void registerLoadedServer(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 { diff --git a/java/src/hexacloud/core/server/ServerManager.java b/java/src/hexacloud/core/server/ServerManager.java index cd3c130..c4d5042 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<>(); @@ -36,16 +36,31 @@ public class ServerManager implements ServerOperations { private hexacloud.core.server.PerformanceProfile performanceProfile = hexacloud.core.server.PerformanceProfile.STANDARD; private hexacloud.core.ports.SslContextPort sslContextPort; - 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(); } + /** + * 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; + } + private void autoRegisterControllers() { try { List> controllers = hexacloud.core.utils.common.PathUtils.scanClasspathForImplementations(hexacloud.core.server.route.RouteController.class); @@ -56,11 +71,12 @@ private void autoRegisterControllers() { try { hexacloud.core.server.route.RouteController controller = null; + Cluster firstCluster = clusters.isEmpty() ? null : clusters.get(0); try { - if (cluster != null) { + if (firstCluster != null) { java.lang.reflect.Constructor ctor = clazz.getDeclaredConstructor(Cluster.class); ctor.setAccessible(true); - controller = (hexacloud.core.server.route.RouteController) ctor.newInstance(cluster); + controller = (hexacloud.core.server.route.RouteController) ctor.newInstance(firstCluster); } } catch (NoSuchMethodException e) { java.lang.reflect.Constructor ctor = clazz.getDeclaredConstructor(); @@ -70,8 +86,8 @@ private void autoRegisterControllers() { if (controller != null) { this.routeRegistry.registerController(controller); - if (this.cluster != null) { - this.cluster.getRouteRegistry().registerController(controller); + for (Cluster c : this.clusters) { + c.getRouteRegistry().registerController(controller); } DebugUtils.log("RouteScanner: Auto-discovered and registered controller: " + clazz.getName()); } @@ -84,11 +100,6 @@ private void autoRegisterControllers() { } } - 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")); @@ -175,7 +186,7 @@ public ServerManager listen(int port) { if(telnetEnabled) { ServerTransport telnet = new TelnetTransport(); - telnet.listen(port, routeRegistry, cluster, customFilters); + telnet.listen(port, routeRegistry, clusters, customFilters); activeTransports.add(telnet); } @@ -192,21 +203,21 @@ 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(); // TCP Proxy runs on port + 3 - tcpProxy.listen(port + 3, routeRegistry, cluster, customFilters); + tcpProxy.listen(port + 3, routeRegistry, clusters, customFilters); activeTransports.add(tcpProxy); } @@ -242,8 +253,8 @@ 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; } 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/infra/gateway/LocalGatewayAdapter.java b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java index 7d2d68d..bda28ee 100644 --- a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java +++ b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java @@ -164,7 +164,7 @@ 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); diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index b18df50..61edce3 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -58,18 +58,20 @@ public class HttpTransport implements ServerTransport { 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)); + 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,9 +96,9 @@ 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); + rebuildFilters(clusters, customFilters); DebugUtils.log("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( @@ -266,7 +268,9 @@ public void handle(HttpExchange exchange) throws IOException { List activeNodes = targetCluster.getCluster().stream() .peek(node -> System.out.println("Node: " + node)) - .filter(n -> n != null && n.status() == NodeStatus.ONLINE && !n.telemetryOnly()) + .filter(n -> n != null && n.status() == NodeStatus.ONLINE && !n.telemetryOnly() + && (n.routingProtocol() == hexacloud.core.model.RoutingProtocol.HTTP + || n.routingProtocol() == hexacloud.core.model.RoutingProtocol.GRPC)) .collect(Collectors.toList()); if (activeNodes.isEmpty()) { diff --git a/java/src/hexacloud/infra/server/TcpProxyTransport.java b/java/src/hexacloud/infra/server/TcpProxyTransport.java index 2fd1bdf..3a5cbe0 100644 --- a/java/src/hexacloud/infra/server/TcpProxyTransport.java +++ b/java/src/hexacloud/infra/server/TcpProxyTransport.java @@ -45,11 +45,11 @@ private void configureSocket(Socket socket) { } @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) { + private void serverListen(int port, List clusters) { DebugUtils.log("TcpProxyTransport starting to listen on port " + port); try { serverSocket = new ServerSocket(port); @@ -67,12 +67,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 +85,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.log("TcpProxyTransport: No active TCP nodes available."); closeQuietly(clientSocket); return; } @@ -113,15 +111,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); diff --git a/java/src/hexacloud/infra/server/TelnetTransport.java b/java/src/hexacloud/infra/server/TelnetTransport.java index 6ce72bc..75b9c4c 100644 --- a/java/src/hexacloud/infra/server/TelnetTransport.java +++ b/java/src/hexacloud/infra/server/TelnetTransport.java @@ -28,8 +28,9 @@ 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) { diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index fa27033..6e51e7a 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -58,19 +58,22 @@ public class UndertowHttpTransport implements ServerTransport { private hexacloud.core.server.PerformanceProfile performanceProfile = hexacloud.core.server.PerformanceProfile.STANDARD; private final List activeFilters = new CopyOnWriteArrayList<>(); private hexacloud.core.ports.SslContextPort sslContextPort; + private List activeClusters = new java.util.ArrayList<>(); - 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)); + 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,9 +98,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); + this.activeClusters = clusters != null ? clusters : new java.util.ArrayList<>(); + rebuildFilters(clusters, customFilters); // Configure Default ByteBuffer Pool to avoid pool starvation under high concurrency io.undertow.connector.ByteBufferPool bufferPool = new io.undertow.server.DefaultByteBufferPool( true, @@ -167,7 +171,7 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { }); } if (fastRouteInfo.handler != null) { - processRequest(exchange, registry, cluster, customFilters); + processRequest(exchange, registry, activeClusters, customFilters); return; } } @@ -176,14 +180,14 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { java.util.concurrent.Executor executor = exchange.getConnection().getWorker(); exchange.dispatch(executor, () -> { try { - processRequest(exchange, registry, cluster, customFilters); + processRequest(exchange, registry, activeClusters, customFilters); } catch (Exception e) { handleError(exchange, e); } }); return; } - processRequest(exchange, registry, cluster, customFilters); + processRequest(exchange, registry, activeClusters, customFilters); } }) .build(); @@ -196,7 +200,7 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } } - private void processRequest(HttpServerExchange exchange, RouteRegistry registry, Cluster cluster, List customFilters) { + private void processRequest(HttpServerExchange exchange, RouteRegistry registry, List clusters, List customFilters) { // Set CORS headers exchange.getResponseHeaders().put(CORS_ALLOW_ORIGIN, "*"); exchange.getResponseHeaders().put(CORS_ALLOW_METHODS, "GET, POST, OPTIONS, PUT, DELETE"); diff --git a/java/src/hexacloud/infra/server/WsTransport.java b/java/src/hexacloud/infra/server/WsTransport.java index 053e27d..ea0ad14 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)); } 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/L4RoutingTest.java b/java/test/hexacloud/infra/server/L4RoutingTest.java index 51cb5a4..113cc1a 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); } diff --git a/java/test/hexacloud/infra/server/L7RoutingTest.java b/java/test/hexacloud/infra/server/L7RoutingTest.java index 8c73049..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); } diff --git a/java/test/hexacloud/infra/server/WsTransportTest.java b/java/test/hexacloud/infra/server/WsTransportTest.java index 5a19066..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); From 17537a764cda745d32f87be6d44257e173c28c95 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Mon, 27 Jul 2026 18:21:59 -0300 Subject: [PATCH 08/43] =?UTF-8?q?feat:=20ExternalAuthFilter=20+=20authServ?= =?UTF-8?q?ice()=20builder=20=E2=80=94=20auth=5Frequest=20delegation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExternalAuthFilter: @Order(20) filter that delegates auth to an external HTTP service. Forwards Authorization, X-Cluster-Token, X-Real-IP, X-Forwarded-For, Cookie and X-Original-URI headers. Returns 401 on denial, 502 on service error. - GatewayBuilderPort: new authService(url) and authService(url, timeoutMs) methods - LocalGatewayAdapter: implements both methods, wires ExternalAuthFilter via registerFilter() --- .../core/ports/GatewayBuilderPort.java | 16 +++ .../filter/builtin/ExternalAuthFilter.java | 121 ++++++++++++++++++ .../infra/gateway/LocalGatewayAdapter.java | 13 ++ 3 files changed, 150 insertions(+) create mode 100644 java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java diff --git a/java/src/hexacloud/core/ports/GatewayBuilderPort.java b/java/src/hexacloud/core/ports/GatewayBuilderPort.java index 8acf005..b8a9c39 100644 --- a/java/src/hexacloud/core/ports/GatewayBuilderPort.java +++ b/java/src/hexacloud/core/ports/GatewayBuilderPort.java @@ -168,4 +168,20 @@ public interface GatewayBuilderPort { * 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); } 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..c66aa0a --- /dev/null +++ b/java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java @@ -0,0 +1,121 @@ +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; +import java.util.List; +import java.util.Map; + +/** + * 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/infra/gateway/LocalGatewayAdapter.java b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java index bda28ee..db643ca 100644 --- a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java +++ b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java @@ -344,6 +344,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); From 8fa8add3499a0c103aaebb02d8f8d63e5e81ca05 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Mon, 27 Jul 2026 19:51:27 -0300 Subject: [PATCH 09/43] refactor(ExternalAuthFilter): remove unused imports refactor(HttpTransport): move imports, change http version to HTTP_1_1 fix(HttpTransport): add error handling for proxy requests refactor(HttpTransport): improve code readability and structure --- .../filter/builtin/ExternalAuthFilter.java | 2 -- .../hexacloud/infra/server/HttpTransport.java | 35 +++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java b/java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java index c66aa0a..de0f819 100644 --- a/java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java +++ b/java/src/hexacloud/core/server/filter/builtin/ExternalAuthFilter.java @@ -9,8 +9,6 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; -import java.util.List; -import java.util.Map; /** * ExternalAuthFilter — delegates authentication to an external HTTP auth service. diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index 61edce3..9013efd 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -1,9 +1,5 @@ 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; @@ -16,6 +12,10 @@ 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; @@ -41,6 +41,24 @@ * and using virtual threads for routing and rate-limiting incoming traffic. * Supports Layer 7 Reverse-Proxy load balancing and passive telemetry extraction. */ +// TODO[]1: create a rebuildFilter to a single cluster. +// TODO[]2: make this dinamically to rebuild on new clusters created on runtime +//TODO[]3: add support for HTTP/2 and HTTP/1, dinamically change the HTTP version using ServerNode protocol. gRPC = HTTP/2; !gRPC = HTTP/1 +//TODO[]4: remove completelly the default route GET_NODES_JSON +//TODO[]5: abtract all listen to new methods +//TODO[]6: refactor all matching route to more readable version and dinamically. +// TODO[]7: Extract CORS configuration logic into a dedicated HttpFilter (e.g., CorsFilter) instead of hardcoding it at the top of the handler. +// TODO[]8: Replace manual string manipulation ("/v1/", "/clusters/") with a dedicated 'Router' or 'PathResolver' component. Routes should be resolved using exact templates (e.g., /clusters/{id}/nodes). +// TODO[]9: Implement strict URI normalization before routing to prevent Path Traversal vulnerabilities (remove double slashes '//', resolve '..'). +// TODO[]10: Unify the "Fast-path" execution. Ensure all requests, even direct custom routes, pass through the FilterChain to maintain security and consistency. +// TODO[]11: Extract the Reverse Proxy logic (HttpRequest builder, header copying, and stream forwarding) into a separate class (e.g., ReverseProxyService). +// TODO[]12: Move the Round-Robin index state and node selection logic into the Cluster class or a dedicated LoadBalancerStrategy. Remove 'roundRobinIndices' from the transport layer. +// TODO[]13: Extract the Passive Telemetry extraction into a separate service that decodes response headers, decoupling it from the main routing handler. +// TODO[]14: Eliminate Magic Strings (e.g., "X-Telemetry-CPU", "X-Cluster-Token"). Move them to an 'HttpConstants' class or Enums. +// TODO[]15: Parameterize the HttpClient timeout (currently hardcoded to 5000ms) to use the cluster's specific timeout configuration. +// TODO[]16: Implement a GlobalExceptionHandler to replace the generic 500 catch block, allowing it to return properly formatted JSON if the client requested 'application/json'. +// TODO[]17: Make CORS configurable for all routes. +// TODO[]18: Make connectionTimeout configurable. public class HttpTransport implements ServerTransport { private HttpServer server; @@ -49,7 +67,7 @@ public class HttpTransport implements ServerTransport { 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) + .version(java.net.http.HttpClient.Version.HTTP_1_1) .connectTimeout(java.time.Duration.ofMillis(5000)) .executor(ThreadManager.newVirtualThreadPool()) .build(); @@ -58,6 +76,7 @@ public class HttpTransport implements ServerTransport { private final List activeFilters = new CopyOnWriteArrayList<>(); private hexacloud.core.ports.SslContextPort sslContextPort; + private void rebuildFilters(List clusters, List customFilters) { activeFilters.clear(); if (clusters != null) { @@ -334,6 +353,8 @@ public void handle(HttpExchange exchange) throws IOException { respCode = proxyResponse.statusCode(); } catch (Exception ex) { respCode = 502; + System.err.println("Error on proxy " + targetUrlStr); + ex.printStackTrace(); } long latencyMs = System.currentTimeMillis() - startTime; @@ -377,7 +398,7 @@ public void handle(HttpExchange exchange) throws IOException { buf = new byte[8192]; } try (InputStream in = proxyResponse.body(); - OutputStream os = exchange.getResponseBody()) { + OutputStream os = exchange.getResponseBody()) { int len; while ((len = in.read(buf)) != -1) { os.write(buf, 0, len); @@ -451,7 +472,7 @@ public void handle(HttpExchange exchange) throws IOException { try { exchange.sendResponseHeaders(500, 0); try (OutputStream os = exchange.getResponseBody(); - PrintWriter out = new PrintWriter(os, true)) { + PrintWriter out = new PrintWriter(os, true)) { out.println("500 Internal Server Error - Execution failure: " + e.getMessage()); } } catch (Exception ignored) {} From 5550d6b8a455016c0b32b474db980b9361ff2c80 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 18:56:16 -0300 Subject: [PATCH 10/43] feat: make TUI System.out redirection optional and disabled by default --- java/src/hexacloud/application/Main.java | 1 + .../application/MinimalApplication.java | 1 + .../application/OnlyTerminalMain.java | 1 + .../hexacloud/application/TerminalMain.java | 1 + .../hexacloud/core/ports/TerminalUiPort.java | 6 +++++ java/src/hexacloud/core/tui/TerminalUI.java | 15 +++++++++++-- .../core/tui/TuiLogRedirectionTest.java | 22 +++++++++++++++++++ 7 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 java/test/hexacloud/core/tui/TuiLogRedirectionTest.java diff --git a/java/src/hexacloud/application/Main.java b/java/src/hexacloud/application/Main.java index 541cf9c..697a255 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(); } diff --git a/java/src/hexacloud/application/MinimalApplication.java b/java/src/hexacloud/application/MinimalApplication.java index ae81618..c39024f 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(); } 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/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/tui/TerminalUI.java b/java/src/hexacloud/core/tui/TerminalUI.java index f9a703d..d8bd78b 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<>(); @@ -170,6 +171,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 +235,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 +388,9 @@ private void cleanup(hexacloud.core.event.EventListener Date: Tue, 28 Jul 2026 18:58:15 -0300 Subject: [PATCH 11/43] fix(tui): add redirectSystemOut getter and update TuiLogRedirectionTest assertions --- java/src/hexacloud/core/tui/TerminalUI.java | 4 ++++ .../hexacloud/core/tui/TuiLogRedirectionTest.java | 13 +++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/java/src/hexacloud/core/tui/TerminalUI.java b/java/src/hexacloud/core/tui/TerminalUI.java index d8bd78b..1528b07 100644 --- a/java/src/hexacloud/core/tui/TerminalUI.java +++ b/java/src/hexacloud/core/tui/TerminalUI.java @@ -124,6 +124,10 @@ public boolean nodeConfigurationEnabled() { return nodeConfigurationEnabled; } + public boolean redirectSystemOut() { + return redirectSystemOut; + } + @Override public boolean tokenManagementEnabled() { return tokenManagementEnabled; diff --git a/java/test/hexacloud/core/tui/TuiLogRedirectionTest.java b/java/test/hexacloud/core/tui/TuiLogRedirectionTest.java index cc7431b..6c90081 100644 --- a/java/test/hexacloud/core/tui/TuiLogRedirectionTest.java +++ b/java/test/hexacloud/core/tui/TuiLogRedirectionTest.java @@ -1,22 +1,27 @@ package hexacloud.core.tui; import org.junit.jupiter.api.Test; -import java.io.PrintStream; import static org.junit.jupiter.api.Assertions.*; public class TuiLogRedirectionTest { + @Test public void testDefaultRedirectionIsDisabled() { - PrintStream originalOut = System.out; TerminalUI ui = new TerminalUI("Test Display Name"); - // By default, system output should not be hijacked before run, and optional - assertEquals(originalOut, System.out); + 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"); } } + From ae611da3ccfcd5b0068d42b80745157e54070797 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 19:01:39 -0300 Subject: [PATCH 12/43] feat: inject X-Forwarded traceability headers in L7 proxy requests --- .../hexacloud/infra/server/HttpTransport.java | 26 ++++++- .../infra/server/UndertowHttpTransport.java | 22 +++++- .../server/L7TraceabilityHeadersTest.java | 69 +++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index 9013efd..e0b2e0e 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -324,7 +324,7 @@ public void handle(HttpExchange exchange) throws IOException { 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")) { + if (hName == null || hName.equalsIgnoreCase("Host") || hName.equalsIgnoreCase("Content-Length") || hName.equalsIgnoreCase("Connection") || hName.equalsIgnoreCase("Upgrade") || hName.equalsIgnoreCase("X-Forwarded-For") || hName.equalsIgnoreCase("X-Forwarded-Proto") || hName.equalsIgnoreCase("X-Forwarded-Host")) { continue; } for (String val : entry.getValue()) { @@ -333,6 +333,30 @@ public void handle(HttpExchange exchange) throws IOException { } } + // Inject X-Forwarded-For + 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); + + // Inject X-Forwarded-Proto + String proto = "http"; + if (exchange instanceof com.sun.net.httpserver.HttpsExchange) { + proto = "https"; + } else { + String existingProto = r.getHeader("X-Forwarded-Proto"); + if (existingProto != null && !existingProto.trim().isEmpty()) { + proto = existingProto; + } + } + reqBuilder.header("X-Forwarded-Proto", proto); + + // Inject X-Forwarded-Host + String originalHost = r.getHeader("Host"); + if (originalHost != null && !originalHost.trim().isEmpty()) { + reqBuilder.header("X-Forwarded-Host", originalHost); + } + // Forward request body if present String method = r.getMethod(); java.net.http.HttpRequest.BodyPublisher bodyPublisher; diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index 6e51e7a..376997a 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -377,7 +377,7 @@ private void processRequest(HttpServerExchange exchange, RouteRegistry registry, 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")) { + if (hName == null || hName.equalsIgnoreCase("Host") || hName.equalsIgnoreCase("Content-Length") || hName.equalsIgnoreCase("Connection") || hName.equalsIgnoreCase("Upgrade") || hName.equalsIgnoreCase("X-Forwarded-For") || hName.equalsIgnoreCase("X-Forwarded-Proto") || hName.equalsIgnoreCase("X-Forwarded-Host")) { continue; } for (String val : entry.getValue()) { @@ -386,6 +386,26 @@ private void processRequest(HttpServerExchange exchange, RouteRegistry registry, } } + // Inject X-Forwarded-For + 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); + + // Inject X-Forwarded-Proto + String proto = exchange.getRequestScheme().equalsIgnoreCase("https") ? "https" : "http"; + String existingProto = r.getHeader("X-Forwarded-Proto"); + if (existingProto != null && !existingProto.trim().isEmpty()) { + proto = existingProto; + } + reqBuilder.header("X-Forwarded-Proto", proto); + + // Inject X-Forwarded-Host + String originalHost = r.getHeader("Host"); + if (originalHost != null && !originalHost.trim().isEmpty()) { + reqBuilder.header("X-Forwarded-Host", originalHost); + } + // Forward body if present String method = r.getMethod(); java.net.http.HttpRequest.BodyPublisher bodyPublisher; diff --git a/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java b/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java new file mode 100644 index 0000000..b15fda6 --- /dev/null +++ b/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java @@ -0,0 +1,69 @@ +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.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 static org.junit.jupiter.api.Assertions.*; + +public class L7TraceabilityHeadersTest { + @Test + public void testTraceabilityHeadersInjected() throws Exception { + int backendPort = 18089; + int gatewayPort = 18090; + + java.util.concurrent.atomic.AtomicReference clientIpHeader = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference hostHeader = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference protoHeader = new java.util.concurrent.atomic.AtomicReference<>(); + + ServerSocket serverSocket = new ServerSocket(backendPort); + Thread t = new Thread(() -> { + try (Socket s = serverSocket.accept(); + java.io.BufferedReader in = new java.io.BufferedReader(new java.io.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"); + 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); + + HttpTransport transport = new HttpTransport(); + transport.listen(gatewayPort, new RouteRegistry(), java.util.List.of(cluster), Collections.emptyList()); + + HttpClient client = HttpClient.newHttpClient(); + HttpRequest req = HttpRequest.newBuilder().uri(URI.create("http://127.0.0.1:" + gatewayPort + "/clusters/trace-cluster/")).build(); + try { + client.send(req, HttpResponse.BodyHandlers.discarding()); + } catch (Exception ignored) {} + + transport.stop(); + serverSocket.close(); + t.join(); + + assertNotNull(clientIpHeader.get()); + assertEquals("127.0.0.1", clientIpHeader.get()); + assertEquals("127.0.0.1:" + gatewayPort, hostHeader.get()); + assertEquals("http", protoHeader.get()); + } +} From 49fc6b33f981edc43403a02bda29787fb8e8c868 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 19:08:59 -0300 Subject: [PATCH 13/43] fix(server): deduplicate L7 traceability header injection and expand test coverage --- .../core/utils/network/HttpHeaderUtils.java | 27 +++ .../hexacloud/infra/server/HttpTransport.java | 27 +-- .../infra/server/UndertowHttpTransport.java | 23 +-- .../server/L7TraceabilityHeadersTest.java | 156 +++++++++++++++--- 4 files changed, 167 insertions(+), 66 deletions(-) create mode 100644 java/src/hexacloud/core/utils/network/HttpHeaderUtils.java 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/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index e0b2e0e..ba18622 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -33,6 +33,7 @@ import hexacloud.core.server.route.RouteRule; import hexacloud.core.utils.common.DebugUtils; import hexacloud.core.utils.concurrent.ThreadManager; +import hexacloud.core.utils.network.HttpHeaderUtils; import hexacloud.infra.server.filter.HttpRequestImpl; import hexacloud.infra.server.filter.HttpResponseImpl; @@ -333,29 +334,9 @@ public void handle(HttpExchange exchange) throws IOException { } } - // Inject X-Forwarded-For - 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); - - // Inject X-Forwarded-Proto - String proto = "http"; - if (exchange instanceof com.sun.net.httpserver.HttpsExchange) { - proto = "https"; - } else { - String existingProto = r.getHeader("X-Forwarded-Proto"); - if (existingProto != null && !existingProto.trim().isEmpty()) { - proto = existingProto; - } - } - reqBuilder.header("X-Forwarded-Proto", proto); - - // Inject X-Forwarded-Host - String originalHost = r.getHeader("Host"); - if (originalHost != null && !originalHost.trim().isEmpty()) { - reqBuilder.header("X-Forwarded-Host", originalHost); - } + // Inject traceability headers + boolean isSsl = exchange instanceof com.sun.net.httpserver.HttpsExchange; + HttpHeaderUtils.injectTraceabilityHeaders(reqBuilder, r, isSsl); // Forward request body if present String method = r.getMethod(); diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index 376997a..ebb4f9e 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -22,6 +22,7 @@ import hexacloud.core.server.filter.builtin.TokenAuthFilter; import hexacloud.core.utils.common.DebugUtils; import hexacloud.core.utils.concurrent.ThreadManager; +import hexacloud.core.utils.network.HttpHeaderUtils; import hexacloud.core.server.filter.HttpFilterChainImpl; import java.io.InputStream; @@ -386,25 +387,9 @@ private void processRequest(HttpServerExchange exchange, RouteRegistry registry, } } - // Inject X-Forwarded-For - 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); - - // Inject X-Forwarded-Proto - String proto = exchange.getRequestScheme().equalsIgnoreCase("https") ? "https" : "http"; - String existingProto = r.getHeader("X-Forwarded-Proto"); - if (existingProto != null && !existingProto.trim().isEmpty()) { - proto = existingProto; - } - reqBuilder.header("X-Forwarded-Proto", proto); - - // Inject X-Forwarded-Host - String originalHost = r.getHeader("Host"); - if (originalHost != null && !originalHost.trim().isEmpty()) { - reqBuilder.header("X-Forwarded-Host", originalHost); - } + // Inject traceability headers + boolean isSsl = exchange.getRequestScheme().equalsIgnoreCase("https"); + HttpHeaderUtils.injectTraceabilityHeaders(reqBuilder, r, isSsl); // Forward body if present String method = r.getMethod(); diff --git a/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java b/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java index b15fda6..6c96cde 100644 --- a/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java +++ b/java/test/hexacloud/infra/server/L7TraceabilityHeadersTest.java @@ -3,8 +3,16 @@ 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; @@ -13,22 +21,32 @@ 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 { - @Test - public void testTraceabilityHeadersInjected() throws Exception { - int backendPort = 18089; - int gatewayPort = 18090; - java.util.concurrent.atomic.AtomicReference clientIpHeader = new java.util.concurrent.atomic.AtomicReference<>(); - java.util.concurrent.atomic.AtomicReference hostHeader = new java.util.concurrent.atomic.AtomicReference<>(); - java.util.concurrent.atomic.AtomicReference protoHeader = new java.util.concurrent.atomic.AtomicReference<>(); + 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(); - ServerSocket serverSocket = new ServerSocket(backendPort); Thread t = new Thread(() -> { - try (Socket s = serverSocket.accept(); - java.io.BufferedReader in = new java.io.BufferedReader(new java.io.InputStreamReader(s.getInputStream())); + 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()) { @@ -42,28 +60,118 @@ public void testTraceabilityHeadersInjected() throws Exception { }); t.start(); - Cluster cluster = new Cluster("trace-cluster"); + 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); - HttpTransport transport = new HttpTransport(); - transport.listen(gatewayPort, new RouteRegistry(), java.util.List.of(cluster), Collections.emptyList()); + transport.listen(gatewayPort, new RouteRegistry(), List.of(cluster), Collections.emptyList()); - HttpClient client = HttpClient.newHttpClient(); - HttpRequest req = HttpRequest.newBuilder().uri(URI.create("http://127.0.0.1:" + gatewayPort + "/clusters/trace-cluster/")).build(); try { - client.send(req, HttpResponse.BodyHandlers.discarding()); - } catch (Exception ignored) {} + HttpClient client = HttpClient.newHttpClient(); + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + gatewayPort + "/clusters/" + cluster.getClusterName() + "/")); - transport.stop(); - serverSocket.close(); - t.join(); + if (sendExistingXff) { + reqBuilder.header("X-Forwarded-For", "1.2.3.4"); + } - assertNotNull(clientIpHeader.get()); - assertEquals("127.0.0.1", clientIpHeader.get()); - assertEquals("127.0.0.1:" + gatewayPort, hostHeader.get()); - assertEquals("http", protoHeader.get()); + 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); } } From 7960223ad5d047ab4ad3f41aeb292802e3ce95eb Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 19:11:44 -0300 Subject: [PATCH 14/43] feat: support TCP keep-alive, timeouts and robust dual-socket closing on exit --- .../core/ports/GatewayBuilderPort.java | 11 ++++ .../hexacloud/core/server/ServerManager.java | 16 ++++- .../infra/gateway/LocalGatewayAdapter.java | 20 ++++++ .../infra/server/TcpProxyTransport.java | 29 ++++++--- .../infra/server/L4ProxyTimeoutTest.java | 65 +++++++++++++++++++ 5 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 java/test/hexacloud/infra/server/L4ProxyTimeoutTest.java diff --git a/java/src/hexacloud/core/ports/GatewayBuilderPort.java b/java/src/hexacloud/core/ports/GatewayBuilderPort.java index b8a9c39..49839f1 100644 --- a/java/src/hexacloud/core/ports/GatewayBuilderPort.java +++ b/java/src/hexacloud/core/ports/GatewayBuilderPort.java @@ -164,6 +164,17 @@ 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. */ diff --git a/java/src/hexacloud/core/server/ServerManager.java b/java/src/hexacloud/core/server/ServerManager.java index c4d5042..7306561 100644 --- a/java/src/hexacloud/core/server/ServerManager.java +++ b/java/src/hexacloud/core/server/ServerManager.java @@ -35,6 +35,8 @@ 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; /** * Primary constructor accepting all clusters. Used by LocalGatewayAdapter. @@ -124,6 +126,16 @@ public ServerManager enableTcpProxy(boolean enabled) { return this; } + public ServerManager tcpSoTimeout(int timeoutMs) { + this.tcpSoTimeout = timeoutMs; + return this; + } + + public ServerManager tcpKeepAlive(boolean enabled) { + this.tcpKeepAlive = enabled; + return this; + } + public boolean isTelnetEnabled() { return telnetEnabled; } @@ -215,7 +227,9 @@ public ServerManager listen(int port) { } 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, clusters, customFilters); activeTransports.add(tcpProxy); diff --git a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java index db643ca..10efe62 100644 --- a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java +++ b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java @@ -35,6 +35,8 @@ 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; public LocalGatewayAdapter(String gatewayName) { DebugUtils.log("Creating LocalGatewayAdapter for gateway: " + gatewayName); @@ -168,6 +170,8 @@ private void ensureServerManagerInitialized() { 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); } } @@ -457,6 +461,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/server/TcpProxyTransport.java b/java/src/hexacloud/infra/server/TcpProxyTransport.java index 3a5cbe0..555541e 100644 --- a/java/src/hexacloud/infra/server/TcpProxyTransport.java +++ b/java/src/hexacloud/infra/server/TcpProxyTransport.java @@ -35,10 +35,24 @@ public class TcpProxyTransport implements ServerTransport { private final Set activeSockets = ConcurrentHashMap.newKeySet(); 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) {} @@ -135,8 +149,8 @@ private void handleConnection(Socket clientSocket, List clusters) { 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 { @@ -159,7 +173,7 @@ private void handleConnection(Socket clientSocket, List clusters) { } } - 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) { buffer = new byte[8192]; @@ -173,11 +187,8 @@ 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) {} + closeQuietly(inSocket); + closeQuietly(outSocket); } } 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(); + } +} From a653d614fb03221aa35870b08a760e93a34d9050 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 19:16:11 -0300 Subject: [PATCH 15/43] perf: bound TCP proxy buffer pool cache size to prevent memory leaks --- .../infra/server/TcpProxyTransport.java | 11 ++++- .../hexacloud/infra/server/L4RoutingTest.java | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/java/src/hexacloud/infra/server/TcpProxyTransport.java b/java/src/hexacloud/infra/server/TcpProxyTransport.java index 555541e..8ec4226 100644 --- a/java/src/hexacloud/infra/server/TcpProxyTransport.java +++ b/java/src/hexacloud/infra/server/TcpProxyTransport.java @@ -33,6 +33,8 @@ 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; @@ -175,7 +177,9 @@ private void handleConnection(Socket clientSocket, List clusters) { 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 { @@ -186,7 +190,10 @@ private void tunnel(InputStream in, OutputStream out, Socket inSocket, Socket ou } } catch (IOException ignored) { } finally { - BUFFER_POOL.offer(buffer); + if (POOL_SIZE.get() < MAX_POOL_SIZE) { + BUFFER_POOL.offer(buffer); + POOL_SIZE.incrementAndGet(); + } closeQuietly(inSocket); closeQuietly(outSocket); } diff --git a/java/test/hexacloud/infra/server/L4RoutingTest.java b/java/test/hexacloud/infra/server/L4RoutingTest.java index 113cc1a..4bc573d 100644 --- a/java/test/hexacloud/infra/server/L4RoutingTest.java +++ b/java/test/hexacloud/infra/server/L4RoutingTest.java @@ -162,6 +162,48 @@ 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); + pool.clear(); + + 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); + + 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); + + // Pre-fill pool and poolSize to 512 + for (int i = 0; i < 512; i++) { + pool.offer(new byte[8192]); + } + poolSize.set(512); + + // Run tunnel with a new buffer when pool is already full at 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); + + assertTrue(pool.size() <= 512, "BUFFER_POOL size should be capped at 512, but was " + pool.size()); + assertTrue(poolSize.get() <= 512, "POOL_SIZE should be capped at 512, but was " + poolSize.get()); + } + private String sendTcpMessage(String host, int port, String message) throws Exception { try (Socket socket = new Socket(host, port)) { socket.setSoTimeout(3000); From e7529c95cf2b069f37763533bb896e7008cc697c Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 19:17:59 -0300 Subject: [PATCH 16/43] fix(l4): eliminate check-then-act race in buffer pool & fix buffer drop test assertion --- java/src/hexacloud/infra/server/TcpProxyTransport.java | 5 +++-- java/test/hexacloud/infra/server/L4RoutingTest.java | 10 +++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/java/src/hexacloud/infra/server/TcpProxyTransport.java b/java/src/hexacloud/infra/server/TcpProxyTransport.java index 8ec4226..61ebf61 100644 --- a/java/src/hexacloud/infra/server/TcpProxyTransport.java +++ b/java/src/hexacloud/infra/server/TcpProxyTransport.java @@ -190,9 +190,10 @@ private void tunnel(InputStream in, OutputStream out, Socket inSocket, Socket ou } } catch (IOException ignored) { } finally { - if (POOL_SIZE.get() < MAX_POOL_SIZE) { + if (POOL_SIZE.incrementAndGet() <= MAX_POOL_SIZE) { BUFFER_POOL.offer(buffer); - POOL_SIZE.incrementAndGet(); + } else { + POOL_SIZE.decrementAndGet(); } closeQuietly(inSocket); closeQuietly(outSocket); diff --git a/java/test/hexacloud/infra/server/L4RoutingTest.java b/java/test/hexacloud/infra/server/L4RoutingTest.java index 4bc573d..7e32c46 100644 --- a/java/test/hexacloud/infra/server/L4RoutingTest.java +++ b/java/test/hexacloud/infra/server/L4RoutingTest.java @@ -189,19 +189,15 @@ public void testBoundedBufferPool() throws Exception { ); tunnelMethod.setAccessible(true); - // Pre-fill pool and poolSize to 512 - for (int i = 0; i < 512; i++) { - pool.offer(new byte[8192]); - } + pool.clear(); poolSize.set(512); - // Run tunnel with a new buffer when pool is already full at 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); - assertTrue(pool.size() <= 512, "BUFFER_POOL size should be capped at 512, but was " + pool.size()); - assertTrue(poolSize.get() <= 512, "POOL_SIZE should be capped at 512, but was " + poolSize.get()); + assertEquals(0, pool.size(), "BUFFER_POOL size should remain 0"); + assertEquals(512, poolSize.get(), "POOL_SIZE should remain 512"); } private String sendTcpMessage(String host, int port, String message) throws Exception { From d1d0d9fb0e55b5147aa7ca402e12ad1cac767620 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 19:19:23 -0300 Subject: [PATCH 17/43] fix(test): wrap testBoundedBufferPool execution in try-finally to cleanup static pool state --- .../hexacloud/infra/server/L4RoutingTest.java | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/java/test/hexacloud/infra/server/L4RoutingTest.java b/java/test/hexacloud/infra/server/L4RoutingTest.java index 7e32c46..c7db011 100644 --- a/java/test/hexacloud/infra/server/L4RoutingTest.java +++ b/java/test/hexacloud/infra/server/L4RoutingTest.java @@ -169,35 +169,41 @@ public void testBoundedBufferPool() throws Exception { @SuppressWarnings("unchecked") java.util.concurrent.ConcurrentLinkedQueue pool = (java.util.concurrent.ConcurrentLinkedQueue) poolField.get(null); - pool.clear(); 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); - 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"); + 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 { From b5d5c05e3bdd585dd3ed74c2250306ac575511cf Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 19:24:37 -0300 Subject: [PATCH 18/43] docs: update gateway and terminal-ui documentation for issue #16 and #19 features --- docs/gateway.md | 50 +++++++++++++++++++++++++++++++++++++-------- docs/terminal-ui.md | 9 ++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) 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/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. From 25cb5cfa0db8de92222d0c2b3aea4c65989eb182 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Tue, 28 Jul 2026 23:55:37 -0300 Subject: [PATCH 19/43] refactor(log): change startup/shutdown lifecycle logs from debug to info level --- .../hexacloud/infra/server/WsTransport.java | 2 +- java/src/hexacloud/core/cluster/Cluster.java | 10 +++++----- java/src/hexacloud/core/config/EnvLoader.java | 2 +- .../core/config/LocalFilePersistenceAdapter.java | 16 ++++++++++++---- .../hexacloud/core/event/EventBusManager.java | 2 +- .../src/hexacloud/core/server/ServerManager.java | 12 ++++++------ .../core/server/route/RouteRegistry.java | 2 +- .../infra/gateway/LocalGatewayAdapter.java | 6 +++--- .../hexacloud/infra/server/HttpTransport.java | 4 ++-- .../infra/server/TcpProxyTransport.java | 8 ++++---- .../hexacloud/infra/server/TelnetTransport.java | 14 +++++++------- .../infra/server/UndertowHttpTransport.java | 2 +- java/src/hexacloud/infra/server/WsTransport.java | 2 +- 13 files changed, 45 insertions(+), 37 deletions(-) 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/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index 04640e9..26b4eb5 100644 --- a/java/src/hexacloud/core/cluster/Cluster.java +++ b/java/src/hexacloud/core/cluster/Cluster.java @@ -187,7 +187,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(); @@ -197,7 +197,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(); @@ -227,7 +227,7 @@ public void listClusterNodes() { try { for (ServerNode node : cluster.values()) { if (node != null) { - DebugUtils.log(node.toString()); + DebugUtils.info(node.toString()); } } } finally { @@ -298,7 +298,7 @@ public void updateServerNode(ServerNode updatedNode) { 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(); } @@ -354,7 +354,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 { 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 be96bc2..9c2a4ac 100644 --- a/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java +++ b/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java @@ -31,17 +31,24 @@ public boolean isStateLoaded() { private String getStateDirectory() { String dir = System.getProperty("hexacloud.state.dir"); + DebugUtils.info("Default state directory is " + dir); if (dir == null) { dir = System.getenv("HEXACLOUD_STATE_DIR"); } + + DebugUtils.info("Directory is " + dir); if (dir == null || dir.trim().isEmpty()) { dir = ".state"; } + + DebugUtils.info("Directory is " + dir); + File dirFile = new File(dir); if (!dirFile.exists()) { dirFile.mkdirs(); + DebugUtils.info("Created directory " + dirFile.getAbsolutePath()); } return dir; @@ -115,7 +122,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 +131,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 +139,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 +214,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) { diff --git a/java/src/hexacloud/core/event/EventBusManager.java b/java/src/hexacloud/core/event/EventBusManager.java index 4768a2c..2cf502b 100644 --- a/java/src/hexacloud/core/event/EventBusManager.java +++ b/java/src/hexacloud/core/event/EventBusManager.java @@ -34,7 +34,7 @@ public void dispatch(T event) { Class eventType = event.getClass(); List> listeners = channels.get(eventType); - DebugUtils.log("Dispatching event: " + event); + DebugUtils.info("Dispatching event: " + event); // Run interceptors for (EventListener interceptor : interceptors) { diff --git a/java/src/hexacloud/core/server/ServerManager.java b/java/src/hexacloud/core/server/ServerManager.java index 7306561..08c673f 100644 --- a/java/src/hexacloud/core/server/ServerManager.java +++ b/java/src/hexacloud/core/server/ServerManager.java @@ -91,7 +91,7 @@ private void autoRegisterControllers() { for (Cluster c : this.clusters) { c.getRouteRegistry().registerController(controller); } - DebugUtils.log("RouteScanner: Auto-discovered and registered controller: " + clazz.getName()); + DebugUtils.info("RouteScanner: Auto-discovered and registered controller: " + clazz.getName()); } } catch (Exception e) { DebugUtils.error("RouteScanner: Failed to auto-instantiate controller " + clazz.getName(), e); @@ -104,25 +104,25 @@ private void autoRegisterControllers() { 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; } @@ -191,7 +191,7 @@ 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(); diff --git a/java/src/hexacloud/core/server/route/RouteRegistry.java b/java/src/hexacloud/core/server/route/RouteRegistry.java index a2425ec..41c9787 100644 --- a/java/src/hexacloud/core/server/route/RouteRegistry.java +++ b/java/src/hexacloud/core/server/route/RouteRegistry.java @@ -72,7 +72,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: 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/infra/gateway/LocalGatewayAdapter.java b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java index 10efe62..539bde1 100644 --- a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java +++ b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java @@ -39,7 +39,7 @@ class LocalGatewayAdapter implements GatewayBuilderPort, RunningGatewayPort { private boolean tcpKeepAlive = true; public LocalGatewayAdapter(String gatewayName) { - DebugUtils.log("Creating LocalGatewayAdapter for gateway: " + gatewayName); + DebugUtils.info("Creating LocalGatewayAdapter for gateway: " + gatewayName); this.clusterEventManager = new ClusterEventBusManager(); autoRegisterEventListeners(); @@ -182,7 +182,7 @@ private void autoRegisterEventListeners() { 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()); + DebugUtils.info("EventScanner: Auto-discovered and registered listener: " + clazz.getName()); } catch (Exception e) { DebugUtils.error("EventScanner: Failed to auto-instantiate listener " + clazz.getName(), e); } @@ -221,7 +221,7 @@ public LocalGatewayAdapter listen(int port) { 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; diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index ba18622..8c6297a 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -119,7 +119,7 @@ public void setSslContext(hexacloud.core.ports.SslContextPort sslContextPort) { public void listen(int port, RouteRegistry registry, List clusters, List customFilters) { try { rebuildFilters(clusters, customFilters); - DebugUtils.log("HTTP Transport (JDK) starting on port " + port + " with profile: " + performanceProfile); + 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 @@ -523,7 +523,7 @@ public void stop() { if(server != null) { server.stop(0); running = false; - DebugUtils.log("HTTP Transport stopped."); + DebugUtils.info("HTTP Transport stopped."); } } diff --git a/java/src/hexacloud/infra/server/TcpProxyTransport.java b/java/src/hexacloud/infra/server/TcpProxyTransport.java index 61ebf61..c39b9c7 100644 --- a/java/src/hexacloud/infra/server/TcpProxyTransport.java +++ b/java/src/hexacloud/infra/server/TcpProxyTransport.java @@ -66,7 +66,7 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis } private void serverListen(int port, List clusters) { - DebugUtils.log("TcpProxyTransport starting to listen on port " + port); + DebugUtils.info("TcpProxyTransport starting to listen on port " + port); try { serverSocket = new ServerSocket(port); running = true; @@ -113,7 +113,7 @@ private void handleConnection(Socket clientSocket, List clusters) { .collect(Collectors.toList()); if (activeNodes.isEmpty()) { - DebugUtils.log("TcpProxyTransport: No active TCP nodes available."); + DebugUtils.info("TcpProxyTransport: No active TCP nodes available."); closeQuietly(clientSocket); return; } @@ -160,7 +160,7 @@ private void handleConnection(Socket clientSocket, List clusters) { 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) { @@ -223,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 75b9c4c..79e05c6 100644 --- a/java/src/hexacloud/infra/server/TelnetTransport.java +++ b/java/src/hexacloud/infra/server/TelnetTransport.java @@ -34,14 +34,14 @@ public void listen(int port, RouteRegistry registry, java.util.List conn(socket, registry, cluster)); } } catch(IOException ex) { @@ -59,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; @@ -119,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); @@ -146,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/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index ebb4f9e..fe3d700 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -568,7 +568,7 @@ public void stop() { server.stop(); running = false; virtualExecutor.shutdown(); - DebugUtils.log("HTTP Transport (Undertow) stopped."); + DebugUtils.info("HTTP Transport (Undertow) stopped."); } } diff --git a/java/src/hexacloud/infra/server/WsTransport.java b/java/src/hexacloud/infra/server/WsTransport.java index ea0ad14..4e50b1d 100644 --- a/java/src/hexacloud/infra/server/WsTransport.java +++ b/java/src/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() { From 323b4a32100a9f4968cde505ea5b6d7ed602ace7 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:04:18 -0300 Subject: [PATCH 20/43] feat(reflection): implement native ClassScanner and unit tests --- .../core/utils/reflection/ClassScanner.java | 141 ++++++++++++++++++ .../utils/reflection/ClassScannerTest.java | 33 ++++ 2 files changed, 174 insertions(+) create mode 100644 java/src/hexacloud/core/utils/reflection/ClassScanner.java create mode 100644 java/test/hexacloud/core/utils/reflection/ClassScannerTest.java 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..f0071b4 --- /dev/null +++ b/java/src/hexacloud/core/utils/reflection/ClassScanner.java @@ -0,0 +1,141 @@ +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 + */ + @SuppressWarnings("unchecked") + 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/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"); + } +} From feeda43cee3c1a094097b11973381e2e08df0382 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:06:29 -0300 Subject: [PATCH 21/43] feat(dx): support scanPackages and lazy route registration during bootstrap --- .../core/ports/GatewayBuilderPort.java | 5 ++ .../hexacloud/core/server/ServerManager.java | 85 ++++++++++++------- .../core/utils/reflection/ClassScanner.java | 1 - .../infra/gateway/LocalGatewayAdapter.java | 63 +++++++++++--- .../gateway/LocalGatewayAdapterTest.java | 54 ++++++++++++ 5 files changed, 166 insertions(+), 42 deletions(-) diff --git a/java/src/hexacloud/core/ports/GatewayBuilderPort.java b/java/src/hexacloud/core/ports/GatewayBuilderPort.java index 49839f1..dd2a024 100644 --- a/java/src/hexacloud/core/ports/GatewayBuilderPort.java +++ b/java/src/hexacloud/core/ports/GatewayBuilderPort.java @@ -195,4 +195,9 @@ public interface GatewayBuilderPort { * @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/server/ServerManager.java b/java/src/hexacloud/core/server/ServerManager.java index 08c673f..ca37f3b 100644 --- a/java/src/hexacloud/core/server/ServerManager.java +++ b/java/src/hexacloud/core/server/ServerManager.java @@ -48,7 +48,6 @@ public ServerManager(List clusters, ClusterEventBusManager eventManager for (Cluster cluster : this.clusters) { this.routeRegistry.registerController(new ClusterController(cluster)); } - autoRegisterControllers(); } /** @@ -63,42 +62,70 @@ public ServerManager(int port, Cluster cluster, ClusterEventBusManager eventMana this.port = port; } - 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; - Cluster firstCluster = clusters.isEmpty() ? null : clusters.get(0); + private String getAppBasePackage() { + String command = System.getProperty("sun.java.command"); + if (command == null || command.trim().isEmpty()) { + return ""; + } + String mainClass = command.split(" ")[0]; + int lastDot = mainClass.lastIndexOf('.'); + if (lastDot != -1) { + return mainClass.substring(0, lastDot); + } + return ""; + } + + public void autoRegisterControllers(List scanPackages) { + List packages = new ArrayList<>(); + if (scanPackages != null) { + packages.addAll(scanPackages); + } + if (packages.isEmpty()) { + String basePkg = 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 (firstCluster != 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(firstCluster); + 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); - for (Cluster c : this.clusters) { - c.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.info("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); } } diff --git a/java/src/hexacloud/core/utils/reflection/ClassScanner.java b/java/src/hexacloud/core/utils/reflection/ClassScanner.java index f0071b4..acc9801 100644 --- a/java/src/hexacloud/core/utils/reflection/ClassScanner.java +++ b/java/src/hexacloud/core/utils/reflection/ClassScanner.java @@ -29,7 +29,6 @@ private ClassScanner() {} * @param the interface/class type * @return a list of concrete classes implementing the target interface */ - @SuppressWarnings("unchecked") public static List> scanPackage(String packageName, Class targetInterface) { List> classes = new ArrayList<>(); String packagePath = packageName.replace('.', '/'); diff --git a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java index 539bde1..4fdd2f5 100644 --- a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java +++ b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java @@ -37,11 +37,11 @@ class LocalGatewayAdapter implements GatewayBuilderPort, RunningGatewayPort { 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.info("Creating LocalGatewayAdapter for gateway: " + gatewayName); this.clusterEventManager = new ClusterEventBusManager(); - autoRegisterEventListeners(); // Load configurations state from file on startup ClusterStatePersistence.loadState(); @@ -81,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); @@ -175,20 +187,45 @@ private void ensureServerManagerInitialized() { } } + private String getAppBasePackage() { + String command = System.getProperty("sun.java.command"); + if (command == null || command.trim().isEmpty()) { + return ""; + } + String mainClass = command.split(" ")[0]; + int lastDot = mainClass.lastIndexOf('.'); + if (lastDot != -1) { + return mainClass.substring(0, lastDot); + } + return ""; + } + 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.info("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 = 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); } } @@ -217,6 +254,8 @@ 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 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!"); + } } From 776f129ccfd23df508926a3e8da482dc28a41b91 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:14:19 -0300 Subject: [PATCH 22/43] fix(dx): optimize findResourcesDir to walk up parent directories, avoiding container hangs --- .../core/utils/common/PathUtils.java | 55 +++++-------------- 1 file changed, 13 insertions(+), 42 deletions(-) diff --git a/java/src/hexacloud/core/utils/common/PathUtils.java b/java/src/hexacloud/core/utils/common/PathUtils.java index af73592..2838b12 100644 --- a/java/src/hexacloud/core/utils/common/PathUtils.java +++ b/java/src/hexacloud/core/utils/common/PathUtils.java @@ -10,52 +10,23 @@ public class PathUtils { private PathUtils() {} /** - * Recursively searches for a directory with the given name starting from the base directory. - * Skips build and metadata folders for performance optimization. - */ - public static File findDirectory(File current, String targetName) { - if (current == null || !current.exists()) { - return null; - } - - File direct = new File(current, targetName); - if (direct.isDirectory()) { - return direct; - } - - File[] children = current.listFiles(File::isDirectory); - if (children != null) { - for (File child : children) { - String name = child.getName(); - if (name.equals(".git") || name.equals("target") || name.equals("build") || - name.equals("bin") || name.equals(".idea") || name.equals(".gradle") || - name.equals(".gemini")) { - continue; - } - File res = findDirectory(child, targetName); - if (res != null) { - return res; - } - } - } - return null; - } - - /** - * Finds the resources directory in the workspace tree, walking up to parent directories if not found locally. + * Finds the resources directory in the workspace tree by walking up to parent directories. */ public static File findResourcesDir() { - File resourcesDir = findDirectory(new File("."), "resources"); - if (resourcesDir == null) { - File parent = new File(".").getAbsoluteFile(); - for (int i = 0; i < 3; i++) { - parent = parent.getParentFile(); - if (parent == null) break; - resourcesDir = findDirectory(parent, "resources"); - if (resourcesDir != null) break; + File current = new File(".").getAbsoluteFile(); + for (int i = 0; i < 5; i++) { + if (current == null) break; + File resources = new File(current, "resources"); + if (resources.isDirectory()) { + return resources; } + File srcResources = new File(current, "src/main/resources"); + if (srcResources.isDirectory()) { + return srcResources; + } + current = current.getParentFile(); } - return resourcesDir; + return null; } /** From c3b0682cf2a12012d5e5eb35dd48098554bcb52f Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:21:49 -0300 Subject: [PATCH 23/43] fix(log): eliminate duplicate event dispatches, config folder, and scope route registration logs --- java/src/hexacloud/core/cluster/Cluster.java | 2 +- .../core/config/LocalFilePersistenceAdapter.java | 9 --------- java/src/hexacloud/core/event/EventBusManager.java | 4 +++- .../hexacloud/core/server/route/RouteRegistry.java | 12 +++++++++++- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/java/src/hexacloud/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index 26b4eb5..75c8396 100644 --- a/java/src/hexacloud/core/cluster/Cluster.java +++ b/java/src/hexacloud/core/cluster/Cluster.java @@ -62,7 +62,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); } diff --git a/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java b/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java index 9c2a4ac..8d15663 100644 --- a/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java +++ b/java/src/hexacloud/core/config/LocalFilePersistenceAdapter.java @@ -31,26 +31,17 @@ public boolean isStateLoaded() { private String getStateDirectory() { String dir = System.getProperty("hexacloud.state.dir"); - DebugUtils.info("Default state directory is " + dir); if (dir == null) { dir = System.getenv("HEXACLOUD_STATE_DIR"); } - - DebugUtils.info("Directory is " + dir); - if (dir == null || dir.trim().isEmpty()) { dir = ".state"; } - - - DebugUtils.info("Directory is " + dir); - File dirFile = new File(dir); if (!dirFile.exists()) { dirFile.mkdirs(); DebugUtils.info("Created directory " + dirFile.getAbsolutePath()); } - return dir; } diff --git a/java/src/hexacloud/core/event/EventBusManager.java b/java/src/hexacloud/core/event/EventBusManager.java index 2cf502b..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.info("Dispatching event: " + event); + if (this == GLOBAL) { + DebugUtils.info("Dispatching event: " + event); + } // Run interceptors for (EventListener interceptor : interceptors) { diff --git a/java/src/hexacloud/core/server/route/RouteRegistry.java b/java/src/hexacloud/core/server/route/RouteRegistry.java index 41c9787..469ca9a 100644 --- a/java/src/hexacloud/core/server/route/RouteRegistry.java +++ b/java/src/hexacloud/core/server/route/RouteRegistry.java @@ -10,7 +10,17 @@ 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.List routeRules = new java.util.concurrent.CopyOnWriteArrayList<>(); @@ -72,7 +82,7 @@ public void registerController(RouteController controller) { }; } routes.put(command, handler); - DebugUtils.info("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)"); } From 6757aedec145839b7a45168668016eafe57f6cef Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:26:48 -0300 Subject: [PATCH 24/43] fix(log): remove standard output prints inside HttpTransport request path and ThreadPingScheduler --- java/src/hexacloud/infra/network/ThreadPingScheduler.java | 2 +- java/src/hexacloud/infra/server/HttpTransport.java | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/java/src/hexacloud/infra/network/ThreadPingScheduler.java b/java/src/hexacloud/infra/network/ThreadPingScheduler.java index fb3e90b..6f52297 100644 --- a/java/src/hexacloud/infra/network/ThreadPingScheduler.java +++ b/java/src/hexacloud/infra/network/ThreadPingScheduler.java @@ -81,7 +81,7 @@ private void pingClusterNode(ServerNode node) { boolean statusChanged = node.status() != status; if (statusChanged) { eventManager.dispatch(new NodeStatusChanged(node.getFullHost(), status, node.getId())); - System.out.println("Node " + node.getFullHost() + " " + node.getId()); + DebugUtils.info("Node " + node.getFullHost() + " status updated to " + status + " (" + node.getId() + ")"); } if (result.hasTelemetry()){ diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index 8c6297a..441f8dd 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -243,8 +243,6 @@ public void handle(HttpExchange exchange) throws IOException { } } } - //debug... - System.out.println(targetClusterName); if (targetClusterName != null) { Cluster targetCluster = ClusterRegistry.getInstance().getCluster(targetClusterName); if (targetCluster == null) { @@ -287,7 +285,6 @@ public void handle(HttpExchange exchange) throws IOException { } List activeNodes = targetCluster.getCluster().stream() - .peek(node -> System.out.println("Node: " + node)) .filter(n -> n != null && n.status() == NodeStatus.ONLINE && !n.telemetryOnly() && (n.routingProtocol() == hexacloud.core.model.RoutingProtocol.HTTP || n.routingProtocol() == hexacloud.core.model.RoutingProtocol.GRPC)) From 9b13bf704d8571a30d6e9a6421c687a08cd89d6a Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:31:29 -0300 Subject: [PATCH 25/43] fix(dx): support base package resolution from stack trace for fat JAR and Docker executions --- .../hexacloud/core/server/ServerManager.java | 15 +--------- .../core/utils/common/PathUtils.java | 30 ++++++++++++++----- .../infra/gateway/LocalGatewayAdapter.java | 15 +--------- 3 files changed, 24 insertions(+), 36 deletions(-) diff --git a/java/src/hexacloud/core/server/ServerManager.java b/java/src/hexacloud/core/server/ServerManager.java index ca37f3b..6c1880d 100644 --- a/java/src/hexacloud/core/server/ServerManager.java +++ b/java/src/hexacloud/core/server/ServerManager.java @@ -62,26 +62,13 @@ public ServerManager(int port, Cluster cluster, ClusterEventBusManager eventMana this.port = port; } - private String getAppBasePackage() { - String command = System.getProperty("sun.java.command"); - if (command == null || command.trim().isEmpty()) { - return ""; - } - String mainClass = command.split(" ")[0]; - int lastDot = mainClass.lastIndexOf('.'); - if (lastDot != -1) { - return mainClass.substring(0, lastDot); - } - return ""; - } - public void autoRegisterControllers(List scanPackages) { List packages = new ArrayList<>(); if (scanPackages != null) { packages.addAll(scanPackages); } if (packages.isEmpty()) { - String basePkg = getAppBasePackage(); + String basePkg = hexacloud.core.utils.common.PathUtils.getAppBasePackage(); if (!basePkg.isEmpty()) { packages.add(basePkg); } diff --git a/java/src/hexacloud/core/utils/common/PathUtils.java b/java/src/hexacloud/core/utils/common/PathUtils.java index 2838b12..f2952b0 100644 --- a/java/src/hexacloud/core/utils/common/PathUtils.java +++ b/java/src/hexacloud/core/utils/common/PathUtils.java @@ -53,15 +53,29 @@ public static java.util.List> 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/infra/gateway/LocalGatewayAdapter.java b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java index 4fdd2f5..81c026c 100644 --- a/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java +++ b/java/src/hexacloud/infra/gateway/LocalGatewayAdapter.java @@ -187,23 +187,10 @@ private void ensureServerManagerInitialized() { } } - private String getAppBasePackage() { - String command = System.getProperty("sun.java.command"); - if (command == null || command.trim().isEmpty()) { - return ""; - } - String mainClass = command.split(" ")[0]; - int lastDot = mainClass.lastIndexOf('.'); - if (lastDot != -1) { - return mainClass.substring(0, lastDot); - } - return ""; - } - private void autoRegisterEventListeners() { List packages = new ArrayList<>(this.scanPackages); if (packages.isEmpty()) { - String basePkg = getAppBasePackage(); + String basePkg = hexacloud.core.utils.common.PathUtils.getAppBasePackage(); if (!basePkg.isEmpty()) { packages.add(basePkg); } From 757a405c32400cba80059722e0c3540102a0da4a Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:38:46 -0300 Subject: [PATCH 26/43] feat(http): implement ProxyResponse, JdkHttpProxyClient and PathResolver with unit tests --- .../core/server/route/PathResolver.java | 50 +++++++++++++++++++ .../core/server/route/RouteResolution.java | 30 +++++++++++ .../core/utils/network/HttpProxyClient.java | 9 ++++ .../utils/network/JdkHttpProxyClient.java | 50 +++++++++++++++++++ .../core/utils/network/ProxyResponse.java | 21 ++++++++ .../core/server/route/PathResolverTest.java | 30 +++++++++++ 6 files changed, 190 insertions(+) create mode 100644 java/src/hexacloud/core/server/route/PathResolver.java create mode 100644 java/src/hexacloud/core/server/route/RouteResolution.java create mode 100644 java/src/hexacloud/core/utils/network/HttpProxyClient.java create mode 100644 java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java create mode 100644 java/src/hexacloud/core/utils/network/ProxyResponse.java create mode 100644 java/test/hexacloud/core/server/route/PathResolverTest.java 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..046ee8a --- /dev/null +++ b/java/src/hexacloud/core/server/route/PathResolver.java @@ -0,0 +1,50 @@ +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("//", "/"); + } + + String routeKey = matchingPath.toUpperCase(); + if (registry.getRoutes().containsKey(routeKey)) { + return new RouteResolution(null, null, false, routeKey, null); + } + + 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); + } + + 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); + } +} 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/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..5ed5d96 --- /dev/null +++ b/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java @@ -0,0 +1,50 @@ +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() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + @Override + public ProxyResponse execute(String targetUrl, String method, Map> headers, InputStream body, int timeoutMs) throws Exception { + HttpRequest.BodyPublisher publisher = body == null + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofInputStream(() -> body); + + 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")) { + continue; + } + for (String val : entry.getValue()) { + if (val != null) { + builder.header(key, val); + } + } + } + } + + 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/test/hexacloud/core/server/route/PathResolverTest.java b/java/test/hexacloud/core/server/route/PathResolverTest.java new file mode 100644 index 0000000..a367121 --- /dev/null +++ b/java/test/hexacloud/core/server/route/PathResolverTest.java @@ -0,0 +1,30 @@ +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()); + } +} From adce5b305c0174b44f5559823739004df14bacf6 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:41:55 -0300 Subject: [PATCH 27/43] feat(http): implement central HttpErrorHandler and ReverseProxyService --- java/src/hexacloud/core/cluster/Cluster.java | 22 +++++ .../core/server/filter/HttpRequest.java | 1 + .../core/server/filter/HttpResponse.java | 1 + .../infra/server/DefaultHttpErrorHandler.java | 35 ++++++++ .../infra/server/HttpErrorHandler.java | 8 ++ .../infra/server/ReverseProxyService.java | 85 +++++++++++++++++++ .../infra/server/UndertowHttpRequestImpl.java | 9 ++ .../server/UndertowHttpResponseImpl.java | 11 +++ .../infra/server/filter/HttpRequestImpl.java | 3 + .../infra/server/filter/HttpResponseImpl.java | 8 ++ 10 files changed, 183 insertions(+) create mode 100644 java/src/hexacloud/infra/server/DefaultHttpErrorHandler.java create mode 100644 java/src/hexacloud/infra/server/HttpErrorHandler.java create mode 100644 java/src/hexacloud/infra/server/ReverseProxyService.java diff --git a/java/src/hexacloud/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index 75c8396..96d2c75 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; @@ -243,6 +244,27 @@ public List getCluster() { lock.unlock(); } } + + public ServerNode selectNode() { + lock.lock(); + try { + 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(); 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/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/ReverseProxyService.java b/java/src/hexacloud/infra/server/ReverseProxyService.java new file mode 100644 index 0000000..1ce3ba1 --- /dev/null +++ b/java/src/hexacloud/infra/server/ReverseProxyService.java @@ -0,0 +1,85 @@ +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.HashMap; +import java.util.List; +import java.util.Map; + +public class ReverseProxyService { + private final HttpProxyClient proxyClient; + private final HttpErrorHandler errorHandler; + + 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) { + ServerNode targetNode = targetCluster.selectNode(); + if (targetNode == null) { + errorHandler.handleStatus(res, 503, "No active nodes in cluster: " + targetCluster.getClusterName()); + return; + } + + String targetUrl = targetNode.getFullHost() + (subpath.startsWith("/") ? subpath : "/" + subpath); + String query = req.getQuery(); + if (query != null && !query.isEmpty()) { + targetUrl += "?" + query; + } + + Map> headers = new HashMap<>(); + 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); + + 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("Content-Length")) { + continue; + } + for (String val : entry.getValue()) { + res.setHeader(key, val); + } + } + + try (InputStream in = response.bodyStream(); OutputStream out = res.getOutputStream()) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + out.flush(); + } + } catch (Exception e) { + DebugUtils.error("ReverseProxyService: Proxy request failed to " + targetUrl, e); + errorHandler.handleException(res, e); + } + } +} 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..1a54d0b 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java +++ b/java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java @@ -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/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..63d1985 100644 --- a/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java +++ b/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java @@ -45,4 +45,12 @@ public PrintWriter getWriter() throws Exception { public boolean isCommitted() { return committed; } + + @Override + public java.io.OutputStream getOutputStream() throws Exception { + if (!committed) { + setStatus(200); + } + return exchange.getResponseBody(); + } } From ad74f50274ec99aae62bca9bf7619129d4fc9f2f Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 00:42:43 -0300 Subject: [PATCH 28/43] feat(cors): add CorsFilter and refactor route mappings to lowercase slash paths --- java/src/hexacloud/application/Main.java | 2 +- .../application/MinimalApplication.java | 4 ++-- .../server/filter/builtin/CorsFilter.java | 23 ++++++++++++++++++ .../core/server/route/ClusterController.java | 24 +++++++++---------- 4 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 java/src/hexacloud/core/server/filter/builtin/CorsFilter.java diff --git a/java/src/hexacloud/application/Main.java b/java/src/hexacloud/application/Main.java index 697a255..2839de5 100644 --- a/java/src/hexacloud/application/Main.java +++ b/java/src/hexacloud/application/Main.java @@ -125,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 c39024f..9212c92 100644 --- a/java/src/hexacloud/application/MinimalApplication.java +++ b/java/src/hexacloud/application/MinimalApplication.java @@ -184,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/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/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); From 64a6e8ff270801be4d0a136f1aa13c899a8dc8c1 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 06:47:41 -0300 Subject: [PATCH 29/43] feat(http): refactor and unify HttpTransport and UndertowHttpTransport --- .../core/server/route/PathResolver.java | 61 +- .../utils/network/JdkHttpProxyClient.java | 20 +- .../hexacloud/infra/server/HttpTransport.java | 424 ++----------- .../infra/server/ReverseProxyService.java | 48 +- .../infra/server/UndertowHttpTransport.java | 569 +++--------------- pom.xml | 2 +- 6 files changed, 239 insertions(+), 885 deletions(-) diff --git a/java/src/hexacloud/core/server/route/PathResolver.java b/java/src/hexacloud/core/server/route/PathResolver.java index 046ee8a..4907d22 100644 --- a/java/src/hexacloud/core/server/route/PathResolver.java +++ b/java/src/hexacloud/core/server/route/PathResolver.java @@ -14,11 +14,13 @@ public static RouteResolution resolve(String path, String host, RouteRegistry re matchingPath = matchingPath.replace("//", "/"); } - String routeKey = matchingPath.toUpperCase(); - if (registry.getRoutes().containsKey(routeKey)) { - return new RouteResolution(null, null, false, routeKey, null); + // 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); @@ -36,6 +38,7 @@ public static RouteResolution resolve(String path, String host, RouteRegistry re 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) { @@ -47,4 +50,56 @@ public static RouteResolution resolve(String path, String host, RouteRegistry re 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/utils/network/JdkHttpProxyClient.java b/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java index 5ed5d96..1d22259 100644 --- a/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java +++ b/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java @@ -33,13 +33,23 @@ public ProxyResponse execute(String targetUrl, String method, Map> entry : headers.entrySet()) { String key = entry.getKey(); - if (key == null || key.equalsIgnoreCase("Host") || key.equalsIgnoreCase("Content-Length") || key.equalsIgnoreCase("Connection")) { + 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; } - for (String val : entry.getValue()) { - if (val != null) { - builder.header(key, val); - } + 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())); } } } diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index 441f8dd..e3400c8 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -1,16 +1,10 @@ package hexacloud.infra.server; 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; @@ -18,8 +12,6 @@ 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,11 +21,12 @@ 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.core.utils.network.HttpHeaderUtils; import hexacloud.infra.server.filter.HttpRequestImpl; import hexacloud.infra.server.filter.HttpResponseImpl; @@ -42,44 +35,23 @@ * and using virtual threads for routing and rate-limiting incoming traffic. * Supports Layer 7 Reverse-Proxy load balancing and passive telemetry extraction. */ -// TODO[]1: create a rebuildFilter to a single cluster. -// TODO[]2: make this dinamically to rebuild on new clusters created on runtime -//TODO[]3: add support for HTTP/2 and HTTP/1, dinamically change the HTTP version using ServerNode protocol. gRPC = HTTP/2; !gRPC = HTTP/1 -//TODO[]4: remove completelly the default route GET_NODES_JSON -//TODO[]5: abtract all listen to new methods -//TODO[]6: refactor all matching route to more readable version and dinamically. -// TODO[]7: Extract CORS configuration logic into a dedicated HttpFilter (e.g., CorsFilter) instead of hardcoding it at the top of the handler. -// TODO[]8: Replace manual string manipulation ("/v1/", "/clusters/") with a dedicated 'Router' or 'PathResolver' component. Routes should be resolved using exact templates (e.g., /clusters/{id}/nodes). -// TODO[]9: Implement strict URI normalization before routing to prevent Path Traversal vulnerabilities (remove double slashes '//', resolve '..'). -// TODO[]10: Unify the "Fast-path" execution. Ensure all requests, even direct custom routes, pass through the FilterChain to maintain security and consistency. -// TODO[]11: Extract the Reverse Proxy logic (HttpRequest builder, header copying, and stream forwarding) into a separate class (e.g., ReverseProxyService). -// TODO[]12: Move the Round-Robin index state and node selection logic into the Cluster class or a dedicated LoadBalancerStrategy. Remove 'roundRobinIndices' from the transport layer. -// TODO[]13: Extract the Passive Telemetry extraction into a separate service that decodes response headers, decoupling it from the main routing handler. -// TODO[]14: Eliminate Magic Strings (e.g., "X-Telemetry-CPU", "X-Cluster-Token"). Move them to an 'HttpConstants' class or Enums. -// TODO[]15: Parameterize the HttpClient timeout (currently hardcoded to 5000ms) to use the cluster's specific timeout configuration. -// TODO[]16: Implement a GlobalExceptionHandler to replace the generic 500 catch block, allowing it to return properly formatted JSON if the client requested 'application/json'. -// TODO[]17: Make CORS configurable for all routes. -// TODO[]18: Make connectionTimeout configurable. 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_1_1) - .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(List clusters, List customFilters) { activeFilters.clear(); + + // 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(); @@ -133,140 +105,31 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis server.createContext("/", new HttpHandler() { @Override public void handle(HttpExchange exchange) throws IOException { - // CORS Configuration - exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); - exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE"); - exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "X-Cluster-Token, Content-Type, Authorization"); - - if ("OPTIONS".equals(exchange.getRequestMethod())) { - exchange.sendResponseHeaders(204, -1); - return; - } - 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")) { - exchange.getResponseHeaders().set("Content-Type", "application/json"); - } else { - exchange.getResponseHeaders().set("Content-Type", "text/plain"); - } - exchange.sendResponseHeaders(200, 0); - 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); - } - return; - } - } - - // 1. Instantiate Wrappers HttpRequestImpl req = new HttpRequestImpl(exchange); HttpResponseImpl res = new HttpResponseImpl(exchange); - // 3. Final Route execution handler + RouteResolution resolution = PathResolver.resolve(req.getPath(), req.getHeader("Host"), registry); + 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 (resolution.isProxy()) { + Cluster targetCluster = ClusterRegistry.getInstance().getCluster(resolution.targetClusterName()); if (targetCluster == null) { - s.setStatus(404); - try (PrintWriter out = s.getWriter()) { - out.print("404 Not Found - Unknown Cluster: " + targetClusterName); - } + errorHandler.handleStatus(s, 404, "Unknown Cluster: " + resolution.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")) { + // 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"); } - if (!s.isCommitted()) { - s.setStatus(200); - } try (PrintWriter out = s.getWriter()) { String query = r.getQuery(); String args = query != null ? query : ""; @@ -275,244 +138,47 @@ public void handle(HttpExchange exchange) throws IOException { 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() - && (n.routingProtocol() == hexacloud.core.model.RoutingProtocol.HTTP - || n.routingProtocol() == hexacloud.core.model.RoutingProtocol.GRPC)) - .collect(Collectors.toList()); + reverseProxyService.proxyRequest(r, s, targetCluster, resolution.targetSubpath(), targetCluster.getTimeoutMs(), resolution.matchedRouteRule()); - 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; - } - - 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") || hName.equalsIgnoreCase("X-Forwarded-For") || hName.equalsIgnoreCase("X-Forwarded-Proto") || hName.equalsIgnoreCase("X-Forwarded-Host")) { - continue; - } - for (String val : entry.getValue()) { - reqBuilder.header(hName, val); - } - } - } - - // Inject traceability headers - boolean isSsl = exchange instanceof com.sun.net.httpserver.HttpsExchange; - HttpHeaderUtils.injectTraceabilityHeaders(reqBuilder, r, isSsl); - - // 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 if (resolution.isLocal()) { + BiConsumer handler = registry.getRoutes().get(resolution.localRouteName()); + if (resolution.localRouteName().equals("/V1/GET_NODES_JSON")) { + s.setContentType("application/json"); } 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; - System.err.println("Error on proxy " + targetUrlStr); - ex.printStackTrace(); - } - - 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"); + s.setContentType("text/plain"); } - - 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); - } - } + try (PrintWriter out = s.getWriter()) { + String query = r.getQuery(); + String args = query != null ? query : ""; + handler.accept(args, out); } - - // 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); - } - } - } 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); - }); - - 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); - } - } + errorHandler.handleStatus(s, 404, "Unknown Route: " + r.getPath()); } } 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); - } - } - 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) {} - } - } + 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); } - return null; - } - - private static String toRouteName(String path) { - if (path == null || path.equals("/") || path.isEmpty()) { - return "/"; - } - return path.startsWith("/") ? path.substring(1).toUpperCase() : path.toUpperCase(); } @Override @@ -528,14 +194,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 index 1ce3ba1..d4f6277 100644 --- a/java/src/hexacloud/infra/server/ReverseProxyService.java +++ b/java/src/hexacloud/infra/server/ReverseProxyService.java @@ -26,19 +26,30 @@ public ReverseProxyService(HttpProxyClient proxyClient, HttpErrorHandler errorHa } 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); String query = req.getQuery(); if (query != null && !query.isEmpty()) { targetUrl += "?" + query; } - Map> headers = new HashMap<>(); + 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())); @@ -56,6 +67,17 @@ public void proxyRequest(HttpRequest req, HttpResponse res, Cluster targetCluste try (InputStream bodyIn = req.getBody()) { ProxyResponse response = proxyClient.execute(targetUrl, req.getMethod(), headers, bodyIn, timeoutMs); + long latencyMs = System.currentTimeMillis() - 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 @@ -82,4 +104,28 @@ public void proxyRequest(HttpRequest req, HttpResponse res, Cluster targetCluste 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/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index fe3d700..3727ab5 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,49 +17,32 @@ import hexacloud.core.server.filter.builtin.IpRestrictionFilter; import hexacloud.core.server.filter.builtin.RateLimitFilter; import hexacloud.core.server.filter.builtin.TokenAuthFilter; -import hexacloud.core.utils.common.DebugUtils; -import hexacloud.core.utils.concurrent.ThreadManager; -import hexacloud.core.utils.network.HttpHeaderUtils; +import hexacloud.core.server.filter.builtin.CorsFilter; import hexacloud.core.server.filter.HttpFilterChainImpl; +import hexacloud.core.utils.common.DebugUtils; -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 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 List activeClusters = new java.util.ArrayList<>(); private void rebuildFilters(List clusters, List customFilters) { activeFilters.clear(); + + // 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(); @@ -101,9 +81,7 @@ public void setSslContext(hexacloud.core.ports.SslContextPort sslContextPort) { @Override public void listen(int port, RouteRegistry registry, List clusters, List customFilters) { try { - this.activeClusters = clusters != null ? clusters : new java.util.ArrayList<>(); rebuildFilters(clusters, customFilters); - // Configure Default ByteBuffer Pool to avoid pool starvation under high concurrency io.undertow.connector.ByteBufferPool bufferPool = new io.undertow.server.DefaultByteBufferPool( true, 16384, @@ -120,7 +98,6 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis } if (performanceProfile == hexacloud.core.server.PerformanceProfile.MAX_PERFORMANCE) { - // Maximized performance profile for container resource utilization builder.setServerOption(UndertowOptions.ALWAYS_SET_KEEP_ALIVE, true) .setServerOption(UndertowOptions.BUFFER_PIPELINED_DATA, false) .setServerOption(UndertowOptions.RECORD_REQUEST_START_TIME, false) @@ -131,7 +108,6 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis .setIoThreads(Math.max(Runtime.getRuntime().availableProcessors(), 2)) .setWorkerThreads(Runtime.getRuntime().availableProcessors() * 8); } else { - // Standard lightweight profile for normal operations builder.setServerOption(UndertowOptions.ALWAYS_SET_KEEP_ALIVE, true) .setServerOption(UndertowOptions.BUFFER_PIPELINED_DATA, false) .setServerOption(UndertowOptions.RECORD_REQUEST_START_TIME, false) @@ -143,390 +119,91 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis .setWorkerThreads(Runtime.getRuntime().availableProcessors() * 2); } - server = builder.setHandler(new HttpHandler() { - @Override - public void handleRequest(HttpServerExchange exchange) throws Exception { - String fastPath = exchange.getRequestPath(); - 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()) { - fastRouteInfo = routeCache.computeIfAbsent(fastMatchingPath, path -> { - 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, activeClusters, customFilters); - return; - } - } - - if (exchange.isInIoThread()) { - java.util.concurrent.Executor executor = exchange.getConnection().getWorker(); - exchange.dispatch(executor, () -> { - try { - processRequest(exchange, registry, activeClusters, customFilters); - } catch (Exception e) { - handleError(exchange, e); - } - }); - return; - } - processRequest(exchange, registry, activeClusters, 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, List clusters, 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") || hName.equalsIgnoreCase("X-Forwarded-For") || hName.equalsIgnoreCase("X-Forwarded-Proto") || hName.equalsIgnoreCase("X-Forwarded-Host")) { - continue; - } - for (String val : entry.getValue()) { - reqBuilder.header(hName, val); - } - } - } - - // Inject traceability headers - boolean isSsl = exchange.getRequestScheme().equalsIgnoreCase("https"); - HttpHeaderUtils.injectTraceabilityHeaders(reqBuilder, r, isSsl); - - // 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(); + builder.setHandler(new HttpHandler() { + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + if (exchange.isInIoThread()) { + java.util.concurrent.Executor executor = exchange.getConnection().getWorker(); + exchange.dispatch(executor, () -> { + try { + processRequest(exchange, registry); + } catch (Exception e) { + handleError(exchange, e); } - 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; + }); + return; + } + processRequest(exchange, registry); + } + }); - // 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"); - } + 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); + } + } - 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); + private void processRequest(HttpServerExchange exchange, RouteRegistry registry) { + try { + UndertowHttpRequestImpl req = new UndertowHttpRequestImpl(exchange); + UndertowHttpResponseImpl res = new UndertowHttpResponseImpl(exchange); - // 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); - } - } - } + RouteResolution resolution = PathResolver.resolve(req.getPath(), req.getHeader("Host"), registry); - // 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]; - } - 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(); - } - } + BiConsumer routeHandler = (r, s) -> { + try { + if (resolution.isProxy()) { + Cluster targetCluster = ClusterRegistry.getInstance().getCluster(resolution.targetClusterName()); + if (targetCluster == null) { + errorHandler.handleStatus(s, 404, "Unknown Cluster: " + resolution.targetClusterName()); + return; } - } 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); - }); - - if (routeInfo.handler != null) { - if (routeInfo.routeName.equals("GET_NODES_JSON")) { + // 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"); } - 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); + 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.setStatus(404); - try (PrintWriter out = s.getWriter()) { - out.print("404 Not Found - Unknown Route: " + matchingPath); - } + 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()); } } 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(); @@ -536,30 +213,13 @@ private void processRequest(HttpServerExchange exchange, RouteRegistry registry, } 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 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) {} - } - } - } - return null; + try { + UndertowHttpResponseImpl res = new UndertowHttpResponseImpl(exchange); + errorHandler.handleException(res, e); + res.flushBuffer(); + exchange.endExchange(); + } catch (Exception ignored) {} } @Override @@ -567,7 +227,6 @@ public void stop() { if (server != null) { server.stop(); running = false; - virtualExecutor.shutdown(); DebugUtils.info("HTTP Transport (Undertow) stopped."); } } @@ -576,76 +235,4 @@ public void stop() { 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 class FastPrintWriter extends java.io.PrintWriter { - private static class StringBuilderWriter extends java.io.Writer { - final StringBuilder sb = new StringBuilder(512); - - @Override - public void write(char[] cbuf, int off, int len) { - sb.append(cbuf, off, len); - } - - @Override - public void write(String str, int off, int len) { - sb.append(str, off, off + len); - } - - @Override - public void write(int c) { - sb.append((char)c); - } - - @Override - public void flush() {} - - @Override - public void close() {} - - void reset() { - sb.setLength(0); - } - - byte[] toBytes() { - return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); - } - } - - private final StringBuilderWriter sbw; - - public FastPrintWriter() { - this(new StringBuilderWriter()); - } - - private FastPrintWriter(StringBuilderWriter sbw) { - super(sbw); - this.sbw = sbw; - } - - public void reset() { - sbw.reset(); - clearError(); - } - - public byte[] toBytes() { - return sbw.toBytes(); - } - } } diff --git a/pom.xml b/pom.xml index 9c5aaf8..e10698e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.hexacloud gatebridge-core - 1.4.8-release + 1.4.9-SNAPSHOT jar GateBridge Core Framework From f3f5be8b43d004cbbca65b74ad034bba5a39011b Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 06:56:37 -0300 Subject: [PATCH 30/43] fix(cluster): disable redundant disk state persistence on telemetry updates --- java/src/hexacloud/core/cluster/Cluster.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/java/src/hexacloud/core/cluster/Cluster.java b/java/src/hexacloud/core/cluster/Cluster.java index 96d2c75..59e9e5f 100644 --- a/java/src/hexacloud/core/cluster/Cluster.java +++ b/java/src/hexacloud/core/cluster/Cluster.java @@ -301,9 +301,6 @@ 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, current.getId()); } finally { lock.unlock(); From aa01947a82bdbb27af76b2dbaeca44013910b6fd Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:01:52 -0300 Subject: [PATCH 31/43] fix(model): generate node ID from host:port when custom name is null or empty --- java/src/hexacloud/core/model/ServerNode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/src/hexacloud/core/model/ServerNode.java b/java/src/hexacloud/core/model/ServerNode.java index 4ff77f4..d647bcb 100644 --- a/java/src/hexacloud/core/model/ServerNode.java +++ b/java/src/hexacloud/core/model/ServerNode.java @@ -43,7 +43,7 @@ public ServerNode(String name, String host, int port, NodeStatus status, boolean this.isDynamic = isDynamic; this.telemetryOnly = telemetryOnly; this.routingProtocol = routingProtocol != null ? routingProtocol : RoutingProtocol.HTTP; - this.id = name; + this.id = name != null && !name.isEmpty() ? name : (host + ":" + port); } /** From 5b0ddc1ca50935585e4da8902d928434401f0295 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:04:53 -0300 Subject: [PATCH 32/43] fix(proxy): resolve GET request timeout and bind client connections to Loom virtual thread executor --- .../utils/network/JdkHttpProxyClient.java | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java b/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java index 1d22259..fb8e707 100644 --- a/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java +++ b/java/src/hexacloud/core/utils/network/JdkHttpProxyClient.java @@ -14,16 +14,54 @@ public class JdkHttpProxyClient implements HttpProxyClient { 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 { - HttpRequest.BodyPublisher publisher = body == null - ? HttpRequest.BodyPublishers.noBody() - : HttpRequest.BodyPublishers.ofInputStream(() -> body); + 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)) From 1fa041d53d2f30eafa6b90857c4defd53d168d0b Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:17:48 -0300 Subject: [PATCH 33/43] perf(proxy): resolve localhost instantly to 127.0.0.1 and strip hop-by-hop response headers to maximize keep-alive connection reuse --- .../infra/server/ReverseProxyService.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/java/src/hexacloud/infra/server/ReverseProxyService.java b/java/src/hexacloud/infra/server/ReverseProxyService.java index d4f6277..8fd144f 100644 --- a/java/src/hexacloud/infra/server/ReverseProxyService.java +++ b/java/src/hexacloud/infra/server/ReverseProxyService.java @@ -44,6 +44,11 @@ public void proxyRequest(HttpRequest req, HttpResponse res, Cluster targetCluste 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; @@ -67,7 +72,8 @@ public void proxyRequest(HttpRequest req, HttpResponse res, Cluster targetCluste try (InputStream bodyIn = req.getBody()) { ProxyResponse response = proxyClient.execute(targetUrl, req.getMethod(), headers, bodyIn, timeoutMs); - long latencyMs = System.currentTimeMillis() - startTime; + long startTimeForTelemetry = System.currentTimeMillis(); + long latencyMs = startTimeForTelemetry - startTime; // Passive Telemetry extraction Double cpuVal = parseHeaderDouble(response.headers(), "X-Telemetry-CPU", "X-Node-CPU"); @@ -83,7 +89,11 @@ public void proxyRequest(HttpRequest req, HttpResponse res, Cluster targetCluste // Forward headers for (Map.Entry> entry : response.headers().entrySet()) { String key = entry.getKey(); - if (key == null || key.equalsIgnoreCase("Transfer-Encoding") || key.equalsIgnoreCase("Content-Length")) { + 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()) { From 084e4ad6dd5cbef91d28dff3ccf850281d57a636 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:22:32 -0300 Subject: [PATCH 34/43] perf(undertow): dispatch request processing to Loom virtual thread executor to prevent platform worker thread starvation under blocking L7 routing calls --- .../hexacloud/infra/server/UndertowHttpTransport.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index 3727ab5..b48c955 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -20,6 +20,7 @@ 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 java.io.PrintWriter; import java.util.List; @@ -30,6 +31,7 @@ public class UndertowHttpTransport implements ServerTransport { private Undertow server; private boolean running = false; + 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); @@ -119,12 +121,13 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis .setWorkerThreads(Runtime.getRuntime().availableProcessors() * 2); } + virtualExecutor = ThreadManager.newVirtualThreadPool(); + builder.setHandler(new HttpHandler() { @Override public void handleRequest(HttpServerExchange exchange) throws Exception { if (exchange.isInIoThread()) { - java.util.concurrent.Executor executor = exchange.getConnection().getWorker(); - exchange.dispatch(executor, () -> { + exchange.dispatch(virtualExecutor, () -> { try { processRequest(exchange, registry); } catch (Exception e) { @@ -227,6 +230,9 @@ public void stop() { if (server != null) { server.stop(); running = false; + if (virtualExecutor != null) { + virtualExecutor.shutdown(); + } DebugUtils.info("HTTP Transport (Undertow) stopped."); } } From a6302fb3d17fdde13ffa1d6d5b0e65096fface2c Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:25:50 -0300 Subject: [PATCH 35/43] fix(undertow): use heap buffers and disable thread-local cache in DefaultByteBufferPool to prevent direct memory OOM leaks under virtual threads load --- java/src/hexacloud/infra/server/UndertowHttpTransport.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index b48c955..54aab1a 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -85,10 +85,10 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis try { rebuildFilters(clusters, customFilters); io.undertow.connector.ByteBufferPool bufferPool = new io.undertow.server.DefaultByteBufferPool( - true, + false, 16384, -1, - 24, + 0, 0 ); Undertow.Builder builder = Undertow.builder() From 0d7e1435df77f5f372d60f0d134f3db2f6bd6d50 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:29:58 -0300 Subject: [PATCH 36/43] fix(undertow): restore threadLocalCacheSize to 24 while maintaining Heap buffers to avoid thread pool lock contention under high concurrency --- java/src/hexacloud/infra/server/UndertowHttpTransport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index 54aab1a..46dc5eb 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -88,7 +88,7 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis false, 16384, -1, - 0, + 24, 0 ); Undertow.Builder builder = Undertow.builder() From e324624d3d9cf26469cb46631a8ff4826b5b971b Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:33:36 -0300 Subject: [PATCH 37/43] fix(undertow): tune DefaultByteBufferPool buffer size to 8KB and cache size to 2 to eliminate virtual thread ThreadLocal memory footprint bloat under high load --- java/src/hexacloud/infra/server/UndertowHttpTransport.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index 46dc5eb..4f93ee4 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -86,9 +86,9 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis rebuildFilters(clusters, customFilters); io.undertow.connector.ByteBufferPool bufferPool = new io.undertow.server.DefaultByteBufferPool( false, - 16384, + 8192, -1, - 24, + 2, 0 ); Undertow.Builder builder = Undertow.builder() From 07e824ec719e0641031971d95955fae83b877cab Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:45:00 -0300 Subject: [PATCH 38/43] perf(undertow): restore non-blocking FastPrintWriter fast-path for local custom routes to bypass virtual threads and blocking I/O stream overhead --- .../infra/server/UndertowHttpTransport.java | 110 +++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index 4f93ee4..b91dc83 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -126,6 +126,18 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis 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() + && (activeFilters.isEmpty() || (activeFilters.size() == 1 && activeFilters.get(0) instanceof CorsFilter)); + + if (canUseFastPath) { + processRequest(exchange, registry); + return; + } + if (exchange.isInIoThread()) { exchange.dispatch(virtualExecutor, () -> { try { @@ -152,10 +164,47 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { private void processRequest(HttpServerExchange exchange, RouteRegistry registry) { try { UndertowHttpRequestImpl req = new UndertowHttpRequestImpl(exchange); - UndertowHttpResponseImpl res = new UndertowHttpResponseImpl(exchange); - RouteResolution resolution = PathResolver.resolve(req.getPath(), req.getHeader("Host"), registry); + boolean canUseFastPath = resolution.isLocal() + && (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); + BiConsumer routeHandler = (r, s) -> { try { if (resolution.isProxy()) { @@ -241,4 +290,61 @@ public void stop() { public boolean isRunning() { return running; } + + 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 { + final StringBuilder sb = new StringBuilder(512); + + @Override + public void write(char[] cbuf, int off, int len) { + sb.append(cbuf, off, len); + } + + @Override + public void write(String str, int off, int len) { + sb.append(str, off, off + len); + } + + @Override + public void write(int c) { + sb.append((char)c); + } + + @Override + public void flush() {} + + @Override + public void close() {} + + void reset() { + sb.setLength(0); + } + + byte[] toBytes() { + return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + } + + private final StringBuilderWriter sbw; + + public FastPrintWriter() { + this(new StringBuilderWriter()); + } + + private FastPrintWriter(StringBuilderWriter sbw) { + super(sbw); + this.sbw = sbw; + } + + public void reset() { + sbw.reset(); + clearError(); + } + + public byte[] toBytes() { + return sbw.toBytes(); + } + } } From 66267806449695fea6841a4b2946c8d60d02f44f Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:49:22 -0300 Subject: [PATCH 39/43] perf(transport): restore non-blocking fast-path for local routes in JDK HttpTransport and reuse pooled byte arrays for L7 proxy stream copying in ReverseProxyService --- .../hexacloud/infra/server/HttpTransport.java | 35 +++++++++++++++++++ .../infra/server/ReverseProxyService.java | 18 +++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index e3400c8..f2f1381 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -105,7 +105,42 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis server.createContext("/", new HttpHandler() { @Override public void handle(HttpExchange exchange) throws IOException { + // CORS Configuration + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE"); + exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "X-Cluster-Token, Content-Type, Authorization"); + + if ("OPTIONS".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(204, -1); + return; + } + try { + 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() + && (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"); + } + exchange.sendResponseHeaders(200, 0); + 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 : ""; + handler.accept(args, out); + } + return; + } + } + HttpRequestImpl req = new HttpRequestImpl(exchange); HttpResponseImpl res = new HttpResponseImpl(exchange); diff --git a/java/src/hexacloud/infra/server/ReverseProxyService.java b/java/src/hexacloud/infra/server/ReverseProxyService.java index 8fd144f..e0270ac 100644 --- a/java/src/hexacloud/infra/server/ReverseProxyService.java +++ b/java/src/hexacloud/infra/server/ReverseProxyService.java @@ -19,6 +19,7 @@ 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(); @@ -102,12 +103,19 @@ public void proxyRequest(HttpRequest req, HttpResponse res, Cluster targetCluste } try (InputStream in = response.bodyStream(); OutputStream out = res.getOutputStream()) { - byte[] buffer = new byte[8192]; - int bytesRead; - while ((bytesRead = in.read(buffer)) != -1) { - out.write(buffer, 0, bytesRead); + 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); } - out.flush(); } } catch (Exception e) { DebugUtils.error("ReverseProxyService: Proxy request failed to " + targetUrl, e); From b09a01d46520d42e2c92ac75eb5f920e7694246b Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 07:54:54 -0300 Subject: [PATCH 40/43] refactor(transport): remove bypass fast-path to guarantee unified filter chain execution and full virtual threads execution for all routes, protecting event loops from developer blocking code --- .../hexacloud/infra/server/HttpTransport.java | 35 ------------ .../infra/server/UndertowHttpTransport.java | 53 +------------------ 2 files changed, 2 insertions(+), 86 deletions(-) diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index f2f1381..e3400c8 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -105,42 +105,7 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis server.createContext("/", new HttpHandler() { @Override public void handle(HttpExchange exchange) throws IOException { - // CORS Configuration - exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); - exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE"); - exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "X-Cluster-Token, Content-Type, Authorization"); - - if ("OPTIONS".equals(exchange.getRequestMethod())) { - exchange.sendResponseHeaders(204, -1); - return; - } - try { - 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() - && (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"); - } - exchange.sendResponseHeaders(200, 0); - 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 : ""; - handler.accept(args, out); - } - return; - } - } - HttpRequestImpl req = new HttpRequestImpl(exchange); HttpResponseImpl res = new HttpResponseImpl(exchange); diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index b91dc83..456985c 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -126,18 +126,6 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis 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() - && (activeFilters.isEmpty() || (activeFilters.size() == 1 && activeFilters.get(0) instanceof CorsFilter)); - - if (canUseFastPath) { - processRequest(exchange, registry); - return; - } - if (exchange.isInIoThread()) { exchange.dispatch(virtualExecutor, () -> { try { @@ -164,47 +152,10 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { 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() - && (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); + RouteResolution resolution = PathResolver.resolve(req.getPath(), req.getHeader("Host"), registry); + BiConsumer routeHandler = (r, s) -> { try { if (resolution.isProxy()) { From 87983e10ac0904ad5b4826a9c91f7d88403a1728 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 08:00:48 -0300 Subject: [PATCH 41/43] feat(route): add support for explicit fastPath property in RouteMapping annotation to run specific non-blocking local routes on the event loop --- .../core/server/route/RouteMapping.java | 1 + .../core/server/route/RouteRegistry.java | 9 +++ .../hexacloud/infra/server/HttpTransport.java | 36 ++++++++++++ .../infra/server/UndertowHttpTransport.java | 55 ++++++++++++++++++- .../core/server/route/PathResolverTest.java | 23 ++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) 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 469ca9a..6674fe7 100644 --- a/java/src/hexacloud/core/server/route/RouteRegistry.java +++ b/java/src/hexacloud/core/server/route/RouteRegistry.java @@ -22,6 +22,7 @@ public RouteRegistry(String 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) { @@ -43,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; @@ -54,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) { diff --git a/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index e3400c8..c02a1c2 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -105,7 +105,43 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis server.createContext("/", new HttpHandler() { @Override public void handle(HttpExchange exchange) throws IOException { + // CORS Configuration + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE"); + exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "X-Cluster-Token, Content-Type, Authorization"); + + if ("OPTIONS".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(204, -1); + return; + } + try { + 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"); + } + exchange.sendResponseHeaders(200, 0); + 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 : ""; + handler.accept(args, out); + } + return; + } + } + HttpRequestImpl req = new HttpRequestImpl(exchange); HttpResponseImpl res = new HttpResponseImpl(exchange); diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index 456985c..cf4a354 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -126,6 +126,19 @@ public void listen(int port, RouteRegistry registry, List clusters, Lis 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; + } + if (exchange.isInIoThread()) { exchange.dispatch(virtualExecutor, () -> { try { @@ -152,10 +165,48 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { private void processRequest(HttpServerExchange exchange, RouteRegistry registry) { try { UndertowHttpRequestImpl req = new UndertowHttpRequestImpl(exchange); - UndertowHttpResponseImpl res = new UndertowHttpResponseImpl(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); + BiConsumer routeHandler = (r, s) -> { try { if (resolution.isProxy()) { diff --git a/java/test/hexacloud/core/server/route/PathResolverTest.java b/java/test/hexacloud/core/server/route/PathResolverTest.java index a367121..12e0bb5 100644 --- a/java/test/hexacloud/core/server/route/PathResolverTest.java +++ b/java/test/hexacloud/core/server/route/PathResolverTest.java @@ -27,4 +27,27 @@ public void testResolveProxyWithVersionPrefix() { 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())); + } } From e4a6bc74c7d6ce45f73726704f362bcd90a1ffd9 Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 08:08:23 -0300 Subject: [PATCH 42/43] perf(writer): remove BufferedWriter wrapper from http responses to avoid redundant 16KB array allocations per request, reducing GC overhead and latency under high concurrency --- java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java | 2 +- java/src/hexacloud/infra/server/filter/HttpResponseImpl.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java b/java/src/hexacloud/infra/server/UndertowHttpResponseImpl.java index 1a54d0b..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; } diff --git a/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java b/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java index 63d1985..7bd703a 100644 --- a/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java +++ b/java/src/hexacloud/infra/server/filter/HttpResponseImpl.java @@ -38,7 +38,7 @@ 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 From 3a9acfacaa605c18c86914549f853b725f0e42ed Mon Sep 17 00:00:00 2001 From: watashi-00 Date: Wed, 29 Jul 2026 09:21:20 -0300 Subject: [PATCH 43/43] refactor(HttpTransport): extract route execution logic into separate method refactor(UndertowHttpTransport): extract route execution logic into separate method fix(.gitignore): add .tmp to ignored files chore(pom.xml): update version from 1.4.9-SNAPSHOT to 1.4.9-release --- .gitignore | 3 +- .../hexacloud/infra/server/HttpTransport.java | 104 +++++++++------- .../infra/server/ReverseProxyService.java | 1 - .../infra/server/UndertowHttpTransport.java | 115 +++++++++++------- pom.xml | 2 +- 5 files changed, 136 insertions(+), 89 deletions(-) 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/java/src/hexacloud/infra/server/HttpTransport.java b/java/src/hexacloud/infra/server/HttpTransport.java index c02a1c2..2d771d2 100644 --- a/java/src/hexacloud/infra/server/HttpTransport.java +++ b/java/src/hexacloud/infra/server/HttpTransport.java @@ -141,56 +141,29 @@ public void handle(HttpExchange exchange) throws IOException { return; } } - HttpRequestImpl req = new HttpRequestImpl(exchange); HttpResponseImpl res = new HttpResponseImpl(exchange); RouteResolution resolution = PathResolver.resolve(req.getPath(), req.getHeader("Host"), registry); + // 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); + return; + } + + executeRoute(req, res, resolution, registry); + return; + } + BiConsumer routeHandler = (r, s) -> { try { - 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; - } - - 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()); - } + executeRoute(r, s, resolution, registry); } catch (Exception e) { throw new RuntimeException(e); } @@ -217,6 +190,51 @@ public void handle(HttpExchange exchange) throws IOException { } } + 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; + } + + 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()); + } + } + @Override public void stop() { if(server != null) { diff --git a/java/src/hexacloud/infra/server/ReverseProxyService.java b/java/src/hexacloud/infra/server/ReverseProxyService.java index e0270ac..aae8e9f 100644 --- a/java/src/hexacloud/infra/server/ReverseProxyService.java +++ b/java/src/hexacloud/infra/server/ReverseProxyService.java @@ -12,7 +12,6 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; diff --git a/java/src/hexacloud/infra/server/UndertowHttpTransport.java b/java/src/hexacloud/infra/server/UndertowHttpTransport.java index cf4a354..325d68c 100644 --- a/java/src/hexacloud/infra/server/UndertowHttpTransport.java +++ b/java/src/hexacloud/infra/server/UndertowHttpTransport.java @@ -207,50 +207,28 @@ private void processRequest(HttpServerExchange exchange, RouteRegistry registry) 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 { - 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; - } - - 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()); - } + executeRoute(r, s, resolution, registry); } catch (Exception e) { throw new RuntimeException(e); } @@ -262,7 +240,58 @@ private void processRequest(HttpServerExchange exchange, RouteRegistry registry) 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 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; + } + + 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()); } } diff --git a/pom.xml b/pom.xml index e10698e..f0ed683 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.hexacloud gatebridge-core - 1.4.9-SNAPSHOT + 1.4.9-release jar GateBridge Core Framework