001/* 002 * Copyright 2025 devteam@scivics-lab.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, 011 * software distributed under the License is distributed on an 012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 013 * either express or implied. See the License for the 014 * specific language governing permissions and limitations 015 * under the License. 016 */ 017 018package com.scivicslab.actoriac; 019 020import java.io.BufferedReader; 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.InputStreamReader; 024 025import com.jcraft.jsch.ChannelExec; 026import com.jcraft.jsch.JSch; 027import com.jcraft.jsch.JSchException; 028import com.jcraft.jsch.Session; 029 030/** 031 * Represents a single node in the infrastructure as a pure POJO. 032 * 033 * <p>This is a pure POJO class that provides SSH-based command execution 034 * capabilities. It has NO dependency on ActorSystem or workflow components.</p> 035 * 036 * <h2>Three Levels of Usage</h2> 037 * 038 * <h3>Level 1: Pure POJO (Synchronous)</h3> 039 * <pre>{@code 040 * Node node = new Node("192.168.1.1", "admin"); 041 * CommandResult result = node.executeCommand("show version"); 042 * System.out.println(result.getStdout()); 043 * }</pre> 044 * 045 * <h3>Level 2: Actor-based (Asynchronous, Parallel)</h3> 046 * <pre>{@code 047 * ActorSystem system = new ActorSystem("iac", 4); 048 * ActorRef<Node> nodeActor = system.actorOf("node1", node); 049 * CompletableFuture<CommandResult> future = nodeActor.ask(n -> n.executeCommand("show version")); 050 * }</pre> 051 * 052 * <h3>Level 3: Workflow-based (YAML/JSON/XML)</h3> 053 * <pre>{@code 054 * // Use NodeInterpreter instead for workflow capabilities 055 * NodeInterpreter nodeInterpreter = new NodeInterpreter(node, system); 056 * IIActorRef<NodeInterpreter> nodeActor = new IIActorRef<>("node1", nodeInterpreter, system); 057 * }</pre> 058 * 059 * <p>Uses ssh-agent for SSH key authentication. Make sure ssh-agent is running 060 * and your SSH key is added before using this class.</p> 061 * 062 * @author devteam@scivics-lab.com 063 */ 064public class Node { 065 066 private final String hostname; 067 private final String user; 068 private final int port; 069 private final boolean localMode; 070 private final String password; 071 072 // Jump host session (kept open for the duration of the connection) 073 private Session jumpHostSession = null; 074 075 /** 076 * Constructs a Node with the specified connection parameters (POJO constructor). 077 * 078 * @param hostname the hostname or IP address of the node 079 * @param user the SSH username 080 * @param port the SSH port (typically 22) 081 */ 082 public Node(String hostname, String user, int port) { 083 this(hostname, user, port, false, null); 084 } 085 086 /** 087 * Constructs a Node with the specified connection parameters and local mode. 088 * 089 * @param hostname the hostname or IP address of the node 090 * @param user the SSH username 091 * @param port the SSH port (typically 22) 092 * @param localMode if true, execute commands locally instead of via SSH 093 */ 094 public Node(String hostname, String user, int port, boolean localMode) { 095 this(hostname, user, port, localMode, null); 096 } 097 098 /** 099 * Constructs a Node with all connection parameters including password. 100 * 101 * @param hostname the hostname or IP address of the node 102 * @param user the SSH username 103 * @param port the SSH port (typically 22) 104 * @param localMode if true, execute commands locally instead of via SSH 105 * @param password the SSH password (null to use ssh-agent key authentication) 106 */ 107 public Node(String hostname, String user, int port, boolean localMode, String password) { 108 this.hostname = hostname; 109 this.user = user; 110 this.port = port; 111 this.localMode = localMode; 112 this.password = password; 113 } 114 115 /** 116 * Constructs a Node with default port 22 (POJO constructor). 117 * 118 * @param hostname the hostname or IP address of the node 119 * @param user the SSH username 120 */ 121 public Node(String hostname, String user) { 122 this(hostname, user, 22, false, null); 123 } 124 125 /** 126 * Checks if this node is in local execution mode. 127 * 128 * @return true if commands are executed locally, false for SSH 129 */ 130 public boolean isLocalMode() { 131 return localMode; 132 } 133 134 /** 135 * Executes a command on the node. 136 * 137 * <p>If localMode is true, executes the command locally using ProcessBuilder. 138 * Otherwise, executes via SSH using JSch.</p> 139 * 140 * @param command the command to execute 141 * @return the execution result containing stdout, stderr, and exit code 142 * @throws IOException if command execution fails 143 */ 144 public CommandResult executeCommand(String command) throws IOException { 145 if (localMode) { 146 return executeLocalCommand(command); 147 } 148 return executeRemoteCommand(command); 149 } 150 151 /** 152 * Executes a command locally using ProcessBuilder with real-time streaming. 153 * 154 * <p>Output is streamed to System.out/System.err in real-time as it becomes available, 155 * while also being captured for the CommandResult.</p> 156 * 157 * @param command the command to execute 158 * @return the execution result 159 * @throws IOException if command execution fails 160 */ 161 private CommandResult executeLocalCommand(String command) throws IOException { 162 ProcessBuilder pb = new ProcessBuilder("bash", "-c", command); 163 Process process = pb.start(); 164 165 StringBuilder stdoutBuilder = new StringBuilder(); 166 StringBuilder stderrBuilder = new StringBuilder(); 167 168 // Read stderr in separate thread to avoid deadlock 169 Thread stderrThread = new Thread(() -> { 170 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { 171 String line; 172 while ((line = reader.readLine()) != null) { 173 synchronized (stderrBuilder) { 174 stderrBuilder.append(line).append("\n"); 175 } 176 System.err.println(line); 177 } 178 } catch (IOException e) { 179 // Ignore 180 } 181 }); 182 stderrThread.start(); 183 184 // Read stdout with real-time streaming 185 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { 186 String line; 187 while ((line = reader.readLine()) != null) { 188 stdoutBuilder.append(line).append("\n"); 189 System.out.println(line); 190 } 191 } 192 193 try { 194 stderrThread.join(); 195 int exitCode = process.waitFor(); 196 return new CommandResult( 197 stdoutBuilder.toString().trim(), 198 stderrBuilder.toString().trim(), 199 exitCode 200 ); 201 } catch (InterruptedException e) { 202 Thread.currentThread().interrupt(); 203 throw new IOException("Command execution interrupted", e); 204 } 205 } 206 207 /** 208 * Executes a command on the remote node via SSH using JSch with real-time streaming. 209 * 210 * <p>Output is streamed to System.out/System.err in real-time as it becomes available, 211 * while also being captured for the CommandResult.</p> 212 * 213 * @param command the command to execute 214 * @return the execution result containing stdout, stderr, and exit code 215 * @throws IOException if SSH connection or command execution fails 216 */ 217 private CommandResult executeRemoteCommand(String command) throws IOException { 218 Session session = null; 219 ChannelExec channel = null; 220 221 try { 222 // Create JSch session 223 session = createSession(); 224 session.connect(); 225 226 // Open exec channel 227 channel = (ChannelExec) session.openChannel("exec"); 228 channel.setCommand(command); 229 230 // Get streams before connecting 231 InputStream stdout = channel.getInputStream(); 232 InputStream stderr = channel.getErrStream(); 233 234 // Connect channel 235 channel.connect(); 236 237 StringBuilder stdoutBuilder = new StringBuilder(); 238 StringBuilder stderrBuilder = new StringBuilder(); 239 240 // Read stderr in separate thread to avoid deadlock 241 final InputStream stderrFinal = stderr; 242 Thread stderrThread = new Thread(() -> { 243 try (BufferedReader reader = new BufferedReader(new InputStreamReader(stderrFinal))) { 244 String line; 245 while ((line = reader.readLine()) != null) { 246 synchronized (stderrBuilder) { 247 stderrBuilder.append(line).append("\n"); 248 } 249 System.err.println(line); 250 } 251 } catch (IOException e) { 252 // Ignore 253 } 254 }); 255 stderrThread.start(); 256 257 // Read stdout with real-time streaming 258 try (BufferedReader reader = new BufferedReader(new InputStreamReader(stdout))) { 259 String line; 260 while ((line = reader.readLine()) != null) { 261 stdoutBuilder.append(line).append("\n"); 262 System.out.println(line); 263 } 264 } 265 266 // Wait for stderr thread 267 stderrThread.join(); 268 269 // Wait for channel to close 270 while (!channel.isClosed()) { 271 try { 272 Thread.sleep(100); 273 } catch (InterruptedException e) { 274 Thread.currentThread().interrupt(); 275 throw new IOException("Command execution interrupted", e); 276 } 277 } 278 279 int exitCode = channel.getExitStatus(); 280 281 return new CommandResult( 282 stdoutBuilder.toString().trim(), 283 stderrBuilder.toString().trim(), 284 exitCode 285 ); 286 287 } catch (JSchException e) { 288 throw new IOException("SSH connection failed: " + e.getMessage(), e); 289 } catch (InterruptedException e) { 290 Thread.currentThread().interrupt(); 291 throw new IOException("Command execution interrupted", e); 292 } finally { 293 if (channel != null && channel.isConnected()) { 294 channel.disconnect(); 295 } 296 if (session != null && session.isConnected()) { 297 session.disconnect(); 298 } 299 // Clean up jump host session if used 300 if (jumpHostSession != null && jumpHostSession.isConnected()) { 301 jumpHostSession.disconnect(); 302 jumpHostSession = null; 303 } 304 } 305 } 306 307 private static final String SUDO_PASSWORD_ENV = "SUDO_PASSWORD"; 308 309 /** 310 * Executes a command with sudo privileges on the remote node. 311 * 312 * <p>Reads the sudo password from the SUDO_PASSWORD environment variable. 313 * If the environment variable is not set, throws an IOException.</p> 314 * 315 * <p>Multi-line scripts are properly handled by wrapping them in bash -c.</p> 316 * 317 * @param command the command to execute with sudo 318 * @return the execution result containing stdout, stderr, and exit code 319 * @throws IOException if SSH connection fails or SUDO_PASSWORD is not set 320 */ 321 public CommandResult executeSudoCommand(String command) throws IOException { 322 String sudoPassword = System.getenv(SUDO_PASSWORD_ENV); 323 if (sudoPassword == null || sudoPassword.isEmpty()) { 324 throw new IOException("SUDO_PASSWORD environment variable is not set"); 325 } 326 327 // Escape single quotes in command for bash -c 328 String escapedCommand = command.replace("'", "'\"'\"'"); 329 330 // Use sudo -S to read password from stdin, wrap command in bash -c for multi-line support 331 String sudoCommand = String.format("echo '%s' | sudo -S bash -c '%s'", 332 sudoPassword.replace("'", "'\\''"), escapedCommand); 333 334 return executeCommand(sudoCommand); 335 } 336 337 /** 338 * Creates a JSch SSH session with configured credentials. 339 * Supports ProxyJump for connections through a jump host. 340 * 341 * @return configured but not yet connected Session 342 * @throws JSchException if session creation fails 343 * @throws IOException if SSH key file operations fail 344 */ 345 private Session createSession() throws JSchException, IOException { 346 JSch jsch = new JSch(); 347 348 // Load OpenSSH config file first (for user/hostname/port/IdentityFile/ProxyJump) 349 com.jcraft.jsch.ConfigRepository configRepository = null; 350 String identityFileFromConfig = null; 351 String proxyJump = null; 352 try { 353 String sshConfigPath = System.getProperty("user.home") + "/.ssh/config"; 354 java.io.File configFile = new java.io.File(sshConfigPath); 355 if (configFile.exists()) { 356 com.jcraft.jsch.OpenSSHConfig openSSHConfig = 357 com.jcraft.jsch.OpenSSHConfig.parseFile(sshConfigPath); 358 configRepository = openSSHConfig; 359 360 // Get IdentityFile and ProxyJump from config for this host 361 com.jcraft.jsch.ConfigRepository.Config hostConfig = openSSHConfig.getConfig(hostname); 362 if (hostConfig != null) { 363 identityFileFromConfig = hostConfig.getValue("IdentityFile"); 364 // Expand ~ to home directory 365 if (identityFileFromConfig != null && identityFileFromConfig.startsWith("~")) { 366 identityFileFromConfig = System.getProperty("user.home") + 367 identityFileFromConfig.substring(1); 368 } 369 // Get ProxyJump setting 370 proxyJump = hostConfig.getValue("ProxyJump"); 371 } 372 } 373 } catch (Exception e) { 374 // If config loading fails, continue without it 375 } 376 377 // Setup authentication 378 setupAuthentication(jsch, identityFileFromConfig); 379 380 // Get effective connection parameters from SSH config 381 String effectiveUser = user; 382 String effectiveHostname = hostname; 383 int effectivePort = port; 384 385 if (configRepository != null) { 386 com.jcraft.jsch.ConfigRepository.Config config = configRepository.getConfig(hostname); 387 if (config != null) { 388 // Override with SSH config values if present 389 String configUser = config.getUser(); 390 if (configUser != null) { 391 effectiveUser = configUser; 392 } 393 String configHostname = config.getHostname(); 394 if (configHostname != null) { 395 effectiveHostname = configHostname; 396 } 397 String configPort = config.getValue("Port"); 398 if (configPort != null) { 399 try { 400 effectivePort = Integer.parseInt(configPort); 401 } catch (NumberFormatException e) { 402 // Keep original port 403 } 404 } 405 } 406 } 407 408 Session session; 409 410 // Handle ProxyJump if configured 411 if (proxyJump != null && !proxyJump.isEmpty()) { 412 session = createSessionViaProxyJump(jsch, proxyJump, effectiveUser, effectiveHostname, effectivePort); 413 } else { 414 // Direct connection 415 session = jsch.getSession(effectiveUser, effectiveHostname, effectivePort); 416 } 417 418 // Set password if using password authentication 419 if (password != null && !password.isEmpty()) { 420 session.setPassword(password); 421 } 422 423 // Disable strict host key checking (for convenience) 424 session.setConfig("StrictHostKeyChecking", "no"); 425 426 return session; 427 } 428 429 /** 430 * Sets up authentication for JSch. 431 */ 432 private void setupAuthentication(JSch jsch, String identityFileFromConfig) throws IOException, JSchException { 433 if (password != null && !password.isEmpty()) { 434 // Password authentication - no ssh-agent needed 435 return; 436 } 437 438 boolean authConfigured = false; 439 440 // Priority 1: Try ssh-agent first (supports Ed25519 and other modern key types) 441 try { 442 com.jcraft.jsch.IdentityRepository repo = 443 new com.jcraft.jsch.AgentIdentityRepository(new com.jcraft.jsch.SSHAgentConnector()); 444 jsch.setIdentityRepository(repo); 445 authConfigured = true; 446 } catch (Exception e) { 447 // ssh-agent not available, will try key files directly 448 } 449 450 // Priority 2: Use IdentityFile from ~/.ssh/config (for RSA/ECDSA keys without passphrase) 451 if (!authConfigured && identityFileFromConfig != null) { 452 java.io.File keyFile = new java.io.File(identityFileFromConfig); 453 if (keyFile.exists() && keyFile.canRead()) { 454 try { 455 jsch.addIdentity(identityFileFromConfig); 456 authConfigured = true; 457 } catch (JSchException ex) { 458 // Key file may require passphrase or be unsupported type 459 } 460 } 461 } 462 463 // Priority 3: Fallback to default key files (for RSA/ECDSA keys without passphrase) 464 if (!authConfigured) { 465 String home = System.getProperty("user.home"); 466 String[] keyFiles = { 467 home + "/.ssh/id_rsa", 468 home + "/.ssh/id_ecdsa", 469 home + "/.ssh/id_dsa" 470 // Note: id_ed25519 requires ssh-agent, so not included here 471 }; 472 473 for (String keyFile : keyFiles) { 474 java.io.File f = new java.io.File(keyFile); 475 if (f.exists() && f.canRead()) { 476 try { 477 jsch.addIdentity(keyFile); 478 authConfigured = true; 479 break; 480 } catch (JSchException ex) { 481 // Key file may require passphrase, try next 482 } 483 } 484 } 485 486 if (!authConfigured) { 487 throw new IOException("SSH authentication failed: No usable authentication method found.\n" + 488 "For Ed25519 keys, please start ssh-agent: eval \"$(ssh-agent -s)\" && ssh-add\n" + 489 "Or ensure you have RSA/ECDSA keys in ~/.ssh/ without passphrase.\n" + 490 "Or use --ask-pass for password authentication."); 491 } 492 } 493 } 494 495 /** 496 * Creates a session through a jump host using ProxyJump. 497 * Format: user@host or user@host:port 498 */ 499 private Session createSessionViaProxyJump(JSch jsch, String proxyJump, 500 String targetUser, String targetHost, int targetPort) throws JSchException, IOException { 501 502 // Parse ProxyJump: user@host or user@host:port 503 String jumpUser; 504 String jumpHost; 505 int jumpPort = 22; 506 507 String[] atParts = proxyJump.split("@", 2); 508 if (atParts.length == 2) { 509 jumpUser = atParts[0]; 510 String hostPart = atParts[1]; 511 if (hostPart.contains(":")) { 512 String[] hostPortParts = hostPart.split(":", 2); 513 jumpHost = hostPortParts[0]; 514 try { 515 jumpPort = Integer.parseInt(hostPortParts[1]); 516 } catch (NumberFormatException e) { 517 jumpHost = hostPart; 518 } 519 } else { 520 jumpHost = hostPart; 521 } 522 } else { 523 // No user specified, use current user 524 jumpUser = user; 525 String hostPart = proxyJump; 526 if (hostPart.contains(":")) { 527 String[] hostPortParts = hostPart.split(":", 2); 528 jumpHost = hostPortParts[0]; 529 try { 530 jumpPort = Integer.parseInt(hostPortParts[1]); 531 } catch (NumberFormatException e) { 532 jumpHost = hostPart; 533 } 534 } else { 535 jumpHost = hostPart; 536 } 537 } 538 539 // Create and connect to jump host 540 jumpHostSession = jsch.getSession(jumpUser, jumpHost, jumpPort); 541 jumpHostSession.setConfig("StrictHostKeyChecking", "no"); 542 if (password != null && !password.isEmpty()) { 543 jumpHostSession.setPassword(password); 544 } 545 jumpHostSession.connect(); 546 547 // Set up port forwarding through jump host 548 // Find an available local port 549 int localPort = jumpHostSession.setPortForwardingL(0, targetHost, targetPort); 550 551 // Create session to target via the forwarded port 552 Session targetSession = jsch.getSession(targetUser, "127.0.0.1", localPort); 553 554 return targetSession; 555 } 556 557 /** 558 * Cleans up resources used by this Node. 559 * Closes any open jump host sessions. 560 */ 561 public void cleanup() { 562 if (jumpHostSession != null && jumpHostSession.isConnected()) { 563 jumpHostSession.disconnect(); 564 jumpHostSession = null; 565 } 566 } 567 568 /** 569 * Gets the hostname of this node. 570 * 571 * @return the hostname 572 */ 573 public String getHostname() { 574 return hostname; 575 } 576 577 /** 578 * Gets the SSH username for this node. 579 * 580 * @return the username 581 */ 582 public String getUser() { 583 return user; 584 } 585 586 /** 587 * Gets the SSH port for this node. 588 * 589 * @return the port number 590 */ 591 public int getPort() { 592 return port; 593 } 594 595 @Override 596 public String toString() { 597 return String.format("Node{hostname='%s', user='%s', port=%d}", 598 hostname, user, port); 599 } 600 601 /** 602 * Represents the result of a command execution. 603 */ 604 public static class CommandResult { 605 private final String stdout; 606 private final String stderr; 607 private final int exitCode; 608 609 public CommandResult(String stdout, String stderr, int exitCode) { 610 this.stdout = stdout; 611 this.stderr = stderr; 612 this.exitCode = exitCode; 613 } 614 615 public String getStdout() { 616 return stdout; 617 } 618 619 public String getStderr() { 620 return stderr; 621 } 622 623 public int getExitCode() { 624 return exitCode; 625 } 626 627 public boolean isSuccess() { 628 return exitCode == 0; 629 } 630 631 @Override 632 public String toString() { 633 return String.format("CommandResult{exitCode=%d, stdout='%s', stderr='%s'}", 634 exitCode, stdout, stderr); 635 } 636 } 637}