1<?php
2/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
3
4namespace Icinga\Module\Monitoring\Command\Transport;
5
6use Icinga\Application\Logger;
7use Icinga\Data\ResourceFactory;
8use Icinga\Exception\ConfigurationError;
9use Icinga\Module\Monitoring\Command\IcingaCommand;
10use Icinga\Module\Monitoring\Command\Renderer\IcingaCommandFileCommandRenderer;
11use Icinga\Module\Monitoring\Exception\CommandTransportException;
12
13/**
14 * A remote Icinga command file
15 *
16 * Key-based SSH login must be possible for the user to log in as on the remote host
17 */
18class RemoteCommandFile implements CommandTransportInterface
19{
20    /**
21     * Transport identifier
22     */
23    const TRANSPORT = 'remote';
24
25    /**
26     * The name of the Icinga instance this transport will transfer commands to
27     *
28     * @var string
29     */
30    protected $instanceName;
31
32    /**
33     * Remote host
34     *
35     * @var string
36     */
37    protected $host;
38
39    /**
40     * Port to connect to on the remote host
41     *
42     * @var int
43     */
44    protected $port = 22;
45
46    /**
47     * User to log in as on the remote host
48     *
49     * Defaults to current PHP process' user
50     *
51     * @var string
52     */
53    protected $user;
54
55    /**
56     * Path to the private key file for the key-based authentication
57     *
58     * @var string
59     */
60    protected $privateKey;
61
62    /**
63     * Path to the Icinga command file on the remote host
64     *
65     * @var string
66     */
67    protected $path;
68
69    /**
70     * Command renderer
71     *
72     * @var IcingaCommandFileCommandRenderer
73     */
74    protected $renderer;
75
76    /**
77     * SSH subprocess pipes
78     *
79     * @var array
80     */
81    protected $sshPipes;
82
83    /**
84     * SSH subprocess
85     *
86     * @var resource
87     */
88    protected $sshProcess;
89
90    /**
91     * Create a new remote command file command transport
92     */
93    public function __construct()
94    {
95        $this->renderer = new IcingaCommandFileCommandRenderer();
96    }
97
98    /**
99     * Set the name of the Icinga instance this transport will transfer commands to
100     *
101     * @param   string  $name
102     *
103     * @return  $this
104     */
105    public function setInstance($name)
106    {
107        $this->instanceName = $name;
108        return $this;
109    }
110
111    /**
112     * Return the name of the Icinga instance this transport will transfer commands to
113     *
114     * @return  string
115     */
116    public function getInstance()
117    {
118        return $this->instanceName;
119    }
120
121    /**
122     * Set the remote host
123     *
124     * @param   string $host
125     *
126     * @return  $this
127     */
128    public function setHost($host)
129    {
130        $this->host = (string) $host;
131        return $this;
132    }
133
134    /**
135     * Get the remote host
136     *
137     * @return string
138     */
139    public function getHost()
140    {
141        return $this->host;
142    }
143
144    /**
145     * Set the port to connect to on the remote host
146     *
147     * @param   int $port
148     *
149     * @return  $this
150     */
151    public function setPort($port)
152    {
153        $this->port = (int) $port;
154        return $this;
155    }
156
157    /**
158     * Get the port to connect on the remote host
159     *
160     * @return int
161     */
162    public function getPort()
163    {
164        return $this->port;
165    }
166
167    /**
168     * Set the user to log in as on the remote host
169     *
170     * @param   string $user
171     *
172     * @return  $this
173     */
174    public function setUser($user)
175    {
176        $this->user = (string) $user;
177        return $this;
178    }
179
180    /**
181     * Get the user to log in as on the remote host
182     *
183     * Defaults to current PHP process' user
184     *
185     * @return string|null
186     */
187    public function getUser()
188    {
189        return $this->user;
190    }
191
192    /**
193     * Set the path to the private key file
194     *
195     * @param string $privateKey
196     *
197     * @return $this
198     */
199    public function setPrivateKey($privateKey)
200    {
201        $this->privateKey = (string) $privateKey;
202        return $this;
203    }
204
205    /**
206     * Get the path to the private key
207     *
208     * @return string
209     */
210    public function getPrivateKey()
211    {
212        return $this->privateKey;
213    }
214
215    /**
216     * Use a given resource to set the user and the key
217     *
218     * @param string
219     *
220     * @throws ConfigurationError
221     */
222    public function setResource($resource = null)
223    {
224        $config = ResourceFactory::getResourceConfig($resource);
225
226        if (! isset($config->user)) {
227            throw new ConfigurationError(
228                t("Can't send external Icinga Command. Remote user is missing")
229            );
230        }
231        if (! isset($config->private_key)) {
232            throw new ConfigurationError(
233                t("Can't send external Icinga Command. The private key for the remote user is missing")
234            );
235        }
236
237        $this->setUser($config->user);
238        $this->setPrivateKey($config->private_key);
239    }
240
241    /**
242     * Set the path to the Icinga command file on the remote host
243     *
244     * @param   string $path
245     *
246     * @return  $this
247     */
248    public function setPath($path)
249    {
250        $this->path = (string) $path;
251        return $this;
252    }
253
254    /**
255     * Get the path to the Icinga command file on the remote host
256     *
257     * @return string
258     */
259    public function getPath()
260    {
261        return $this->path;
262    }
263
264    /**
265     * Write the command to the Icinga command file on the remote host
266     *
267     * @param   IcingaCommand   $command
268     * @param   int|null        $now
269     *
270     * @throws  ConfigurationError
271     * @throws  CommandTransportException
272     */
273    public function send(IcingaCommand $command, $now = null)
274    {
275        if (! isset($this->path)) {
276            throw new ConfigurationError(
277                'Can\'t send external Icinga Command. Path to the remote command file is missing'
278            );
279        }
280        if (! isset($this->host)) {
281            throw new ConfigurationError('Can\'t send external Icinga Command. Remote host is missing');
282        }
283        $commandString = $this->renderer->render($command, $now);
284        Logger::debug(
285            'Sending external Icinga command "%s" to the remote command file "%s:%u%s"',
286            $commandString,
287            $this->host,
288            $this->port,
289            $this->path
290        );
291        return $this->sendCommandString($commandString);
292    }
293
294    /**
295     * Get the SSH command
296     *
297     * @return  string
298     */
299    protected function sshCommand()
300    {
301        $cmd = sprintf(
302            'exec ssh -o BatchMode=yes -p %u',
303            $this->port
304        );
305        // -o BatchMode=yes for disabling interactive authentication methods
306
307        if (isset($this->user)) {
308            $cmd .= ' -l ' . escapeshellarg($this->user);
309        }
310
311        if (isset($this->privateKey)) {
312            // TODO: StrictHostKeyChecking=no for compat only, must be removed
313            $cmd .= ' -o StrictHostKeyChecking=no'
314                  . ' -i ' . escapeshellarg($this->privateKey);
315        }
316
317        $cmd .= sprintf(
318            ' %s "cat > %s"',
319            escapeshellarg($this->host),
320            escapeshellarg($this->path)
321        );
322
323        return $cmd;
324    }
325
326    /**
327     * Send the command over SSH
328     *
329     * @param   string  $commandString
330     *
331     * @throws  CommandTransportException
332     */
333    protected function sendCommandString($commandString)
334    {
335        if ($this->isSshAlive()) {
336            $ret = fwrite($this->sshPipes[0], $commandString . "\n");
337            if ($ret === false) {
338                $this->throwSshFailure('Cannot write to the remote command pipe');
339            } elseif ($ret !== strlen($commandString) + 1) {
340                $this->throwSshFailure(
341                    'Failed to write the whole command to the remote command pipe'
342                );
343            }
344        } else {
345            $this->throwSshFailure();
346        }
347    }
348
349    /**
350     * Get the pipes of the SSH subprocess
351     *
352     * @return  array
353     */
354    protected function getSshPipes()
355    {
356        if ($this->sshPipes === null) {
357            $this->forkSsh();
358        }
359
360        return $this->sshPipes;
361    }
362
363    /**
364     * Get the SSH subprocess
365     *
366     * @return  resource
367     */
368    protected function getSshProcess()
369    {
370        if ($this->sshProcess === null) {
371            $this->forkSsh();
372        }
373
374        return $this->sshProcess;
375    }
376
377    /**
378     * Get the status of the SSH subprocess
379     *
380     * @param   string  $what
381     *
382     * @return  mixed
383     */
384    protected function getSshProcessStatus($what = null)
385    {
386        $status = proc_get_status($this->getSshProcess());
387        if ($what === null) {
388            return $status;
389        } else {
390            return $status[$what];
391        }
392    }
393
394    /**
395     * Get whether the SSH subprocess is alive
396     *
397     * @return  bool
398     */
399    protected function isSshAlive()
400    {
401        return $this->getSshProcessStatus('running');
402    }
403
404    /**
405     * Fork SSH subprocess
406     *
407     * @throws  CommandTransportException   If fork fails
408     */
409    protected function forkSsh()
410    {
411        $descriptors = array(
412            0 => array('pipe', 'r'),
413            1 => array('pipe', 'w'),
414            2 => array('pipe', 'w')
415        );
416
417        $this->sshProcess = proc_open($this->sshCommand(), $descriptors, $this->sshPipes);
418
419        if (! is_resource($this->sshProcess)) {
420            throw new CommandTransportException(
421                'Can\'t send external Icinga command: Failed to fork SSH'
422            );
423        }
424    }
425
426    /**
427     * Read from STDERR
428     *
429     * @return  string
430     */
431    protected function readStderr()
432    {
433        return stream_get_contents($this->sshPipes[2]);
434    }
435
436    /**
437     * Throw SSH failure
438     *
439     * @param   string  $msg
440     *
441     * @throws  CommandTransportException
442     */
443    protected function throwSshFailure($msg = 'Can\'t send external Icinga command')
444    {
445        throw new CommandTransportException(
446            '%s: %s',
447            $msg,
448            $this->readStderr() . var_export($this->getSshProcessStatus(), true)
449        );
450    }
451
452    /**
453     * Close SSH pipes and SSH subprocess
454     */
455    public function __destruct()
456    {
457        if (is_resource($this->sshProcess)) {
458            fclose($this->sshPipes[0]);
459            fclose($this->sshPipes[1]);
460            fclose($this->sshPipes[2]);
461
462            proc_close($this->sshProcess);
463        }
464    }
465}
466