icingaweb2/application/forms/Config/Resource/RestApiResourceForm.php
2017-10-27 14:03:26 +02:00

607 lines
20 KiB
PHP

<?php
/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
namespace Icinga\Forms\Config\Resource;
use DateTime;
use DateTimeZone;
use ErrorException;
use Icinga\File\Storage\TemporaryLocalFileStorage;
use Icinga\Util\TimezoneDetect;
use Icinga\Web\Form;
use Icinga\Web\Form\Validator\RestApiUrlValidator;
use Icinga\Web\Form\Validator\TlsCertValidator;
use Icinga\Web\Url;
use Zend_Form_Element;
use Zend_Form_Element_Hidden;
/**
* Form class for adding/modifying ReST API resources
*/
class RestApiResourceForm extends Form
{
/**
* Not neccessarily present error handling options
*
* @var string[]
*/
protected $optionalErrorHandlingOptions = array(
'force_creation',
'tls_server_insecure',
'tls_server_ignore_cn',
'tls_server_discover_rootca',
'tls_server_accept_rootca'
);
/**
* Form elements which have to be above all others, in this order
*
* @var string[]
*/
protected $priorizedElements = array(
'force_creation',
'tls_server_insecure',
'tls_server_ignore_cn',
'tls_server_discover_rootca',
'tls_server_rootca_info',
'tls_server_accept_rootca'
);
public function init()
{
$this->setName('form_config_resource_restapi');
$this->setValidatePartial(true);
}
public function createElements(array $formData)
{
$this->addElement(
'text',
'baseurl',
array(
'label' => $this->translate('Base URL'),
'description' => $this->translate('http[s]://<HOST>[:<PORT>][/<BASE_LOCATION>]'),
'required' => true,
'validators' => array(new RestApiUrlValidator())
)
);
$this->addElement(
'text',
'username',
array(
'label' => $this->translate('Username'),
'description' => $this->translate(
'A user with access to the above URL via HTTP basic authentication'
)
)
);
$this->addElement(
'password',
'password',
array(
'label' => $this->translate('Password'),
'description' => $this->translate('The above user\'s password')
)
);
$tlsClientIdentities = array(
// TODO
);
if (empty($tlsClientIdentities)) {
$this->addElement(
'note',
'tls_client_identities_missing',
array(
'ignore' => true,
'label' => $this->translate('TLS Client Identity'),
'description' => $this->translate('TLS X509 client certificate with its private key (PEM)'),
'escape' => false,
'value' => sprintf(
$this->translate(
'There aren\'t any TLS client identities you could choose from, but you can %sadd some%s.'
),
sprintf(
'<a data-base-target="_next" href="#" title="%s" class="highlighted">', // TODO
$this->translate('Add TLS client identity')
),
'</a>'
)
)
);
} else {
$this->addElement(
'select',
'tls_client_identity',
array(
'label' => $this->translate('TLS Client Identity'),
'description' => $this->translate('TLS X509 client certificate with its private key (PEM)'),
'multiOptions' => array_merge(
array('' => $this->translate('(none)')),
$tlsClientIdentities
),
'value' => ''
)
);
}
$optionalErrorHandlingOptions = array_intersect($this->optionalErrorHandlingOptions, array_keys($formData));
if (isset($formData['tls_server_rootca_cert'])) {
$this->addRootCaCertCache();
if (! in_array('tls_server_discover_rootca', $optionalErrorHandlingOptions)) {
$optionalErrorHandlingOptions[] = 'tls_server_discover_rootca';
}
}
$this->ensureOnlyErrorHandlingOptions($optionalErrorHandlingOptions);
return $this->priorizeElements();
}
/**
* Ensure that only the given error handling options are present
*
* @param string[] $optionalErrorHandlingOptions
*
* @return $this
*/
protected function ensureOnlyErrorHandlingOptions(array $optionalErrorHandlingOptions = array())
{
foreach (array_diff($this->optionalErrorHandlingOptions, $optionalErrorHandlingOptions) as $option) {
$this->removeElement($option);
}
foreach ($optionalErrorHandlingOptions as $option) {
$element = $this->getElement($option);
if ($element === null) {
switch ($option) {
case 'force_creation':
$this->addElement('checkbox', 'force_creation', array(
'ignore' => true,
'label' => $this->translate('Force Changes'),
'description' => $this->translate(
'Check this box to enforce changes without connectivity validation'
)
));
break;
case 'tls_server_insecure':
$this->addElement('checkbox', 'tls_server_insecure', array(
'label' => $this->translate('Insecure Connection'),
'description' => $this->translate(
'Don\'t validate the remote\'s TLS certificate chain at all'
)
));
break;
case 'tls_server_ignore_cn':
$this->addElement('checkbox', 'tls_server_ignore_cn', array(
'ignore' => true,
'label' => $this->translate('Ignore Remote CN'),
'description' => $this->translate('Ignore the remote\'s TLS certificate\'s CN')
));
break;
case 'tls_server_discover_rootca':
$this->addElement('submit', 'tls_server_discover_rootca', array(
'ignore' => true,
'label' => $this->translate('Discover Root CA'),
'description' => $this->translate(
'Discover the remote\'s TLS certificate\'s root CA'
. ' (makes sense only in case of an isolated PKI)'
)
));
break;
case 'tls_server_accept_rootca':
$this->addElement('checkbox', 'tls_server_accept_rootca', array(
'ignore' => true,
'label' => $this->translate('Accept the remote\'s root CA'),
'description' => $this->translate('Trust the remote\'s TLS certificate\'s root CA')
));
break;
}
}
}
return $this;
}
/**
* Add form element with the given TLS root CA certificate's info
*
* @param array $cert
*/
protected function addRootCaInfo($cert)
{
$timezoneDetect = new TimezoneDetect();
$timeZone = new DateTimeZone(
$timezoneDetect->success() ? $timezoneDetect->getTimezoneName() : date_default_timezone_get()
);
$view = $this->getView();
$subject = array();
foreach ($cert['parsed']['subject'] as $key => $value) {
$subject[] = $view->escape("$key = " . var_export($value, true));
}
$this->addElement(
'note',
'tls_server_rootca_info',
array(
'ignore' => true,
'escape' => false,
'label' => $this->translate('Root CA'),
'value' => sprintf(
'<table class="name-value-list">' . str_repeat('<tr><td>%s</td><td>%s</td></tr>', 5) . '</table>',
$view->escape($this->translate('Subject', 'x509.certificate')),
implode('<br>', $subject),
$view->escape($this->translate('Valid from', 'x509.certificate')),
$view->escape(
DateTime::createFromFormat('U', $cert['parsed']['validFrom_time_t'])
->setTimezone($timeZone)
->format(DateTime::ISO8601)
),
$view->escape($this->translate('Valid until', 'x509.certificate')),
$view->escape(
DateTime::createFromFormat('U', $cert['parsed']['validTo_time_t'])
->setTimezone($timeZone)
->format(DateTime::ISO8601)
),
$view->escape($this->translate('SHA256 fingerprint', 'x509.certificate')),
$view->escape(
implode(' ', str_split(strtoupper(openssl_x509_fingerprint($cert['x509'], 'sha256')), 2))
),
$view->escape($this->translate('SHA1 fingerprint', 'x509.certificate')),
$view->escape(
implode(' ', str_split(strtoupper(openssl_x509_fingerprint($cert['x509'], 'sha1')), 2))
)
)
)
);
}
/**
* Add and return form element for the discovered TLS root CA certificate
*
* @return Zend_Form_Element_Hidden
*/
protected function addRootCaCertCache()
{
$element = $this->getElement('tls_server_rootca_cert');
if ($element === null) {
$this->addElement(
'hidden',
'tls_server_rootca_cert',
array('validators' => array(new TlsCertValidator()))
);
return $this->getElement('tls_server_rootca_cert');
}
return $element;
}
/**
* Reorder form elements as needed
*/
protected function priorizeElements()
{
$priorizedElements = array();
foreach ($this->priorizedElements as $priorizedElement) {
$element = $this->getElement($priorizedElement);
if ($element !== null) {
$element->setOrder(null);
$priorizedElements[] = $element;
}
}
$nextOrder = -1;
foreach ($priorizedElements as $priorizedElement) {
/** @var Zend_Form_Element $priorizedElement */
$priorizedElement->setOrder(++$nextOrder);
}
foreach ($this->getElements() as $name => $element) {
$this->_order[$name] = $element->getOrder();
}
return $this;
}
public function isValidPartial(array $formData)
{
if (! parent::isValidPartial($formData)) {
return false;
}
$result = $this->isEndpointValid();
$this->priorizeElements();
return $result;
}
public function isValid($formData)
{
if (! parent::isValid($formData)) {
return false;
}
$result = $this->isEndpointValid();
$this->priorizeElements();
return $result;
}
/**
* Return whether the configured endpoint is valid
*
* @return bool
*/
protected function isEndpointValid()
{
if ($this->isElementChecked('force_creation')) {
return true;
}
if (Url::fromPath($this->getValue('baseurl'))->getScheme() === 'https') {
if (! $this->probeInsecureTlsConnection()) {
$this->ensureOnlyErrorHandlingOptions(array('force_creation'));
return false;
}
if ($this->isElementChecked('tls_server_insecure')) {
return true;
}
if ($this->isElementChecked('tls_server_discover_rootca')) {
$this->removeElement('tls_server_rootca_cert');
$certs = $this->fetchServerTlsCertChain();
if ($certs === false) {
return false;
}
if ($certs['leaf']['parsed']['subject']['CN'] === $certs['leaf']['parsed']['issuer']['CN']) {
$this->error($this->translate('The remote didn\'t provide any non-self-signed TLS certificate'));
return false;
}
if (! isset($certs['root'])) {
$this->error($this->translate('The remote didn\'t provide any root CA certificate'));
return false;
}
$this->ensureOnlyErrorHandlingOptions(array(
'force_creation',
'tls_server_insecure',
'tls_server_ignore_cn',
'tls_server_discover_rootca',
'tls_server_accept_rootca'
));
$this->addRootCaInfo($certs['root']);
$this->addRootCaCertCache()->setValue($certs['root']['x509']);
return false;
}
$rootCaCert = $this->getValue('tls_server_rootca_cert');
if ($rootCaCert !== null && $this->isElementChecked('tls_server_accept_rootca')) {
$temporaryLocalFileStorage = new TemporaryLocalFileStorage();
$temporaryLocalFileStorage->create('rootca.pem', $rootCaCert);
$rootCaPath = $temporaryLocalFileStorage->resolvePath('rootca.pem');
} else {
$rootCaPath = null;
}
if ($rootCaCert !== null) {
$this->addRootCaInfo(array(
'x509' => $rootCaCert,
'parsed' => openssl_x509_parse($rootCaCert),
));
}
if (! $this->probeSecureTlsConnection($this->isElementChecked('tls_server_ignore_cn'), $rootCaPath)) {
$optionalErrorHandlingOptions = array(
'force_creation',
'tls_server_insecure',
'tls_server_ignore_cn',
'tls_server_discover_rootca'
);
if ($rootCaCert !== null) {
$optionalErrorHandlingOptions[] = 'tls_server_accept_rootca';
}
$this->ensureOnlyErrorHandlingOptions($optionalErrorHandlingOptions);
return false;
}
$this->removeElement('force_creation');
$this->removeElement('tls_server_insecure');
} else {
if (! $this->probeTcpConnection()) {
$this->ensureOnlyErrorHandlingOptions(array('force_creation'));
return false;
}
$this->ensureOnlyErrorHandlingOptions(array());
}
return true;
}
/**
* Return whether a TCP connection to the remote is possible and eventually add form errors
*
* @return bool
*/
protected function probeTcpConnection()
{
try {
fclose(stream_socket_client('tcp://' . $this->getTcpEndpoint()));
} catch (ErrorException $element) {
$this->error($element->getMessage());
return false;
}
return true;
}
/**
* Return whether an insecure TLS connection to the remote is possible and eventually add form errors
*
* @return bool
*/
protected function probeInsecureTlsConnection()
{
try {
fclose($this->createTlsStream(stream_context_create($this->includeTlsClientIdentity(array('ssl' => array(
'verify_peer' => false,
'verify_peer_name' => false
))))));
} catch (ErrorException $element) {
$this->error($element->getMessage());
return false;
}
return true;
}
/**
* Return whether a secure TLS connection to the remote is possible and eventually add form errors
*
* @param bool $ignoreCn Whether to ignore the remote's TLS certificate's CN
* @param string $rootCaPath Path to custom root CA to use
*
* @return bool
*/
protected function probeSecureTlsConnection($ignoreCn = false, $rootCaPath = null)
{
$options = array();
if ($rootCaPath !== null) {
$options['ssl']['cafile'] = $rootCaPath;
}
if ($ignoreCn) {
$options['ssl']['verify_peer_name'] = false;
}
try {
fclose($this->createTlsStream(stream_context_create($this->includeTlsClientIdentity($options))));
} catch (ErrorException $element) {
$this->error($element->getMessage());
return false;
}
return true;
}
/**
* Add the TLS client certificate to use (if any) to the given stream context options and return them
*
* @param array $contextOptions
*
* @return array
*/
protected function includeTlsClientIdentity(array $contextOptions)
{
if ($this->getValue('tls_client_identity') !== null) {
$contextOptions['ssl']['local_cert'] = null; // TODO
}
return $contextOptions;
}
/**
* Create a TLS stream to the remote with the the given stream context
*
* @param resource $context
*
* @return resource
*/
protected function createTlsStream($context)
{
return stream_socket_client(
'tls://' . $this->getTcpEndpoint(),
$errno,
$errstr,
ini_get('default_socket_timeout'),
STREAM_CLIENT_CONNECT,
$context
);
}
/**
* Get <HOST>:<PORT>
*
* @return string
*/
protected function getTcpEndpoint()
{
$baseurl = Url::fromPath($this->getValue('baseurl'));
$port = $baseurl->getPort();
return $baseurl->getHost() . ':' . ($port === null ? '443' : $port);
}
/**
* Return whether the given element is present and checked
*
* @param string $name
*
* @return bool
*/
protected function isElementChecked($name)
{
/** @var \Zend_Form_Element_Checkbox|\Zend_Form_Element_Submit $element */
$element = $this->getElement($name);
return $element !== null && $element->isChecked();
}
/**
* Try to fetch the remote's TLS certificate chain
*
* @return array|false
*/
protected function fetchServerTlsCertChain()
{
$context = stream_context_create($this->includeTlsClientIdentity(array('ssl' => array(
'verify_peer' => false,
'verify_peer_name' => false,
'capture_peer_cert_chain' => true
))));
try {
fclose($this->createTlsStream($context));
} catch (ErrorException $e) {
$this->error($e->getMessage());
return false;
}
$params = stream_context_get_params($context);
$rawChain = $params['options']['ssl']['peer_certificate_chain'];
$chain = array('leaf' => array('x509' => null));
openssl_x509_export(reset($rawChain), $chain['leaf']['x509']);
if (count($rawChain) > 1) {
$chain['root'] = array('x509' => null);
openssl_x509_export(end($rawChain), $chain['root']['x509']);
}
foreach ($chain as & $cert) {
$cert['parsed'] = openssl_x509_parse($cert['x509']);
}
if (isset($chain['root'])
&& $chain['root']['parsed']['subject']['CN'] !== $chain['root']['parsed']['issuer']['CN']
) {
unset($chain['root']);
}
return $chain;
}
}