diff --git a/README.md b/README.md index d54ff44..89e7251 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,26 @@ This project implements a minimal viable version of OpenClaw in Java, focusing o - `src/main/java/ai/openclaw/channel` - Console channel implementation - `src/main/java/ai/openclaw/config` - Configuration loader - `src/main/java/ai/openclaw/session` - Session storage + +## Running with Docker + +You can run the application in a Docker container for an isolated environment. + +1. **Build the Docker image**: + ```bash + docker build -t openclaw-java . + ``` + +2. **Run the container**: + You must provide your Anthropic API key as an environment variable. + ```bash + docker run -it --rm \ + -e ANTHROPIC_API_KEY=sk-ant-... \ + -p 18789:18789 \ + openclaw-java + ``` + + The container runs as a non-root user (`openclaw`) for security. + - Code execution is confined to `/home/openclaw/workspace`. + - File access is restricted to the workspace directory. + - Network access to internal/private IPs is blocked. diff --git a/src/main/java/ai/openclaw/cli/GatewayCommand.java b/src/main/java/ai/openclaw/cli/GatewayCommand.java index 5306c09..1834da2 100644 --- a/src/main/java/ai/openclaw/cli/GatewayCommand.java +++ b/src/main/java/ai/openclaw/cli/GatewayCommand.java @@ -26,7 +26,7 @@ public class GatewayCommand implements Runnable { public void run() { try { // 1. Load Config - System.out.println("Reading config from ~/.openclaw-java/config.json"); + System.out.println("Reading config from ~/.openclaw-java/config.json or Environment Variables"); OpenClawConfig config = ConfigLoader.load(); // 2. Initialize Components diff --git a/src/main/java/ai/openclaw/config/ConfigLoader.java b/src/main/java/ai/openclaw/config/ConfigLoader.java index 01dfc05..05cbb57 100644 --- a/src/main/java/ai/openclaw/config/ConfigLoader.java +++ b/src/main/java/ai/openclaw/config/ConfigLoader.java @@ -10,9 +10,48 @@ public class ConfigLoader { public static OpenClawConfig load() throws IOException { File configFile = CONFIG_PATH.toFile(); - if (!configFile.exists()) { - throw new IOException("Config file not found: " + CONFIG_PATH); + OpenClawConfig config; + + if (configFile.exists()) { + config = Json.mapper().readValue(configFile, OpenClawConfig.class); + } else { + String apiKey = System.getenv("ANTHROPIC_API_KEY"); + if (apiKey != null && !apiKey.isEmpty()) { + config = new OpenClawConfig(); + config.setGateway(new OpenClawConfig.GatewayConfig()); + config.setAgent(new OpenClawConfig.AgentConfig()); + config.getAgent().setApiKey(apiKey); + } else { + throw new IOException( + "Config file not found: " + CONFIG_PATH + " and ANTHROPIC_API_KEY env var is not set."); + } + } + + // Allow environment variables to override config + String envKey = System.getenv("ANTHROPIC_API_KEY"); + if (envKey != null && !envKey.isEmpty()) { + if (config.getAgent() == null) + config.setAgent(new OpenClawConfig.AgentConfig()); + config.getAgent().setApiKey(envKey); + } + + String envPort = System.getenv("GATEWAY_PORT"); + if (envPort != null && !envPort.isEmpty()) { + if (config.getGateway() == null) + config.setGateway(new OpenClawConfig.GatewayConfig()); + try { + config.getGateway().setPort(Integer.parseInt(envPort)); + } catch (NumberFormatException ignored) { + } + } + + String envToken = System.getenv("GATEWAY_AUTH_TOKEN"); + if (envToken != null && !envToken.isEmpty()) { + if (config.getGateway() == null) + config.setGateway(new OpenClawConfig.GatewayConfig()); + config.getGateway().setAuthToken(envToken); } - return Json.mapper().readValue(configFile, OpenClawConfig.class); + + return config; } } diff --git a/src/main/java/ai/openclaw/gateway/GatewayServer.java b/src/main/java/ai/openclaw/gateway/GatewayServer.java index b3967b8..6b905bd 100644 --- a/src/main/java/ai/openclaw/gateway/GatewayServer.java +++ b/src/main/java/ai/openclaw/gateway/GatewayServer.java @@ -11,35 +11,90 @@ import org.slf4j.LoggerFactory; import java.net.InetSocketAddress; -import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class GatewayServer extends WebSocketServer { private static final Logger logger = LoggerFactory.getLogger(GatewayServer.class); private final OpenClawConfig config; private final ObjectMapper mapper = Json.mapper(); - private final Map clients = new ConcurrentHashMap<>(); + private final Set authenticatedClients = java.util.Collections.newSetFromMap(new ConcurrentHashMap<>()); private final RpcRouter router; public GatewayServer(OpenClawConfig config, RpcRouter router) { - super(new InetSocketAddress("127.0.0.1", config.getGateway().getPort())); + super(new InetSocketAddress("0.0.0.0", config.getGateway().getPort())); this.config = config; this.router = router; } @Override public void onOpen(WebSocket conn, ClientHandshake handshake) { - logger.info("New connection from {}", conn.getRemoteSocketAddress()); - // Simple auth check could be added here + String remoteAddress = conn.getRemoteSocketAddress().toString(); + logger.info("New connection from {}", remoteAddress); + + String expectedToken = config.getGateway() != null ? config.getGateway().getAuthToken() : null; + if (expectedToken == null || expectedToken.isEmpty()) { + logger.warn("No auth token configured! Accepting connection from {}", remoteAddress); + authenticatedClients.add(conn); + return; + } + + String providedToken = extractToken(handshake); + if (providedToken == null || !constantTimeEquals(expectedToken, providedToken)) { + logger.warn("Unauthorized connection attempt from {}", remoteAddress); + // Close with policy violation code (1008) or normal code (1000) with reason + conn.close(1008, "Unauthorized"); + return; + } + + logger.info("Authenticated connection from {}", remoteAddress); + authenticatedClients.add(conn); + } + + private String extractToken(ClientHandshake handshake) { + // 1. Check Authorization header + String authHeader = handshake.getFieldValue("Authorization"); + if (authHeader != null && authHeader.toLowerCase().startsWith("bearer ")) { + return authHeader.substring(7).trim(); + } + + // 2. Check query parameter + String descriptor = handshake.getResourceDescriptor(); + int index = descriptor.indexOf("?token="); + if (index == -1) index = descriptor.indexOf("&token="); + + if (index != -1) { + String token = descriptor.substring(index + 7); + int end = token.indexOf('&'); + if (end != -1) { + token = token.substring(0, end); + } + return token; + } + return null; + } + + /** Constant-time string comparison to prevent timing attacks. */ + private boolean constantTimeEquals(String a, String b) { + return java.security.MessageDigest.isEqual( + a.getBytes(java.nio.charset.StandardCharsets.UTF_8), + b.getBytes(java.nio.charset.StandardCharsets.UTF_8)); } @Override public void onClose(WebSocket conn, int code, String reason, boolean remote) { - logger.info("Closed connection: {}", conn.getRemoteSocketAddress()); + String remoteAddress = conn.getRemoteSocketAddress().toString(); + logger.info("Closed connection: {}", remoteAddress); + authenticatedClients.remove(conn); } @Override public void onMessage(WebSocket conn, String message) { + if (!authenticatedClients.contains(conn)) { + logger.warn("Message from unauthenticated connection, ignoring"); + return; + } + try { RpcProtocol.RpcMessage request = mapper.readValue(message, RpcProtocol.RpcMessage.class); diff --git a/src/test/java/ai/openclaw/e2e/GatewayE2ETest.java b/src/test/java/ai/openclaw/e2e/GatewayE2ETest.java index 0febe2b..3d1c3b0 100644 --- a/src/test/java/ai/openclaw/e2e/GatewayE2ETest.java +++ b/src/test/java/ai/openclaw/e2e/GatewayE2ETest.java @@ -108,6 +108,10 @@ public void onError(Exception ex) { } }; + final String authToken = "test-token"; // Use the known token directly or capture from config + if (authToken != null) { + client.addHeader("Authorization", "Bearer " + authToken); + } assertTrue(client.connectBlocking(5, TimeUnit.SECONDS)); // Send request