array( 'type' => 'string', 'pattern' => '/^[a-zA-Z0-9\-_]+$/', 'repeatable' => false, 'optional' => false ), 'host' => array( 'type' => 'string', 'pattern' => '/^'.$DOMAIN_NAME_REGEX.'(:\d+)?$/', 'repeatable' => true, 'optional' => true ), 'user' => array( 'type' => 'string', 'pattern' => '/^[a-zA-Z0-9\-_]+$/', 'repeatable' => false, 'optional' => false ), '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')); } 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 gogsRequest($route, $meth = 'GET', $data = null) { global $GOGS_API; global $GOGS_TOKEN; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $GOGS_API.'/'.$route); curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: token '.$GOGS_TOKEN)); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $meth); if ($data) { $textdata = ''; foreach ($data as $key => $value) { $textdata .= '&'.$key.'='.urlencode($value); } curl_setopt($ch, CURLOPT_POST, count($data)); curl_setopt($ch, CURLOPT_POSTFIELDS, ltrim($textdata, '&')); } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $raw = curl_exec($ch); curl_close($ch); return json_decode($raw); } function updateGogsKeys($keys) { global $GOGS_API; global $GOGS_TOKEN; global $SSH_KEY_REGEX; if (isset($GOGS_API) && isset($GOGS_TOKEN)) { $existing = gogsRequest('user/keys'); $toDelete = []; foreach ($existing as $ekey) { $toDelete[$ekey->id] = $ekey->key; } foreach (explode("\n", $keys) as $key) { $found = false; foreach ($toDelete as $id => $ekey) { if ($key == $ekey) { unset($toDelete[$id]); $found = true; break; } } if (!$found) { gogsRequest('user/keys', 'POST', array( "title" => ltrim(preg_replace($SSH_KEY_REGEX, '', $key)), "key" => $key )); } } foreach ($toDelete as $id => $ekey) { gogsRequest('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); if (isset($elements[$route[1]])) { unset($elements[$route[1]]); 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 ($networkName == 'gogs') { updateGogsKeys(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; // 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"); } ?>