722 lines
19 KiB
PHP
722 lines
19 KiB
PHP
<?php
|
|
|
|
require __DIR__ . '/vendor/autoload.php';
|
|
|
|
require_once 'config.inc.php';
|
|
|
|
if (!array_key_exists('REDIRECT_URL', $_SERVER)
|
|
|| rtrim($_SERVER['REDIRECT_URL'], '/') == ''
|
|
) {
|
|
include 'default.php';
|
|
exit;
|
|
}
|
|
|
|
$route = explode(
|
|
'/',
|
|
trim(
|
|
substr(
|
|
explode(
|
|
'?',
|
|
$_SERVER['REDIRECT_URL']
|
|
)[0],
|
|
strrpos($_SERVER['SCRIPT_NAME'], '/')
|
|
), '/'
|
|
)
|
|
);
|
|
$meth = $_SERVER['REQUEST_METHOD'];
|
|
header('Content-Type: text/plain');
|
|
|
|
|
|
// $FQDN_REGEX = '[a-zA-Z0-9\p{L}][a-zA-Z0-9\p{L}-\.]{1,61}[a-zA-Z0-9\p{L}]\.\
|
|
// [a-zA-Z0-9\p{L}][a-zA-Z\p{L}-]*[a-zA-Z0-9\p{L}]+';
|
|
// // From http://stackoverflow.com/a/38477788/2766106
|
|
$FQDN_REGEX = '.+'; // TODO
|
|
$IP4_REGEX = '/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.|$)){4}/'; // From http://stackoverflow.com/a/5284410
|
|
$SSH_KEY_REGEX = '/^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-nistp256) [a-zA-Z0-9+=\/]+/';
|
|
|
|
$machineArgs = array(
|
|
'name' => array(
|
|
'type' => 'string',
|
|
'pattern' => '/^[a-zA-Z0-9\-_]+$/',
|
|
'repeatable' => false,
|
|
'optional' => false
|
|
),
|
|
'host' => array( // DEPRECATED
|
|
'type' => 'string',
|
|
'pattern' => '/^'.$FQDN_REGEX.'(:\d+)?$/',
|
|
'repeatable' => true,
|
|
'optional' => true
|
|
),
|
|
'extIp4' => array(
|
|
'type' => 'string',
|
|
'pattern' => '/^'.$FQDN_REGEX.'(:\d+)?$/',
|
|
'repeatable' => true,
|
|
'optional' => true
|
|
),
|
|
'user' => array(
|
|
'type' => 'string',
|
|
'pattern' => '/^[a-zA-Z0-9\-_]+$/',
|
|
'repeatable' => false,
|
|
'optional' => true
|
|
),
|
|
'network' => array(
|
|
'type' => 'string',
|
|
'oneof' => 'network',
|
|
'repeatable' => false,
|
|
'optional' => true
|
|
),
|
|
'userkey' => array(
|
|
'type' => 'string',
|
|
'pattern' => $SSH_KEY_REGEX,
|
|
'repeatable' => false,
|
|
'optional' => true
|
|
),
|
|
'hostkey' => array(
|
|
'type' => 'string',
|
|
'pattern' => $SSH_KEY_REGEX,
|
|
'repeatable' => false,
|
|
'optional' => true
|
|
)
|
|
);
|
|
|
|
$networkArgs = array(
|
|
'name' => array(
|
|
'type' => 'string',
|
|
'pattern' => '/^[a-zA-Z0-9]+$/',
|
|
'repeatable' => false,
|
|
'optional' => false
|
|
),
|
|
'allowed' => array(
|
|
'type' => 'string',
|
|
'oneof' => 'network',
|
|
'repeatable' => true,
|
|
'optional' => true
|
|
),
|
|
'secure' => array(
|
|
'type' => 'boolean',
|
|
'repeatable' => false,
|
|
'optional' => false
|
|
)
|
|
);
|
|
|
|
// Compute a diff between two arrays
|
|
// From http://stackoverflow.com/a/22021254/2766106
|
|
function computeDiff($from, $to)
|
|
{
|
|
$diffValues = array();
|
|
$diffMask = array();
|
|
|
|
$dm = array();
|
|
$n1 = count($from);
|
|
$n2 = count($to);
|
|
|
|
for ($j = -1; $j < $n2; $j++) {
|
|
$dm[-1][$j] = 0;
|
|
}
|
|
for ($i = -1; $i < $n1; $i++) {
|
|
$dm[$i][-1] = 0;
|
|
}
|
|
for ($i = 0; $i < $n1; $i++) {
|
|
for ($j = 0; $j < $n2; $j++) {
|
|
if ($from[$i] == $to[$j]) {
|
|
$ad = $dm[$i - 1][$j - 1];
|
|
$dm[$i][$j] = $ad + 1;
|
|
} else {
|
|
$a1 = $dm[$i - 1][$j];
|
|
$a2 = $dm[$i][$j - 1];
|
|
$dm[$i][$j] = max($a1, $a2);
|
|
}
|
|
}
|
|
}
|
|
|
|
$i = $n1 - 1;
|
|
$j = $n2 - 1;
|
|
while (($i > -1) || ($j > -1)) {
|
|
if ($j > -1) {
|
|
if ($dm[$i][$j - 1] == $dm[$i][$j]) {
|
|
$diffValues[] = $to[$j];
|
|
$diffMask[] = 1;
|
|
$j--;
|
|
continue;
|
|
}
|
|
}
|
|
if ($i > -1) {
|
|
if ($dm[$i - 1][$j] == $dm[$i][$j]) {
|
|
$diffValues[] = $from[$i];
|
|
$diffMask[] = -1;
|
|
$i--;
|
|
continue;
|
|
}
|
|
}
|
|
{
|
|
$diffValues[] = $from[$i];
|
|
$diffMask[] = 0;
|
|
$i--;
|
|
$j--;
|
|
}
|
|
}
|
|
|
|
$diffValues = array_reverse($diffValues);
|
|
$diffMask = array_reverse($diffMask);
|
|
|
|
return array('values' => $diffValues, 'mask' => $diffMask);
|
|
}
|
|
|
|
function requireToken()
|
|
{
|
|
global $TOTP;
|
|
if (isset($_SERVER['HTTP_X_TOTP']) && $_SERVER['HTTP_X_TOTP']) {
|
|
if (!$TOTP->verify($_SERVER['HTTP_X_TOTP'], null, 1)) {
|
|
http_response_code(403);
|
|
die("Invalid token\n");
|
|
}
|
|
} else {
|
|
http_response_code(401);
|
|
die("Authorization required\n");
|
|
}
|
|
|
|
}
|
|
|
|
function requireSigned()
|
|
{
|
|
if ($_SERVER['SSL_CLIENT_VERIFY'] == 'NONE') {
|
|
http_response_code(401);
|
|
die("Authorization required\n");
|
|
} elseif ($_SERVER['SSL_CLIENT_VERIFY'] != 'SUCCESS') {
|
|
http_response_code(403);
|
|
die("Wrong certificate\n");
|
|
}
|
|
}
|
|
|
|
function logActivity($text)
|
|
{
|
|
$ex = explode("\n", $text);
|
|
$t = '';
|
|
foreach ($ex as $line) {
|
|
if ($line == '') {
|
|
continue;
|
|
}
|
|
$t .= strval(time()).'|'.$line."\n";
|
|
}
|
|
file_put_contents('machines.log', $t, FILE_APPEND);
|
|
}
|
|
|
|
function save($elname, $elements)
|
|
{
|
|
file_put_contents($elname.'.ser.db', serialize($elements));
|
|
}
|
|
|
|
function load($elname)
|
|
{
|
|
if (!file_exists($elname.'.ser.db')) {
|
|
save($elname, []);
|
|
}
|
|
return unserialize(file_get_contents($elname.'.ser.db'));
|
|
}
|
|
|
|
// Get keys that can be used to connect
|
|
// to the network
|
|
function getKeys($network)
|
|
{
|
|
global $SSH_KEY_REGEX;
|
|
global $DOMAIN;
|
|
$machines = load('machine');
|
|
$networks = load('network');
|
|
$t = '';
|
|
foreach ($machines as $machineName => $machine) {
|
|
if ((isset($machine['network']) && isset($networks[$machine['network']]))
|
|
&& (($networks[$machine['network']]['secure'] == 'true')
|
|
|| ($network && isset($network['allowed'])
|
|
&& in_array($machine['network'], $network['allowed'])))
|
|
) {
|
|
if (isset($machine['userkey'])
|
|
&& preg_match($SSH_KEY_REGEX, $machine['userkey'], $matches)
|
|
) {
|
|
$t .= $matches[0].' '.$machineName.'@'.($machine['network']
|
|
? $machine['network'].'.' : '').$DOMAIN."\n";
|
|
}
|
|
}
|
|
}
|
|
return $t;
|
|
}
|
|
|
|
// Display an element in a simple, easily-editable,
|
|
// easily-diffable, plain text format
|
|
function displayElement($element, $elementArgs)
|
|
{
|
|
$t = '';
|
|
ksort($element);
|
|
foreach ($element as $arg => $value) {
|
|
if ($arg == 'name') {
|
|
continue;
|
|
}
|
|
if (array_key_exists($arg, $elementArgs)) {
|
|
if ($value) {
|
|
$argData = $elementArgs[$arg];
|
|
if ($argData['repeatable']) {
|
|
sort($value);
|
|
foreach ($value as $arrValue) {
|
|
$t .= $arg.'[]='.$arrValue."\n";
|
|
}
|
|
} else {
|
|
$t .= $arg.'='.$value."\n";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $t;
|
|
}
|
|
|
|
// Verifiy if one argument is valid
|
|
// return false if valid, string with
|
|
// description if not
|
|
function argAssertOne($argData, $data)
|
|
{
|
|
// Type casting
|
|
switch ($argData['type']) {
|
|
case 'boolean':
|
|
switch ($data) {
|
|
case 'true':
|
|
$data = true;
|
|
break;
|
|
case 'false':
|
|
$data = false;
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Type validation
|
|
if (gettype($data) != $argData['type']) {
|
|
return "should be of type ".$argData['type'];
|
|
}
|
|
|
|
// Pattern validation
|
|
if (isset($argData['pattern']) && !preg_match($argData['pattern'], $data)) {
|
|
return "should match pattern ".$argData['pattern'];
|
|
}
|
|
|
|
// OneOf validation
|
|
if (isset($argData['oneof'])) {
|
|
$elname = $argData['oneof'];
|
|
$elements = load($elname);
|
|
if (!isset($elements[$data])) {
|
|
return "should be an existing $elname";
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function argAssert($arg, $data, $args)
|
|
{
|
|
$argData = $args[$arg];
|
|
// TODO oneof
|
|
|
|
if (!$data) {
|
|
if ($argData['optional']) {
|
|
return false;
|
|
} else {
|
|
return "Argument $arg is required";
|
|
}
|
|
|
|
}
|
|
|
|
if ($argData['repeatable']) {
|
|
if (gettype($data) == 'array') {
|
|
foreach ($data as $key => $dat) {
|
|
if ($err = argAssertOne($argData, $dat)) {
|
|
return "Argument $arg"."[$key] $err";
|
|
}
|
|
}
|
|
} else {
|
|
return "Argument $arg should be an array";
|
|
}
|
|
} else {
|
|
if ($err = argAssertOne($argData, $data)) {
|
|
return "Argument $arg ".$err;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Hooks
|
|
//
|
|
|
|
function updateGitKeys($api, $keys)
|
|
{
|
|
function apiRequest($api, $route, $meth = 'GET', $data = null)
|
|
{
|
|
$ch = curl_init();
|
|
$url = $api['api'].'/'.$route;
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $meth);
|
|
curl_setopt($ch, CURLOPT_USERAGENT, 'Machines Frogeye');
|
|
if ($data) {
|
|
$dataStr = json_encode($data);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $dataStr);
|
|
} else {
|
|
$dataStr = '';
|
|
}
|
|
$authHeader = isset($api['authHeader']) ?
|
|
$api['authHeader'] : 'Authorization: token';
|
|
curl_setopt(
|
|
$ch, CURLOPT_HTTPHEADER, array($authHeader.' '.$api['token'],
|
|
'Content-Type: application/json')
|
|
);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
$raw = curl_exec($ch);
|
|
curl_close($ch);
|
|
$json = json_decode($raw);
|
|
if ($json !== null && isset($json->{"error"})) {
|
|
echo("Error with API ".$url.": ".$json->{"error"}."\n");
|
|
return null;
|
|
}
|
|
return $json;
|
|
}
|
|
|
|
global $SSH_KEY_REGEX;
|
|
$existing = apiRequest($api, 'user/keys');
|
|
if ($existing === null) {
|
|
return 1;
|
|
}
|
|
$toDelete = [];
|
|
foreach ($existing as $ekey) {
|
|
$toDelete[$ekey->id] = $ekey->key;
|
|
}
|
|
|
|
foreach (explode("\n", $keys) as $key) {
|
|
if ($key == '') {
|
|
continue;
|
|
}
|
|
|
|
$found = false;
|
|
foreach ($toDelete as $id => $ekey) {
|
|
if (strpos($key, $ekey) !== false) {
|
|
unset($toDelete[$id]);
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
apiRequest(
|
|
$api, 'user/keys', 'POST', array(
|
|
"title" => ltrim(preg_replace($SSH_KEY_REGEX, '', $key)),
|
|
"key" => $key
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ($toDelete as $id => $ekey) {
|
|
apiRequest($api, 'user/keys/'.$id, 'DELETE');
|
|
}
|
|
}
|
|
|
|
switch ($route[0]) {
|
|
case 'machine':
|
|
case 'network':
|
|
switch ($elname = $route[0]) {
|
|
case 'machine':
|
|
$elementArgs = $machineArgs;
|
|
break;
|
|
case 'network':
|
|
$elementArgs = $networkArgs;
|
|
break;
|
|
}
|
|
|
|
// GET /element
|
|
if (count($route) == 1 && $meth == 'GET') {
|
|
requireSigned();
|
|
$elements = load($elname);
|
|
foreach ($elements as $key => $value) {
|
|
echo $key."\n";
|
|
}
|
|
|
|
// GET /element/{name}
|
|
} elseif (count($route) == 2 && $meth == 'GET') {
|
|
if ($elname != 'machine') {
|
|
requireSigned();
|
|
}
|
|
$elements = load($elname);
|
|
if (isset($elements[$route[1]])) {
|
|
$element = $elements[$route[1]];
|
|
echo displayElement($element, $elementArgs);
|
|
} else {
|
|
http_response_code(404);
|
|
die("Unknown $elname\n");
|
|
}
|
|
|
|
// POST /element
|
|
} elseif (count($route) == 1 && $meth == 'POST') {
|
|
if (isset($_SERVER['HTTP_X_TOTP']) && $elname == 'machine') {
|
|
$restrained = true;
|
|
requireToken();
|
|
} else {
|
|
$restrained = false;
|
|
requireSigned();
|
|
}
|
|
foreach ($_POST as $arg => $value) {
|
|
if ($restrained && $arg == 'network') {
|
|
http_response_code(403);
|
|
die("You can't add a network authenticated like that\n");
|
|
}
|
|
|
|
if (!array_key_exists($arg, $elementArgs)) {
|
|
die("Unknown argument $arg\n");
|
|
}
|
|
}
|
|
$elements = load($elname);
|
|
$element = array();
|
|
foreach ($elementArgs as $arg => $argData) {
|
|
if (!isset($_POST[$arg])) {
|
|
$_POST[$arg] = null;
|
|
}
|
|
if ($err = argAssert($arg, $_POST[$arg], $elementArgs)) {
|
|
http_response_code(400);
|
|
die("$err\n");
|
|
}
|
|
|
|
if ($arg == 'name') {
|
|
$name = $_POST[$arg];
|
|
} else {
|
|
$element[$arg] = $_POST[$arg];
|
|
}
|
|
}
|
|
if (isset($elements[$name])) {
|
|
http_response_code(409);
|
|
die("$elname with the same name already exists\n");
|
|
} else {
|
|
$elements[$name] = $element;
|
|
save($elname, $elements);
|
|
http_response_code(201);
|
|
logActivity(
|
|
"Created $elname \"$name\"\n".displayElement($element, $elementArgs)
|
|
);
|
|
}
|
|
|
|
// POST /element/{name}
|
|
} elseif (count($route) == 2 && $meth == 'POST') {
|
|
requireSigned();
|
|
foreach ($_POST as $arg => $value) {
|
|
if (!array_key_exists($arg, $elementArgs)) {
|
|
die("Unknown argument $arg\n");
|
|
}
|
|
}
|
|
$elements = load($elname);
|
|
if (isset($elements[$route[1]])) {
|
|
$element = $elements[$route[1]];
|
|
foreach ($elementArgs as $arg => $typ) {
|
|
if (isset($_POST[$arg])) {
|
|
if ($arg == 'name') {
|
|
http_response_code(501);
|
|
die("Can't change name\n");
|
|
}
|
|
if ($err = argAssert($arg, $_POST[$arg], $elementArgs)) {
|
|
http_response_code(400);
|
|
die("$err\n");
|
|
}
|
|
$element[$arg] = $_POST[$arg];
|
|
}
|
|
}
|
|
$oldEls = explode(
|
|
"\n", displayElement($elements[$route[1]], $elementArgs)
|
|
);
|
|
|
|
$elements[$route[1]] = $element;
|
|
save($elname, $elements);
|
|
http_response_code(204);
|
|
|
|
$newEls = explode(
|
|
"\n", displayElement($elements[$route[1]], $elementArgs)
|
|
);
|
|
$diff = computeDiff($oldEls, $newEls);
|
|
$t = '';
|
|
foreach ($diff['values'] as $i => $line) {
|
|
switch ($diff['mask'][$i]) {
|
|
case -1:
|
|
$t .= '- '.$line."\n";
|
|
break;
|
|
case 1:
|
|
$t .= '+ '.$line."\n";
|
|
break;
|
|
}
|
|
|
|
}
|
|
logActivity("Modified $elname \"$route[1]\"\n".$t);
|
|
} else {
|
|
http_response_code(404);
|
|
die("Unknown $elname\n");
|
|
}
|
|
|
|
// DELETE /element/{name}
|
|
} elseif (count($route) == 2 && $meth == 'DELETE') {
|
|
requireSigned();
|
|
$elements = load($elname);
|
|
$name = $route[1];
|
|
if (isset($elements[$name])) {
|
|
unset($elements[$name]);
|
|
save($elname, $elements);
|
|
http_response_code(204);
|
|
logActivity("Removed $elname \"$name\"");
|
|
} else {
|
|
http_response_code(404);
|
|
die("Unknown $elname\n");
|
|
}
|
|
|
|
} else {
|
|
http_response_code(501);
|
|
die("Unkown route\n");
|
|
}
|
|
break;
|
|
|
|
// Authorized keys for networks
|
|
case 'akey':
|
|
|
|
// GET /akey/{network}
|
|
if ((count($route) == 1 || count($route) == 2) && $meth == 'GET') {
|
|
if (count($route) == 2) {
|
|
$networkName = $route[1];
|
|
} else {
|
|
$networkName = '';
|
|
}
|
|
$networks = load('network');
|
|
if (!$networkName || isset($networks[$networkName])) {
|
|
if ($networkName) {
|
|
$network = $networks[$networkName];
|
|
}
|
|
|
|
// GET /akey/{network}?signature
|
|
if (isset($_GET['signature'])) {
|
|
echo file_get_contents(
|
|
'akey/'.$networkName.'.authorized_keys.sha256'
|
|
);
|
|
|
|
// GET /akey/{network}?unsigned
|
|
} elseif (isset($_GET['unsigned'])) {
|
|
requireSigned();
|
|
echo getKeys($networkName ? $network : null);
|
|
|
|
// GET /akey/{network}
|
|
} else {
|
|
echo file_get_contents('akey/'.$networkName.'.authorized_keys');
|
|
}
|
|
} else {
|
|
http_response_code(404);
|
|
die("Unknown network\n");
|
|
}
|
|
|
|
// PUT /akey/{network}
|
|
} elseif ((count($route) == 1 || count($route) == 2) && $meth == 'PUT') {
|
|
requireSigned();
|
|
if (count($route) == 2) {
|
|
$networkName = $route[1];
|
|
} else {
|
|
$networkName = '';
|
|
}
|
|
$networks = load('network');
|
|
if (!$networkName || isset($networks[$networkName])) {
|
|
if ($networkName) {
|
|
$network = $networks[$networkName];
|
|
}
|
|
|
|
$sign = file_get_contents('php://input');
|
|
file_put_contents(
|
|
'akey/'.$networkName.'.authorized_keys',
|
|
getKeys($networkName ? $network : null)
|
|
);
|
|
file_put_contents('akey/'.$networkName.'.authorized_keys.sha256', $sign);
|
|
|
|
if (array_key_exists($networkName, $GIT_APIS)) {
|
|
updateGitKeys($GIT_APIS[$networkName], getKeys($network));
|
|
}
|
|
|
|
http_response_code(201);
|
|
logActivity('Updated akeys '.$networkName);
|
|
} else {
|
|
http_response_code(404);
|
|
die("Unknown network\n");
|
|
}
|
|
} else {
|
|
http_response_code(501);
|
|
die("Unkown route\n");
|
|
}
|
|
break;
|
|
|
|
// Authorized keys for networks
|
|
case 'config':
|
|
|
|
// GET /config/{machine}
|
|
if (count($route) == 2 && $meth == 'GET') {
|
|
$machineName = $route[1];
|
|
$machines = load('machine');
|
|
$networks = load('network');
|
|
if (isset($machines[$machineName])) {
|
|
$machine = $machines[$machineName];
|
|
if (!isset($machine['network'])
|
|
|| !isset($networks[$machine['network']])
|
|
) {
|
|
break;
|
|
}
|
|
$network = $network[$machine['network']];
|
|
|
|
foreach ($machines as $dMachineName => $dMachine) {
|
|
if ($network['secure'] == 'true') {
|
|
|
|
}
|
|
}
|
|
|
|
var_dump($machine);
|
|
|
|
} else {
|
|
http_response_code(404);
|
|
die("Unknown machine\n");
|
|
}
|
|
|
|
} else {
|
|
http_response_code(501);
|
|
die("Unkown route\n");
|
|
}
|
|
break;
|
|
|
|
// Activity log
|
|
case 'log':
|
|
if (count($route) == 1 && $meth == 'GET') {
|
|
requireSigned();
|
|
if (!isset($_GET['from']) || !$from = intval($_GET['from'])) {
|
|
$from = 0;
|
|
}
|
|
$file = fopen('machines.log', 'r');
|
|
while (!feof($file)) {
|
|
$line = fgets($file);
|
|
if ($line == '') {
|
|
continue;
|
|
}
|
|
preg_match('/^(\d+)\|(.+)$/', $line, $matches);
|
|
if (intval($matches[1]) > $from) {
|
|
echo $matches[2]."\n";
|
|
}
|
|
}
|
|
fclose($file);
|
|
}
|
|
break;
|
|
case 'totp':
|
|
global $TOTP;
|
|
if (count($route) == 1 && $meth == 'GET') {
|
|
requireSigned();
|
|
echo $TOTP->getProvisioningUri();
|
|
}
|
|
break;
|
|
case 'cert':
|
|
if (count($route) == 1 && $meth == 'GET') {
|
|
echo file_get_contents('machines.crt');
|
|
}
|
|
break;
|
|
default:
|
|
http_response_code(501);
|
|
die("Unkown route\n");
|
|
}
|
|
|
|
|
|
?>
|