Commit 8397bf9f authored by kobuki's avatar kobuki
Browse files

added experimental module ispc_acmeproxy

parent d9a5bb64
### About this repository
The ispc_acmeproxy is an attempt to create an environment running alongside an existing ISPConfig installation and uses normal user login credentials to edit DNS TXT records needed for the ACME DNS domain validation. It's primarily made for the [acme.sh](https://github.com/Neilpang/acme.sh) client. The ISPConfig DNS plugin for `acme.sh` can be used without changes, using user credentials instead of remoting api credentials that cannot be restricted to user resources.
### Suggested usage
1. create an ISPC remoting user with proper rights (Client functions, DNS zone functions, DNS txt functions)
2. checkout the repository and copy the ispc_acmeproxy to a convenient place
3. create a new vhost under your web server and point the root to ispc_acmeproxy/web
4. copy ispc_acmeproxy/config/config.inc.php.sample to ispc_acmeproxy/config/config.inc.php and edit accordingly (use credentials from step 1)
5. reload your web server, test using the `acme.sh` client, etc.
You'll need to provide user credentials instead of the API credentials for the DNS plugin as per [the docs](https://github.com/Neilpang/acme.sh/tree/master/dnsapi).
### Etc.
See this [forum thread](https://www.howtoforge.com/community/threads/restricting-api-users-to-certain-domains-or-limiting-to-one-clients-resources.80450/)
Suggestions, issue reports, PRs are welcome.
<?php
$config = [
'apiUrl' => 'https://ispconfig.example.com:8443/remote/json.php',
'apiUsername' => 'remotingUserName',
'apiPassword' => 'remotingUserPass',
'dnsServerId' => 5
];
<?php
require_once '../config/config.inc.php';
require_once 'utils.php';
class AcmeWrapper {
private $sessionId;
private $client;
private $clientZones;
private $clientZoneNames;
private $username;
private $config;
function __construct($config) {
$this->config = $config;
$resp = $this->jsonCall('login', [
'username' => $config['apiUsername'],
'password' => $config['apiPassword'],
'client_login' => false]);
if ($resp->code != 'ok') {
throw new RuntimeException('API setup error');
}
$this->sessionId = $resp->response;
}
public function jsonCall($function, $data = null) {
if (empty($data)) {
$data = new stdClass();
} else {
$data = (object)$data;
}
$data->session_id = $this->sessionId;
return jsonRequest("{$this->config['apiUrl']}?$function", $data);
}
public function initClient($username, $password, $sessionId) {
if (!$username && !$sessionId) {
throw new RuntimeException("Invalid credentials");
}
if ($sessionId) {
session_id($sessionId);
session_start();
$username = $_SESSION['username'];
$password = $_SESSION['password'];
} else {
session_start();
$sessionId = session_id();
$_SESSION['username'] = $username;
$_SESSION['password'] = $password;
}
$this->client = $this->clientZones = $this->clientZoneNames = null;
$this->username = $username;
$client = $this->jsonCall('client_get_by_username', ['username' => $username]);
if($client->code != 'ok' || !$this->check_crypt_value($password, $client->response->passwort)) {
throw new InvalidArgumentException("Invalid credentials");
}
$this->client = $client->response;
$clientZones = $this->jsonCall('dns_zone_get_by_user',
['client_id' => $this->client->client_id, 'server_id' => $this->config['dnsServerId']]);
$this->clientZones = $clientZones->response;
foreach ($this->clientZones as $clientZone) {
$this->clientZoneNames[$clientZone->origin] = $clientZone->id;
}
return $sessionId;
}
function __destruct() {
if ($this->sessionId) {
$this->jsonCall('logout');
}
}
/**
* @param $password
* @param $saved_password
* @return bool
*/
private static function check_crypt_value($password, $saved_password) {
if($saved_password[0] == '{') {
// remove Dovecot-style password prefix (used for email user logins)
// example: {MD5-CRYPT}$1$12345678$MfjBLH.L2J1K2v0dXHkeJ/
$saved_password = substr($saved_password, strpos($saved_password, '}') + 1);
}
if($saved_password[0] == '$') {
// assume prefixed crypt() hash
// $saved_password can be used as the salt, as php ignores the part after the last $ character
return crypt(stripslashes($password), $saved_password) == $saved_password;
} else {
// assume MD5 hash
return md5(stripslashes($password)) == $saved_password;
}
}
/**
* @param $code
* @param $message
* @param string $data
*/
private function returnJson($code, $message, $data = '') {
$ret = new stdClass;
$ret->code = $code;
$ret->message = $message;
$ret->response = $data;
header('Content-Type: application/json; charset="utf-8"');
echo json_encode($ret);
}
private function proxyCall($method, $data) {
$resp = $this->jsonCall($method, $data);
$this->returnJson($resp->code, $resp->message, $resp->response);
}
public function incrementZoneSerial($zoneId) {
$soa = $this->jsonCall('dns_zone_get', ['primary_id' => $zoneId])->response;
$serial = $soa->serial;
$serial_date = intval(substr($serial, 0, 8));
$count = intval(substr($serial, 8, 2));
$current_date = date("Ymd");
if($serial_date >= $current_date){
$count += 1;
if ($count > 99) {
$serial_date += 1;
$count = 0;
}
$count = str_pad($count, 2, "0", STR_PAD_LEFT);
$new_serial = $serial_date . $count;
} else {
$new_serial = $current_date.'01';
}
$soa->serial = $new_serial;
$this->jsonCall('dns_zone_update', ['client_id' => $this->client->client_id, 'primary_id' => $soa->id, 'params' => $soa]);
}
public function handleJson() {
if(!isset($_GET) || !is_array($_GET) || count($_GET) < 1) {
$this->returnJson('invalid_method', 'Method not provided in json call');
return;
}
try {
$keys = array_keys($_GET);
$method = reset($keys);
$raw = file_get_contents("php://input");
$data = json_decode($raw);
if (empty($data)) throw new RuntimeException('Invalid JSON data');
switch ($method) {
case 'login':
$sid = $this->initClient($data->username, $data->password, null);
$this->returnJson('ok', '', $sid);
break;
case 'dns_zone_get':
$this->initClient(null, null, $data->session_id);
if(!array_key_exists($data->primary_id->origin, $this->getClientZoneNames())) {
throw new RuntimeException('Permission denied');
}
$this->proxyCall($method, $data);
break;
case 'dns_txt_add':
$this->initClient(null, null, $data->session_id);
$fulldomain = $data->params->name;
$zoneId = -1;
foreach ($this->clientZoneNames as $d => $id) {
if (endsWith($fulldomain, $d)) {
$zoneId = $id;
break;
}
}
if ($data->params->server_id != $this->config['dnsServerId'] || $zoneId < 0) {
throw new RuntimeException('Permission denied');
}
$this->proxyCall($method, $data);
$this->incrementZoneSerial($zoneId);
break;
case 'dns_txt_get':
$this->initClient(null, null, $data->session_id);
$fulldomain = $data->primary_id->name;
$zoneId = -1;
foreach ($this->clientZoneNames as $d => $id) {
if (endsWith($fulldomain, $d)) {
$zoneId = $id;
break;
}
}
if ($zoneId < 0) {
throw new RuntimeException('Permission denied');
}
$this->proxyCall($method, $data);
break;
case 'dns_txt_delete':
$this->initClient(null, null, $data->session_id);
$recordId = $data->primary_id;
$record = $this->jsonCall('dns_txt_get', ['primary_id' => $recordId])->response;
if (empty($record) || array_search($record->zone, $this->clientZoneNames) === false || $record->type != 'TXT') {
throw new RuntimeException('Permission denied');
}
$this->proxyCall($method, $data);
$this->incrementZoneSerial($record->zone);
break;
}
} catch (Exception $e) {
$this->returnJson('invalid_data', $e->getMessage());
}
}
/**
* @return mixed
*/
public function getClient() {
return $this->client;
}
/**
* @return mixed
*/
public function getClientZones() {
return $this->clientZones;
}
/**
* @return mixed
*/
public function getClientZoneNames() {
return $this->clientZoneNames;
}
}
$acme = new AcmeWrapper($config);
$acme->handleJson();
<?php
/**
* @param string $data
* @param string $key
* @param string $method
* @return string
*/
function encrypt(string $data, string $key, string $method): string
{
$ivSize = openssl_cipher_iv_length($method);
$iv = openssl_random_pseudo_bytes($ivSize);
$encrypted = openssl_encrypt($data, $method, $key, OPENSSL_RAW_DATA, $iv);
// For storage/transmission, we simply concatenate the IV and cipher text
//$encrypted = base64_encode($iv . $encrypted);
$encrypted = bin2hex($iv . $encrypted);
return $encrypted;
}
/**
* @param string $data
* @param string $key
* @param string $method
* @return string
*/
function decrypt(string $data, string $key, string $method): string
{
//$data = base64_decode($data);
$data = hex2bin($data);
$ivSize = openssl_cipher_iv_length($method);
$iv = substr($data, 0, $ivSize);
$data = openssl_decrypt(substr($data, $ivSize), $method, $key, OPENSSL_RAW_DATA, $iv);
return $data;
}
/**
* @param $string
* @param $endString
* @return bool
*/
function endsWith($string, $endString)
{
$len = strlen($endString);
if ($len == 0) {
return true;
}
return (substr($string, -$len) === $endString);
}
function jsonRequest($url, $data) {
$request = array(
'http' => array(
'method' => 'POST',
'content' => json_encode($data),
'header' =>
"Content-Type: application/json\r\n" .
"Accept: application/json\r\n"
)
);
$context = stream_context_create($request);
$result = file_get_contents($url, false, $context);
$response = json_decode($result);
return $response;
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment