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}