forked from zer.ooo/web
[wip]
This commit is contained in:
parent
23c3cf530e
commit
ebddf2f031
35 changed files with 693 additions and 155 deletions
6
Vagrantfile
vendored
6
Vagrantfile
vendored
|
@ -8,7 +8,7 @@ Vagrant.configure(2) do |config|
|
|||
web.vm.provision :shell, inline: <<installphp
|
||||
add-apt-repository -y ppa:ondrej/php >/dev/null
|
||||
apt-get -qq update 2>/dev/null
|
||||
apt-get -qq install php7.0-cli php7.0-sqlite3 2>/dev/null >/dev/null;
|
||||
apt-get -qq install php7.0-cli php7.0-sqlite3 php7.0-zip 2>/dev/null >/dev/null;
|
||||
start-stop-daemon -bS --quiet --make-pidfile --pidfile /var/run/zerooo.pid --startas /bin/bash -- -c "exec php -S 0:8888 -t /vagrant/public/ /vagrant/public/index.php > /var/log/zerooo.log 2>&1";
|
||||
installphp
|
||||
web.vm.network "forwarded_port", guest: 8888, host: 8888
|
||||
|
@ -18,6 +18,10 @@ installphp
|
|||
vpn.vm.box = "ubuntu/wily64"
|
||||
vpn.vm.network "private_network", ip: "192.168.50.8"
|
||||
vpn.vm.synced_folder "../zer.ooo-server", "/server"
|
||||
vpn.vm.network "forwarded_port", guest: 7864, host: 7864
|
||||
vpn.vm.provision :shell, inline: <<installnode
|
||||
bash /server/main.sh all "http://192.168.50.4:8888";
|
||||
installnode
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
BINDIR=$(dirname $(realpath $0));
|
||||
BASEDIR=$(realpath "$BINDIR/../");
|
||||
cd $BASEDIR;
|
||||
|
||||
set -e;
|
||||
|
||||
BINDIR="$(dirname $(realpath "${0}"))";
|
||||
BASEDIR="$(realpath "${BINDIR}/../")";
|
||||
cd "${BASEDIR}";
|
||||
rm -rf ./storage/ca/*;
|
||||
mkdir ./storage/ca/certs;
|
||||
touch ./storage/ca/{,certs/}.gitkeep;
|
||||
touch ./storage/ca/certs/.gitkeep ./storage/ca/.gitkeep;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
BASEDIR=$(realpath $(dirname $0));
|
||||
KEYDIR=$(realpath "$BASEDIR/../storage/ca/");
|
||||
|
||||
if [ -f $KEYDIR/ca.key ]; then
|
||||
if [ -f "$KEYDIR/ca.key" ]; then
|
||||
echo "CA key already exists. not overwriting it."
|
||||
exit 1;
|
||||
fi
|
||||
|
@ -10,7 +10,7 @@ fi
|
|||
CN="ob.ae-cn";
|
||||
|
||||
if [ ! -z "$1" ]; then
|
||||
CN=$1;
|
||||
CN="$1";
|
||||
fi;
|
||||
|
||||
openssl req -days 3650 -nodes -new -x509 -keyout $KEYDIR/ca.key -out $KEYDIR/ca.crt -subj "/CN=$CN" -extensions ca_ext -config "$BASEDIR/../etc/openssl.conf";
|
||||
openssl req -days 3650 -nodes -new -x509 -keyout "$KEYDIR/ca.key" -out "$KEYDIR/ca.crt" -subj "/CN=$CN" -extensions ca_ext -config "$BASEDIR/../etc/openssl.conf";
|
||||
|
|
22
bin/setup
22
bin/setup
|
@ -1,15 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e;
|
||||
|
||||
BASEDIR="$(dirname $(realpath $0))/../";
|
||||
cd $BASEDIR;
|
||||
STORAGE=$(realpath "$BASEDIR/storage/ca");
|
||||
cd "${BASEDIR}";
|
||||
STORAGE="$(realpath "${BASEDIR}/storage/ca")";
|
||||
|
||||
mkdir -p $STORAGE;
|
||||
mkdir -p $STORAGE/certs;
|
||||
mkdir -p "${STORAGE}/certs";
|
||||
|
||||
echo 01 > $STORAGE/serial;
|
||||
echo 01 > $STORAGE/crl_serial;
|
||||
touch $STORAGE/database;
|
||||
touch $STORAGE/database.attr;
|
||||
echo 01 > "${STORAGE}/serial";
|
||||
echo 01 > "${STORAGE}/crl_serial";
|
||||
touch "${STORAGE}/database";
|
||||
touch "${STORAGE}/database.attr";
|
||||
|
||||
$BASEDIR/bin/create-ca $1;
|
||||
$BASEDIR/bin/create-crl;
|
||||
"${BASEDIR}/bin/create-ca" $1;
|
||||
"${BASEDIR}/bin/create-crl";
|
|
@ -1,6 +1,9 @@
|
|||
prefix: Eater\Glim\Handler\
|
||||
routes:
|
||||
/: Home
|
||||
/install:
|
||||
get: Install\Show
|
||||
post: Install\Action
|
||||
/login:
|
||||
get: Login\Show
|
||||
post: Login\Action
|
||||
|
@ -19,11 +22,15 @@ routes:
|
|||
/revoke:
|
||||
post: Panel\Certificates\Revoke
|
||||
/server:
|
||||
/remove:
|
||||
post: Panel\Servers\Remove
|
||||
/sign:
|
||||
post: Panel\Servers\Sign
|
||||
/{fingerprint}:
|
||||
get: Panel\Servers\Edit\Show
|
||||
post: Panel\Servers\Edit\Action
|
||||
/{fingerprint}/config: Panel\Servers\Config
|
||||
/{fingerprint}/config/{cert}: Panel\Servers\Config
|
||||
/server:
|
||||
/register:
|
||||
post: Server\Register
|
|
@ -31,7 +31,7 @@ subjectKeyIdentifier=hash
|
|||
authorityKeyIdentifier = keyid,issuer:always
|
||||
extendedKeyUsage = serverAuth
|
||||
keyUsage = digitalSignature,keyEncipherment
|
||||
crlDistributionPoints = URI:http://0b.ae/crl
|
||||
crlDistributionPoints = URI:http://localhost:8888/crl
|
||||
[client_ext]
|
||||
subjectKeyIdentifier=hash
|
||||
basicConstraints = CA:FALSE
|
||||
|
|
|
@ -14,15 +14,17 @@ $(function () {
|
|||
function save(callback) {
|
||||
var fingerprint = $('.server-form').data('fingerprint');
|
||||
|
||||
var data = $('.server-form input').serializeArray();
|
||||
var data = $('.server-form input,.server-form select').serializeArray();
|
||||
|
||||
$.post('/panel/server/' + fingerprint, data, function (data) {
|
||||
console.log(data);
|
||||
if (!data.success) {
|
||||
$('h2').after(error.text(data.error));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
});
|
||||
}, 'json');
|
||||
}
|
||||
|
||||
function sign() {
|
||||
|
@ -35,8 +37,12 @@ $(function () {
|
|||
function (data) {
|
||||
if (!data.success) {
|
||||
$('h2').after(error.text(data.error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
location.reload();
|
||||
},
|
||||
'json'
|
||||
);
|
||||
}
|
||||
});
|
|
@ -9,8 +9,8 @@ $(function () {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
||||
$('h2').after(error.text("Only alphanumeric, _ and - allowed in name"));
|
||||
if (!/^[A-Za-z0-9\-]+$/.test(name)) {
|
||||
$('h2').after(error.text("Only alphanumeric and - allowed in name"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -57,7 +57,7 @@ $(function () {
|
|||
} else {
|
||||
$('h2').after(error.text(data.error));
|
||||
}
|
||||
});
|
||||
}, 'json');
|
||||
});
|
||||
|
||||
$("#wantsPassword").change(function () {
|
||||
|
|
|
@ -26,4 +26,11 @@ $(function(){
|
|||
|
||||
$('.revoke-password').val('');
|
||||
});
|
||||
|
||||
$('.remove').click(function () {
|
||||
var that = this;
|
||||
$.post('/panel/server/remove',{ fingerprint: $(this).data('fingerprint') }, function () {
|
||||
$(that).parents('tr').first().remove();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -324,6 +324,7 @@ class Core implements ContainerInterface
|
|||
public function handle($class, Request $request, Response $response, ContainerInterface $containerInterface)
|
||||
{
|
||||
$this->startTimer(['response']);
|
||||
/** @var Handler\Main $handler */
|
||||
$handler = new $class($this, $request, $response, $containerInterface);
|
||||
|
||||
$this->startTimer(['response/before-handle']);
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
namespace Eater\Glim\Handler;
|
||||
|
||||
class CA extends Session
|
||||
class CA extends Main
|
||||
{
|
||||
function handle()
|
||||
{
|
||||
return$this->getResponse()
|
||||
return $this->getResponse()
|
||||
->withHeader('Content-Type', 'plain/text')
|
||||
->withHeader('Content-Disposition', 'attachment; filename="ca.crt"')
|
||||
->write(file_get_contents($this->getCore()->getBaseDir() . '/storage/ca/ca.crt'));
|
||||
|
|
21
src/Handler/CRL.php
Normal file
21
src/Handler/CRL.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
/**
|
||||
* Created by PhpStorm.
|
||||
* User: eater
|
||||
* Date: 6/6/16
|
||||
* Time: 11:28 PM
|
||||
*/
|
||||
|
||||
namespace Eater\Glim\Handler;
|
||||
|
||||
|
||||
class CRL extends Main
|
||||
{
|
||||
function handle()
|
||||
{
|
||||
return $this->getResponse()
|
||||
->withHeader('Content-Type', 'plain/text')
|
||||
->withHeader('Content-Disposition', 'attachment; filename="crl.pem"')
|
||||
->write(file_get_contents($this->getCore()->getBaseDir() . '/storage/ca/crl.pem'));
|
||||
}
|
||||
}
|
53
src/Handler/Install/Action.php
Normal file
53
src/Handler/Install/Action.php
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace Eater\Glim\Handler\Install;
|
||||
|
||||
use Eater\Glim\Service\User;
|
||||
|
||||
class Action extends Show
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->get('user');
|
||||
|
||||
$caCN = $this->post('ca-cn');
|
||||
|
||||
/* @var \Aura\Session\Session $session */
|
||||
$session = $this->get('session');
|
||||
$segment = $session->getSegment('main');
|
||||
|
||||
if (trim($caCN) === "") {
|
||||
$segment->setFlash('error', 'CA Common name can\'t be empty');
|
||||
return $this->redirect('/install');
|
||||
}
|
||||
|
||||
/** @var \Twig_Environment $twig */
|
||||
$twig = $this->get('twig');
|
||||
|
||||
$opensslConf = $twig->render('etc/openssl-ca.conf.twig', [
|
||||
'host' => $this->post('domainWithPort')
|
||||
]);
|
||||
|
||||
file_put_contents($this->getCore()->getBaseDir() . '/etc/openssl.conf', $opensslConf);
|
||||
|
||||
exec($this->getCore()->getBaseDir() . '/bin/clean-all');
|
||||
exec($this->getCore()->getBaseDir() . '/bin/setup ' . escapeshellarg($caCN));
|
||||
|
||||
$newUser = null;
|
||||
|
||||
try {
|
||||
$newUser = $user->createSuperuser($this->post('username'), $this->post('password'));
|
||||
} catch (\Exception $e) {
|
||||
$segment->setFlash("error", $e->getMessage());
|
||||
}
|
||||
|
||||
if ($newUser === null) {
|
||||
return $this->redirect('/install');
|
||||
}
|
||||
|
||||
$segment->set('user', $newUser);
|
||||
|
||||
return $this->redirect('/panel');
|
||||
}
|
||||
}
|
54
src/Handler/Install/Show.php
Normal file
54
src/Handler/Install/Show.php
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace Eater\Glim\Handler\Install;
|
||||
|
||||
use Eater\Glim\Handler\Main;
|
||||
use Eater\Glim\Model\UserQuery;
|
||||
|
||||
class Show extends Main
|
||||
{
|
||||
public function beforeHandle() {
|
||||
if (UserQuery::create()->findOne()) {
|
||||
return $this->redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$return = 1;
|
||||
$execEnabled = $this->isExecEnabled();
|
||||
$hasOpenSsl = false;
|
||||
if ($execEnabled) {
|
||||
exec('command -v openssl', $output, $return);
|
||||
$hasOpenSsl = $return === 0;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'hasExecEnabled' => $execEnabled,
|
||||
'hasOpenSsl' => $hasOpenSsl,
|
||||
'hasOpenSslExtension' => extension_loaded('openssl'),
|
||||
'hasZipExtension' => extension_loaded('zip'),
|
||||
'hostname' => parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST),
|
||||
'hostnameWithPort' => $_SERVER['HTTP_HOST']
|
||||
];
|
||||
|
||||
return $this->render('install.html.twig', $data);
|
||||
}
|
||||
|
||||
private function isExecEnabled() {
|
||||
if (ini_get('safe_mode')) {
|
||||
return false;
|
||||
} else {
|
||||
$d = ini_get('disable_functions');
|
||||
$s = ini_get('suhosin.executor.func.blacklist');
|
||||
if ("$d$s") {
|
||||
$array = preg_split('/,\s*/', "$d,$s");
|
||||
if (in_array('exec', $array)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -11,9 +11,25 @@ namespace Eater\Glim\Handler\Login;
|
|||
|
||||
use Aura\Session\Segment;
|
||||
use Eater\Glim\Handler\Main;
|
||||
use Eater\Glim\Model\UserQuery;
|
||||
|
||||
class Show extends Main
|
||||
{
|
||||
public function beforeHandle()
|
||||
{
|
||||
$session = $this->get('session');
|
||||
$segment = $session->getSegment('main');
|
||||
$user = $segment->get('user');
|
||||
|
||||
if ($user) {
|
||||
$this->redirect('/panel');
|
||||
}
|
||||
|
||||
if (!UserQuery::create()->findOne()) {
|
||||
return $this->redirect('/install');
|
||||
}
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
/** @var Segment $segment */
|
||||
|
|
|
@ -146,7 +146,6 @@ abstract class Main implements ContainerInterface
|
|||
return $this->getResponse()
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->write(json_encode($array));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -29,7 +29,7 @@ class Download extends Session
|
|||
|
||||
return $this->getResponse()
|
||||
->withHeader('Content-Type', 'plain/text')
|
||||
->withHeader('Content-Disposition', 'attachment; filename="' . $name . '.' . $this->getUser()->getUsername() .'.crt"')
|
||||
->withHeader('Content-Disposition', 'attachment; filename="' . $name . '.' . $cert->getSerial() .'.crt"')
|
||||
->write($cert->getCertificate());
|
||||
}
|
||||
}
|
89
src/Handler/Panel/Servers/Config.php
Normal file
89
src/Handler/Panel/Servers/Config.php
Normal file
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
/**
|
||||
* Created by PhpStorm.
|
||||
* User: eater
|
||||
* Date: 6/12/16
|
||||
* Time: 3:26 PM
|
||||
*/
|
||||
|
||||
namespace Eater\Glim\Handler\Panel\Servers;
|
||||
|
||||
|
||||
use Eater\Glim\Handler\Session;
|
||||
use Eater\Glim\Model\Certificate;
|
||||
use Eater\Glim\Model\CertificateQuery;
|
||||
use Eater\Glim\Model\Server;
|
||||
use Eater\Glim\Model\ServerQuery;
|
||||
|
||||
class Config extends Session
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
$zipFile = tempnam(sys_get_temp_dir(), '0zip');
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($zipFile, \ZipArchive::CREATE);
|
||||
$server = ServerQuery::create()->findOneByFingerprint($this->attr('fingerprint'));
|
||||
$name = $server->getFqdn();
|
||||
|
||||
$this->fillZipWithCaAndConfig($zip, $server);
|
||||
|
||||
$cert = $this->attr('cert');
|
||||
|
||||
if ($cert !== null) {
|
||||
$certModel = CertificateQuery::create()->findOneByUserAndName($this->getUser(), $cert);
|
||||
$this->addClientCertificateData($zip, $certModel);
|
||||
|
||||
$name .= '-' . $certModel->getName() . '.' . $certModel->getSerial();
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
$zipContents = file_get_contents($zipFile);
|
||||
unlink($zipFile);
|
||||
|
||||
return $this->getResponse()
|
||||
->withHeader('Content-Type', 'application/zip')
|
||||
->withHeader('Content-Disposition', 'attachment; filename="' . $name . '.zip"')
|
||||
->write($zipContents);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \ZipArchive $zip
|
||||
* @param Server $server
|
||||
*/
|
||||
public function fillZipWithCaAndConfig($zip, $server)
|
||||
{
|
||||
$config = $this->getConfigForServerFingerprint($server);
|
||||
$zip->addFromString('server.conf', $config);
|
||||
$zip->addFromString('ca.crt', file_get_contents($this->getCore()->getBaseDir() . '/storage/ca/ca.crt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Server $server
|
||||
* @return string
|
||||
*/
|
||||
public function getConfigForServerFingerprint($server)
|
||||
{
|
||||
/** @var \Twig_Environment $twig */
|
||||
$twig = $this->get('twig');
|
||||
|
||||
$config = $twig->render('etc/openvpn-client.conf.twig', [
|
||||
'server' => $server
|
||||
]);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \ZipArchive $zip
|
||||
* @param Certificate $cert
|
||||
*/
|
||||
public function addClientCertificateData($zip, $cert)
|
||||
{
|
||||
$zip->addFromString('client.crt', $cert->getCertificate());
|
||||
|
||||
if ($cert->hasPrivateKey()) {
|
||||
$zip->addFromString('client.key', $cert->getPrivateKey());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ namespace Eater\Glim\Handler\Panel\Servers\Edit;
|
|||
|
||||
use Eater\Glim\Handler\Session;
|
||||
use Eater\Glim\Model\ServerQuery;
|
||||
use Eater\Glim\Service\Server;
|
||||
|
||||
class Action extends Session
|
||||
{
|
||||
|
@ -23,13 +24,24 @@ class Action extends Session
|
|||
# Config
|
||||
$server->setInternalIp($this->post('internal-ip'));
|
||||
$server->setNetmask($this->post('netmask'));
|
||||
$server->setPort($this->post('post'));
|
||||
$server->setPort($this->post('port'));
|
||||
$server->setProtocol($this->post('protocol'));
|
||||
$server->setFirstDns($this->post('first-dns'));
|
||||
$server->setSecondDns($this->post('second-dns'));
|
||||
|
||||
$server->save();
|
||||
|
||||
$this->json([ 'success'=> true ]);
|
||||
/** @var Server $serverService */
|
||||
$serverService = $this->get('server');
|
||||
try {
|
||||
$serverService->deliverOpenVPNConfig($server);
|
||||
} catch (\Exception $e) {
|
||||
return $this->json([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->json([ 'success'=> true ]);
|
||||
}
|
||||
}
|
24
src/Handler/Panel/Servers/Remove.php
Normal file
24
src/Handler/Panel/Servers/Remove.php
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Eater\Glim\Handler\Panel\Servers;
|
||||
|
||||
use Eater\Glim\Handler\Session;
|
||||
use Eater\Glim\Model\ServerQuery;
|
||||
use Eater\Glim\Service\CA;
|
||||
use Eater\Glim\Service\Server;
|
||||
use Symfony\Component\Config\Definition\Exception\Exception;
|
||||
|
||||
class Remove extends Session
|
||||
{
|
||||
protected $shouldHaveSuperuser = true;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$server = ServerQuery::create()->findOneByFingerprint($this->post('fingerprint'));
|
||||
$server->delete();
|
||||
|
||||
return $this->json([
|
||||
'success' => true
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -17,10 +17,12 @@ class Sign extends Session
|
|||
/** @var CA $ca */
|
||||
$ca = $this->get('ca');
|
||||
$server = ServerQuery::create()->findOneByFingerprint($this->post('fingerprint'));
|
||||
$publicKey = $server->getPublicKey();
|
||||
|
||||
/** @var Server $serverManager */
|
||||
$serverManager = $this->get('server');
|
||||
try {
|
||||
$crt = $ca->signServerKey($publicKey, $server->getFqdn());
|
||||
$csr = $serverManager->getCsrFromServer($server);
|
||||
$crt = $ca->signServerKey($csr, $server->getFqdn());
|
||||
} catch (\Exception $e) {
|
||||
return $this->json([
|
||||
'success' => false,
|
||||
|
@ -31,9 +33,6 @@ class Sign extends Session
|
|||
$server->setCertificate($crt);
|
||||
$server->save();
|
||||
|
||||
/** @var Server $serverManager */
|
||||
$serverManager = $this->get('server');
|
||||
|
||||
try {
|
||||
$serverManager->deliverSignedCertificate($server);
|
||||
} catch (\Exception $e) {
|
||||
|
@ -43,7 +42,7 @@ class Sign extends Session
|
|||
]);
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
return $this->json([
|
||||
'success' => true
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
namespace Eater\Glim\Handler;
|
||||
|
||||
use Aura\Session\Segment;
|
||||
use Eater\Glim\Model\Base\UserQuery;
|
||||
use Eater\Glim\Model\User;
|
||||
use Eater\Glim\Service\TwigVars;
|
||||
|
||||
|
@ -48,6 +49,10 @@ class Session extends Main
|
|||
|
||||
public function beforeHandle()
|
||||
{
|
||||
if (!UserQuery::create()->findOne()) {
|
||||
return $this->redirect('/install');
|
||||
}
|
||||
|
||||
/* @var \Aura\Session\Session */
|
||||
$session = $this->get('session');
|
||||
/** @var TwigVars $twigVar */
|
||||
|
|
|
@ -16,5 +16,12 @@ use Eater\Glim\Model\Base\Certificate as BaseCertificate;
|
|||
*/
|
||||
class Certificate extends BaseCertificate
|
||||
{
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function hasPrivateKey()
|
||||
{
|
||||
$privateKey = $this->getPrivateKey();
|
||||
return !empty($privateKey);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,5 +16,8 @@ use Eater\Glim\Model\Base\Server as BaseServer;
|
|||
*/
|
||||
class Server extends BaseServer
|
||||
{
|
||||
|
||||
public function getNetmaskIp()
|
||||
{
|
||||
return long2ip(-1 << (32 - (int)$this->getNetmask()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,5 +16,4 @@ use Eater\Glim\Model\Base\User as BaseUser;
|
|||
*/
|
||||
class User extends BaseUser
|
||||
{
|
||||
|
||||
}
|
||||
|
|
|
@ -13,6 +13,14 @@ use Eater\Glim\Core;
|
|||
|
||||
class CA extends Main
|
||||
{
|
||||
public function getPrivateKey()
|
||||
{
|
||||
/** @var Core $core */
|
||||
$core = $this->get('core');
|
||||
|
||||
return file_get_contents($core->getBaseDir() . '/storage/ca/ca.key');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
|
@ -56,47 +64,14 @@ class CA extends Main
|
|||
return $crt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $publicKey
|
||||
* @param string $fqdn
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createCSRForKeyAndFqdn($publicKey, $fqdn)
|
||||
{
|
||||
/** @var Core $core */
|
||||
$core = $this->get('core');
|
||||
|
||||
$csrPath = tempnam(sys_get_temp_dir(), '0.');
|
||||
$pubPath = tempnam(sys_get_temp_dir(), '0.');
|
||||
|
||||
file_put_contents($pubPath, $publicKey);
|
||||
|
||||
exec(escapeshellcmd($core->getBaseDir() . '/bin/create-csr') . ' ' . escapeshellarg($fqdn) . ' ' . escapeshellarg($csrPath) . ' ' . escapeshellarg($pubPath) . ' 2>&1', $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new \Exception("Failed creating CSR: " . implode("\n", $output));
|
||||
}
|
||||
|
||||
$csr = file_get_contents($csrPath);
|
||||
|
||||
unlink($pubPath);
|
||||
unlink($csrPath);
|
||||
|
||||
return $csr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a client certificate and returns the signed certificate
|
||||
* @param string $publicKey
|
||||
* @param string $fqdn
|
||||
* @param string $csr
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function signServerKey($publicKey, $fqdn)
|
||||
public function signServerKey($csr)
|
||||
{
|
||||
$csr = $this->createCSRForKeyAndFqdn($publicKey, $fqdn);
|
||||
|
||||
/** @var Core $core */
|
||||
$core = $this->get('core');
|
||||
|
||||
|
@ -197,13 +172,10 @@ class CA extends Main
|
|||
}
|
||||
|
||||
public function signWithCA($data) {
|
||||
/** @var Core $core */
|
||||
$core = $this->get('core');
|
||||
|
||||
$privateKeyPlain = file_get_contents($core->getBaseDir() . '/storage/ca/ca.key');
|
||||
$privateKeyPlain = $this->getPrivateKey();
|
||||
$privateKey = openssl_get_privatekey($privateKeyPlain);
|
||||
|
||||
$result = openssl_sign($data, $signature, $privateKey);
|
||||
$result = openssl_sign($data, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
|
||||
if ($result) {
|
||||
return $signature;
|
||||
|
|
|
@ -5,6 +5,7 @@ namespace Eater\Glim\Service;
|
|||
use Eater\Glim\Model\Server as ServerModel;
|
||||
use GuzzleHttp\Client as HttpClient;
|
||||
use Eater\Glim\Core;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class Server extends Main
|
||||
{
|
||||
|
@ -20,18 +21,52 @@ class Server extends Main
|
|||
return $this->httpClient;
|
||||
}
|
||||
|
||||
public function getCsrFromServer(ServerModel $server) {
|
||||
$response = $this->doSignedRequest($server, '/create-csr', [
|
||||
'hostname' => $server->getFqdn()
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new \Exception('Requesting CSR failed (' . $response->getStatusCode() . '): ' . $response->getBody()->getContents());
|
||||
}
|
||||
|
||||
$data = $this->decryptResponse($server, $response);
|
||||
|
||||
return $data['csr'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerModel $server
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function deliverSignedCertificate(ServerModel $server) {
|
||||
$response = $this->doSignedRequest($server, '/deliver-crt', [
|
||||
'certficate' => $server->getCertificate()
|
||||
'certificate' => $server->getCertificate()
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new \Exception('Delivering signed certificate failed (' . $response->getStatusCode() . '): ' . $response->getBody()->getContents());
|
||||
}
|
||||
|
||||
$server->setStatus('signed');
|
||||
$server->save();
|
||||
}
|
||||
|
||||
|
||||
public function deliverOpenVPNConfig(ServerModel $server) {
|
||||
/** @var \Twig_Environment $twig */
|
||||
$twig = $this->get('twig');
|
||||
$config = $twig->render('etc/openvpn-server.conf.twig', [
|
||||
'server' => $server
|
||||
]);
|
||||
|
||||
$response = $this->doSignedRequest($server, '/update-openvpn-config', [
|
||||
'config' => $config
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new \Exception('Updating openvpn server config failed (' . $response->getStatusCode() . '): ' . $response->getBody()->getContents());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,27 +86,42 @@ class Server extends Main
|
|||
|
||||
$json = json_encode($data);
|
||||
|
||||
$password = bin2hex(openssl_random_pseudo_bytes(32));
|
||||
$password = bin2hex(openssl_random_pseudo_bytes(48));
|
||||
$pubKey = openssl_get_publickey($server->getPublicKey());
|
||||
$success = openssl_public_encrypt($password, $crypted, $pubKey, OPENSSL_PKCS1_PADDING);
|
||||
|
||||
if (!$success) {
|
||||
throw new \Exception('Encrypting data failed: ' . openssl_error_string() . openssl_error_string());
|
||||
}
|
||||
|
||||
$this->get('logger')->addDebug('Password: ' . $password);
|
||||
|
||||
$body = [
|
||||
bin2hex($crypted),
|
||||
bin2hex(openssl_encrypt($server->getCertificate(), 'aes-256-cbc', $password, 'help'))
|
||||
];
|
||||
|
||||
$this->get('logger')->addDebug('Help: ' . var_export([$json, $body], true));
|
||||
|
||||
|
||||
|
||||
$body = bin2hex($crypted) . bin2hex(openssl_encrypt($json, 'blowfish', substr($password, 0, 32), true, substr($password, 32, 8)));
|
||||
return $client->post('http://' . $server->getExternalIp() . ':' . static::MANAGEMENT_PORT . $path, [
|
||||
'json' => $body
|
||||
'body' => $body
|
||||
]);
|
||||
}
|
||||
|
||||
public function decryptResponse(ServerModel $server, ResponseInterface $response) {
|
||||
$body = $response->getBody()->getContents();
|
||||
$this->get('logger')->addDebug($body);
|
||||
$encryptedPasswordAndIV = hex2bin(substr($body, 0, 512));
|
||||
$encryptedBody = hex2bin(substr($body, 512));
|
||||
|
||||
/** @var CA $ca */
|
||||
$ca = $this->get('ca');
|
||||
|
||||
$success = openssl_private_decrypt($encryptedPasswordAndIV, $passwordAndIV, $ca->getPrivateKey(), OPENSSL_PKCS1_PADDING);
|
||||
if (!$success) {
|
||||
throw new \Exception('Decrypting data failed: ' . openssl_error_string() . openssl_error_string());
|
||||
}
|
||||
|
||||
$json = openssl_decrypt($encryptedBody, 'blowfish', substr($passwordAndIV, 0, 32), true, substr($passwordAndIV, 32, 8));
|
||||
$data = \json_decode($json, true);
|
||||
$ver = openssl_verify($server->getFingerprint(), hex2bin($data['signature']), $server->getPublicKey(), OPENSSL_ALGO_SHA256);
|
||||
|
||||
if ($ver === 1) {
|
||||
return $data;
|
||||
} else {
|
||||
throw new \Exception("Checking signature failed ($ver): " . openssl_error_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,8 @@ class Twig
|
|||
$loader = new \Twig_Loader_Filesystem($core->getBaseDir() . '/views/');
|
||||
$twig = new \Twig_Environment($loader, array(
|
||||
'cache' => $core->getBaseDir() . '/tmp/twig',
|
||||
'debug' => $debug
|
||||
'debug' => $debug,
|
||||
'displayErrorDetails' => $debug
|
||||
));
|
||||
|
||||
if ($twig) {
|
||||
|
|
|
@ -24,9 +24,7 @@ class User extends Main
|
|||
throw new \Exception("Invalid invite code");
|
||||
}
|
||||
|
||||
if ($this->exists($username)) {
|
||||
throw new \Exception("User already exists");
|
||||
}
|
||||
$this->validateUserParams($username, $password);
|
||||
|
||||
$user = new UserModel();
|
||||
$user->setUsername($username);
|
||||
|
@ -38,6 +36,41 @@ class User extends Main
|
|||
return $user;
|
||||
}
|
||||
|
||||
public function validateUserParams($username, $password) {
|
||||
if ($username === "") {
|
||||
throw new \Exception("No username given");
|
||||
}
|
||||
|
||||
if (!preg_match('~^[a-z0-9\-]+$~', $username)) {
|
||||
throw new \Exception("Username can only consist of a-z, 0-9 and -");
|
||||
}
|
||||
|
||||
if ($password === "") {
|
||||
throw new \Exception("Password is nothing, though strong. we rather not have you use that");
|
||||
}
|
||||
|
||||
if (strlen($password) < 9) {
|
||||
throw new \Exception("Please pick a password with more then 8 characters");
|
||||
}
|
||||
|
||||
if ($this->exists($username)) {
|
||||
throw new \Exception("User already exists");
|
||||
}
|
||||
}
|
||||
|
||||
public function createSuperuser($username, $password) {
|
||||
|
||||
$this->validateUserParams($username, $password);
|
||||
|
||||
$user = new UserModel();
|
||||
$user->setUsername($username);
|
||||
$user->setPassword(\password_hash($password, PASSWORD_DEFAULT));
|
||||
$user->setSuperuser(true);
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $username
|
||||
* @return bool
|
||||
|
|
|
@ -6,15 +6,14 @@ default_ca=ca_default
|
|||
[v3_ca]
|
||||
[ca_default]
|
||||
crl_extensions=crl_ext
|
||||
unique_subject=no
|
||||
private_key=storage/ca.key
|
||||
certificate=storage/ca.crt
|
||||
new_certs_dir=storage/certs/
|
||||
database=storage/database
|
||||
private_key=storage/ca/ca.key
|
||||
certificate=storage/ca/ca.crt
|
||||
new_certs_dir=storage/ca/certs/
|
||||
database=storage/ca/database
|
||||
default_md=sha256
|
||||
policy=policy_only_commonname
|
||||
serial=storage/serial
|
||||
crlnumber=storage/crl_serial
|
||||
serial=storage/ca/serial
|
||||
crlnumber=storage/ca/crl_serial
|
||||
default_crl_days=1
|
||||
[policy_only_commonname]
|
||||
countryName = optional
|
||||
|
@ -32,14 +31,14 @@ subjectKeyIdentifier=hash
|
|||
authorityKeyIdentifier = keyid,issuer:always
|
||||
extendedKeyUsage = serverAuth
|
||||
keyUsage = digitalSignature,keyEncipherment
|
||||
crlDistributionPoints = URI:http://{{ hostname }}/crl
|
||||
crlDistributionPoints = URI:http://{{ host }}/crl
|
||||
[client_ext]
|
||||
subjectKeyIdentifier=hash
|
||||
basicConstraints = CA:FALSE
|
||||
crlDistributionPoints = URI:http://{{ hostname }}/crl
|
||||
crlDistributionPoints = URI:http://{{ host }}/crl
|
||||
[ca_ext]
|
||||
basicConstraints = CA:TRUE
|
||||
subjectKeyIdentifier=hash
|
||||
crlDistributionPoints = URI:http://{{ hostname }}/crl
|
||||
crlDistributionPoints = URI:http://{{ host }}/crl
|
||||
[crl_ext]
|
||||
authorityKeyIdentifier=keyid:always
|
27
views/etc/openvpn-client.conf.twig
Normal file
27
views/etc/openvpn-client.conf.twig
Normal file
|
@ -0,0 +1,27 @@
|
|||
client
|
||||
|
||||
dev zerooo
|
||||
dev-type tun
|
||||
|
||||
proto {{ server.getProtocol() }}
|
||||
|
||||
remote {{ server.getExternalIp() }} {{ server.getPort() }}
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
|
||||
user nobody
|
||||
group nogroup
|
||||
|
||||
persist-key
|
||||
persist-tun
|
||||
|
||||
ca ca.crt
|
||||
cert client.crt
|
||||
key client.key
|
||||
|
||||
remote-cert-tls server
|
||||
|
||||
cipher BF-CBC
|
||||
|
||||
comp-lzo
|
||||
verb 3
|
32
views/etc/openvpn-server.conf.twig
Normal file
32
views/etc/openvpn-server.conf.twig
Normal file
|
@ -0,0 +1,32 @@
|
|||
port {{ server.getPort() }}
|
||||
proto {{ server.getProtocol() }}
|
||||
|
||||
dev ovpn0
|
||||
dev-type tun
|
||||
|
||||
ca ca.crt
|
||||
cert server.crt
|
||||
key server.key
|
||||
|
||||
dh dh2048.pem
|
||||
|
||||
server {{ server.getInternalIp() }} {{ server.getNetmaskIp() }}
|
||||
|
||||
ifconfig-pool-persist ipp.txt
|
||||
|
||||
push "redirect-gateway def1 bypass-dhcp"
|
||||
|
||||
push "dhcp-option DNS {{ server.getFirstDns() }}"
|
||||
push "dhcp-option DNS {{ server.getSecondDns() }}"
|
||||
keepalive 10 120
|
||||
cipher BF-CBC
|
||||
|
||||
crl-verify crl.pem
|
||||
|
||||
comp-lzo
|
||||
|
||||
user nobody
|
||||
group nogroup
|
||||
|
||||
persist-key
|
||||
persist-tun
|
80
views/install.html.twig
Normal file
80
views/install.html.twig
Normal file
|
@ -0,0 +1,80 @@
|
|||
{% extends "base.html.twig" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="col-md-offset-3 col-md-6">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="row">
|
||||
<h2>Welcome to your Zer.ooo install</h2>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h3>Checking for extensions and settings</h3>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ hasExecEnabled ? 'Yes' : 'No' }}</td>
|
||||
<td>Is <kbd>exec</kbd> available?</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ hasOpenSsl ? 'Yes' : 'No' }}</td>
|
||||
<td>Is the <kbd>openssl</kbd> binary available?</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ hasOpenSslExtension ? 'Yes' : 'No' }}</td>
|
||||
<td>Is the openssl module loaded?</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ hasZipExtension ? 'Yes' : 'No' }}</td>
|
||||
<td>Is the zip module loaded?</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if not (hasExecEnabled and hasOpenSslExtension and hasOpenSsl and hasZipExtension) %}
|
||||
<div class="row">
|
||||
Those functions are essential for the functionality of Zer.ooo, please enable them<br>
|
||||
<br>
|
||||
<a href="/install" class="btn btn-primary">Refresh</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<h3>Create your superuser</h3>
|
||||
</div>
|
||||
<form method="post" action="/install">
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" class="form-control" type="text" name="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" class="form-control" type="password" name="password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h3>Server details</h3>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label for="ca-cn">CA Common Name</label>
|
||||
<input type="text" id="ca-cn" name="ca-cn" class="form-control" value="{{ hostname }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="domainWithPort">HTTP Host (NOT HTTPS! needed for CRL)</label>
|
||||
<input type="text" id="domainWithPort" name="domainWithPort" class="form-control" value="{{ hostnameWithPort }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary pull-right">Finish install</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -8,7 +8,9 @@
|
|||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2 id="certificates">Certificates</h2>
|
||||
<div class="row">
|
||||
<h2 id="certificates">Certificates</h2>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table">
|
||||
<thead>
|
||||
|
@ -53,59 +55,22 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h2>Servers</h2>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Fingerprint</th>
|
||||
<th>Location</th>
|
||||
<th>Speed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in servers %}\
|
||||
<tr>
|
||||
<td>
|
||||
{{ server.getFqdn() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ server.getFingerprint() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ server.getLocation() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ server.getSpeed() }} MB/s
|
||||
</td>
|
||||
<td>
|
||||
<div class="pull-right">
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
There don't seem to be any servers yet :(
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if user.getSuperuser() %}
|
||||
<h2>Registered servers</h2>
|
||||
<div class="row">
|
||||
<h2>Servers</h2>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Fingerprint</th>
|
||||
<th>Location</th>
|
||||
<th>Speed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in registeredServers %}
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ server.getFqdn() }}
|
||||
|
@ -113,9 +78,35 @@
|
|||
<td>
|
||||
{{ server.getFingerprint() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ server.getLocation() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ server.getSpeed() }} MB/s
|
||||
</td>
|
||||
<td>
|
||||
<div class="pull-right">
|
||||
<a href="/panel/server/{{ server.getFingerprint() }}" class="btn btn-default">Details</a>
|
||||
<div class="btn-group">
|
||||
<a href="/panel/server/{{ server.getFingerprint() }}/config" class="btn btn-default">Download config</a>
|
||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="caret"></span>
|
||||
<span class="sr-only">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% for certificate in user.getCertificates() if certificate.getRevoked() == 0 %}
|
||||
<li>
|
||||
<a href="/panel/server/{{ server.getFingerprint() }}/config/{{ certificate.getName() }}">Download config for "{{ certificate.getName() }}"</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a>You dont have any certificates</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if user.isSuperUser() %}
|
||||
<a href="/panel/server/{{ server.getFingerprint() }}" class="btn btn-default">Edit</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -128,6 +119,46 @@
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if user.getSuperuser() %}
|
||||
<div class="row">
|
||||
<h2>Registered servers</h2>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Fingerprint</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in registeredServers %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ server.getFqdn() }}
|
||||
</td>
|
||||
<td>
|
||||
{{ server.getFingerprint() }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="pull-right">
|
||||
<button data-fingerprint="{{ server.getFingerprint() }}" class="remove btn btn-warning">Remove</button>
|
||||
<a href="/panel/server/{{ server.getFingerprint() }}" class="btn btn-default">Edit</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
There don't seem to be any servers yet :(
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal fade in revoke-modal" tabindex="-1" role="dialog">
|
||||
|
|
|
@ -83,7 +83,9 @@
|
|||
<div class="col-md-12">
|
||||
<div class="pull-right">
|
||||
<button type="button" class="btn save">Save</button>
|
||||
<button type="button" class="btn btn-primary save-and-sign">Sign and save</button>
|
||||
{% if server.getStatus() == 'registered' %}
|
||||
<button type="button" class="btn btn-primary save-and-sign">Sign and save</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue