View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.auth.chain;
17  
18  import static org.codelibs.core.stream.StreamUtil.stream;
19  
20  import java.io.BufferedReader;
21  import java.io.File;
22  import java.io.IOException;
23  import java.io.InputStream;
24  import java.io.InputStreamReader;
25  import java.io.UnsupportedEncodingException;
26  import java.nio.charset.Charset;
27  import java.util.LinkedList;
28  import java.util.List;
29  
30  import org.apache.logging.log4j.LogManager;
31  import org.apache.logging.log4j.Logger;
32  import org.codelibs.core.exception.InterruptedRuntimeException;
33  import org.codelibs.core.lang.StringUtil;
34  import org.codelibs.core.lang.ThreadUtil;
35  import org.codelibs.fess.crawler.Constants;
36  import org.codelibs.fess.crawler.exception.CrawlerSystemException;
37  import org.codelibs.fess.exception.CommandExecutionException;
38  import org.codelibs.fess.opensearch.user.exentity.User;
39  
40  /**
41   * Authentication chain implementation that executes external commands for user operations.
42   * Provides user management through command-line tool execution for password changes and user deletion.
43   */
44  public class CommandChain implements AuthenticationChain {
45  
46      private static final Logger logger = LogManager.getLogger(CommandChain.class);
47  
48      /** Working directory for command execution. */
49      protected File workingDirectory = null;
50  
51      /** Maximum number of output lines to capture. */
52      protected int maxOutputLine = 1000;
53  
54      /** Command execution timeout in milliseconds. */
55      protected long executionTimeout = 30L * 1000L; // 30sec
56  
57      /** Character encoding for command output. */
58      protected String commandOutputEncoding = Charset.defaultCharset().displayName();
59  
60      /** Command array for user update operations. */
61      protected String[] updateCommand;
62  
63      /** Command array for user deletion operations. */
64      protected String[] deleteCommand;
65  
66      /** Array of target usernames for command execution. */
67      protected String[] targetUsers;
68  
69      @Override
70      public void update(final User user) {
71          final String username = user.getName();
72          final String password = user.getOriginalPassword();
73          changePassword(username, password);
74      }
75  
76      @Override
77      public void delete(final User user) {
78          final String username = user.getName();
79          if (isTargetUser(username)) {
80              executeCommand(deleteCommand, username, StringUtil.EMPTY);
81          }
82      }
83  
84      @Override
85      public boolean changePassword(final String username, final String password) {
86          if (isTargetUser(username) && StringUtil.isNotBlank(password)) {
87              return executeCommand(updateCommand, username, password) == 0;
88          }
89          return true;
90      }
91  
92      @Override
93      public User load(final User user) {
94          return user;
95      }
96  
97      /**
98       * Default constructor for CommandChain.
99       */
100     public CommandChain() {
101         // Default constructor
102     }
103 
104     /**
105      * Checks if the given username is a target user for command execution.
106      * @param username The username to check.
107      * @return True if the user is a target user, false otherwise.
108      */
109     protected boolean isTargetUser(final String username) {
110         if (targetUsers == null) {
111             return true;
112         }
113         return stream(targetUsers).get(stream -> stream.anyMatch(s -> s.equals(username)));
114     }
115 
116     /**
117      * Executes an external command with the given parameters.
118      * @param commands The command array to execute.
119      * @param username The username parameter for the command.
120      * @param password The password parameter for the command.
121      * @return The exit code of the executed command.
122      */
123     protected int executeCommand(final String[] commands, final String username, final String password) {
124         if (commands == null || commands.length == 0) {
125             throw new CommandExecutionException("Command array is null or empty. At least one command must be provided.");
126         }
127 
128         // Log command template with masked password for security
129         if (logger.isDebugEnabled()) {
130             final String commandStr = stream(commands).get(stream -> stream.map(s -> {
131                 if ("$PASSWORD".equals(s)) {
132                     return "***MASKED***";
133                 }
134                 if ("$USERNAME".equals(s)) {
135                     return username;
136                 }
137                 return s;
138             }).collect(java.util.stream.Collectors.joining(" ")));
139             logger.debug("Executing command for user: username={}, command={}", username, commandStr);
140         }
141 
142         final String[] cmds = stream(commands).get(stream -> stream.map(s -> {
143             if ("$USERNAME".equals(s)) {
144                 return username;
145             }
146             if ("$PASSWORD".equals(s)) {
147                 return password;
148             }
149             return s;
150         }).toArray(n -> new String[n]));
151         final ProcessBuilder pb = new ProcessBuilder(cmds);
152         if (workingDirectory != null) {
153             pb.directory(workingDirectory);
154         }
155         pb.redirectErrorStream(true);
156 
157         Process currentProcess = null;
158         MonitorThread mt = null;
159         try {
160             currentProcess = pb.start();
161 
162             // monitoring
163             mt = new MonitorThread(currentProcess, executionTimeout);
164             mt.start();
165 
166             final InputStreamThread it = new InputStreamThread(currentProcess.getInputStream(), commandOutputEncoding, maxOutputLine);
167             it.start();
168 
169             currentProcess.waitFor();
170             it.join(5000);
171 
172             if (mt.isTeminated()) {
173                 if (logger.isDebugEnabled()) {
174                     logger.debug("Command execution timeout for user: username={}", username);
175                 }
176                 throw new CommandExecutionException("The command execution is timeout for user: " + username);
177             }
178 
179             final int exitValue = currentProcess.exitValue();
180 
181             if (logger.isInfoEnabled()) {
182                 logger.info("Command execution completed for user: username={}, exitCode={}", username, exitValue);
183             }
184             if (logger.isDebugEnabled()) {
185                 logger.debug("Process output:\n{}", it.getOutput());
186             }
187             if (exitValue == 143 && mt.isTeminated()) {
188                 if (logger.isDebugEnabled()) {
189                     logger.debug("Command execution timeout (exit 143) for user: username={}", username);
190                 }
191                 throw new CommandExecutionException("The command execution is timeout for user: " + username);
192             }
193             return exitValue;
194         } catch (final CrawlerSystemException e) {
195             throw e;
196         } catch (final InterruptedException e) {
197             if (mt != null && mt.isTeminated()) {
198                 if (logger.isDebugEnabled()) {
199                     logger.debug("Command execution interrupted due to timeout for user: username={}", username, e);
200                 }
201                 throw new CommandExecutionException("The command execution is timeout for user: " + username, e);
202             }
203             throw new InterruptedRuntimeException(e);
204         } catch (final Exception e) {
205             if (logger.isDebugEnabled()) {
206                 logger.debug("Command execution failed for user: username={}, error={}", username, e.getMessage(), e);
207             }
208             throw new CommandExecutionException("Process terminated for user: " + username, e);
209         } finally {
210             if (mt != null) {
211                 mt.setFinished(true);
212                 try {
213                     mt.interrupt();
214                 } catch (final Exception e) {
215                     // ignore
216                 }
217             }
218             if (currentProcess != null) {
219                 try {
220                     currentProcess.destroy();
221                 } catch (final Exception e) {
222                     // ignore
223                 }
224             }
225             currentProcess = null;
226 
227         }
228     }
229 
230     /**
231      * Monitor thread that handles process timeout and termination.
232      * This thread sleeps for the specified timeout duration and terminates the process if it hasn't finished.
233      */
234     protected static class MonitorThread extends Thread {
235         /** The process to monitor. */
236         private final Process process;
237 
238         /** The timeout duration in milliseconds. */
239         private final long timeout;
240 
241         /** Flag indicating if the process has finished. */
242         private boolean finished = false;
243 
244         /** Flag indicating if the process has been terminated. */
245         private boolean teminated = false;
246 
247         /**
248          * Constructor for MonitorThread.
249          * @param process The process to monitor.
250          * @param timeout The timeout duration in milliseconds.
251          */
252         public MonitorThread(final Process process, final long timeout) {
253             this.process = process;
254             this.timeout = timeout;
255         }
256 
257         /**
258          * Runs the monitor thread, sleeping for the timeout duration and terminating the process if needed.
259          */
260         @Override
261         public void run() {
262             ThreadUtil.sleepQuietly(timeout);
263 
264             if (!finished) {
265                 try {
266                     process.destroy();
267                     teminated = true;
268                 } catch (final Exception e) {
269                     if (logger.isInfoEnabled()) {
270                         logger.info("Could not kill subprocess: process={}", process, e);
271                     }
272                 }
273             }
274         }
275 
276         /**
277          * Sets the finished flag to indicate whether the process has completed.
278          * @param finished True if the process has finished, false otherwise.
279          */
280         public void setFinished(final boolean finished) {
281             this.finished = finished;
282         }
283 
284         /**
285          * Checks if the process has been terminated due to timeout.
286          * @return True if the process was terminated, false otherwise.
287          */
288         public boolean isTeminated() {
289             return teminated;
290         }
291     }
292 
293     /**
294      * Thread that reads input stream data and buffers it for later retrieval.
295      * Captures output from command execution with configurable line buffering.
296      */
297     protected static class InputStreamThread extends Thread {
298 
299         /** Buffered reader for input stream. */
300         private BufferedReader br;
301 
302         /** List to store captured output lines. */
303         private final List<String> list = new LinkedList<>();
304 
305         /** Maximum number of lines to buffer. */
306         private final int maxLineBuffer;
307 
308         /**
309          * Constructor for InputStreamThread.
310          * @param is The input stream to read from.
311          * @param charset The character encoding to use.
312          * @param maxOutputLineBuffer The maximum number of lines to buffer.
313          */
314         public InputStreamThread(final InputStream is, final String charset, final int maxOutputLineBuffer) {
315             try {
316                 br = new BufferedReader(new InputStreamReader(is, charset));
317             } catch (final UnsupportedEncodingException e) {
318                 br = new BufferedReader(new InputStreamReader(is, Constants.UTF_8_CHARSET));
319             }
320             maxLineBuffer = maxOutputLineBuffer;
321         }
322 
323         /**
324          * Runs the input stream thread, reading lines and buffering them.
325          */
326         @Override
327         public void run() {
328             for (;;) {
329                 try {
330                     final String line = br.readLine();
331                     if (line == null) {
332                         break;
333                     }
334                     if (logger.isDebugEnabled()) {
335                         logger.debug(line);
336                     }
337                     list.add(line);
338                     if (list.size() > maxLineBuffer) {
339                         list.remove(0);
340                     }
341                 } catch (final IOException e) {
342                     throw new CrawlerSystemException(e);
343                 }
344             }
345         }
346 
347         /**
348          * Gets the captured output as a single string.
349          * @return The captured output with newlines.
350          */
351         public String getOutput() {
352             final StringBuilder buf = new StringBuilder(100);
353             for (final String value : list) {
354                 buf.append(value).append("\n");
355             }
356             return buf.toString();
357         }
358 
359     }
360 
361     /**
362      * Sets the working directory for command execution.
363      * @param workingDirectory The working directory.
364      */
365     public void setWorkingDirectory(final File workingDirectory) {
366         this.workingDirectory = workingDirectory;
367     }
368 
369     /**
370      * Sets the maximum number of output lines to capture.
371      * @param maxOutputLine The maximum output line count.
372      */
373     public void setMaxOutputLine(final int maxOutputLine) {
374         this.maxOutputLine = maxOutputLine;
375     }
376 
377     /**
378      * Sets the command execution timeout.
379      * @param executionTimeout The execution timeout in milliseconds.
380      */
381     public void setExecutionTimeout(final long executionTimeout) {
382         this.executionTimeout = executionTimeout;
383     }
384 
385     /**
386      * Sets the character encoding for command output.
387      * @param commandOutputEncoding The character encoding.
388      */
389     public void setCommandOutputEncoding(final String commandOutputEncoding) {
390         this.commandOutputEncoding = commandOutputEncoding;
391     }
392 
393     /**
394      * Sets the command array for user update operations.
395      * @param updateCommand The update command array.
396      */
397     public void setUpdateCommand(final String[] updateCommand) {
398         this.updateCommand = updateCommand;
399     }
400 
401     /**
402      * Sets the command array for user deletion operations.
403      * @param deleteCommand The delete command array.
404      */
405     public void setDeleteCommand(final String[] deleteCommand) {
406         this.deleteCommand = deleteCommand;
407     }
408 
409     /**
410      * Sets the array of target usernames for command execution.
411      * @param targetUsers The target users array.
412      */
413     public void setTargetUsers(final String[] targetUsers) {
414         this.targetUsers = targetUsers;
415     }
416 
417 }