From 8397bf9f4f8505db2d063d048b49ddff7a7c24ed Mon Sep 17 00:00:00 2001 From: kobuki Date: Thu, 15 Nov 2018 18:59:57 +0100 Subject: [PATCH 1/4] added experimental module ispc_acmeproxy --- ispc_acmeproxy/README.md | 21 ++ ispc_acmeproxy/config/config.inc.php.sample | 8 + ispc_acmeproxy/web/acmeproxy.php | 232 ++++++++++++++++++++ ispc_acmeproxy/web/utils.php | 68 ++++++ 4 files changed, 329 insertions(+) create mode 100644 ispc_acmeproxy/README.md create mode 100644 ispc_acmeproxy/config/config.inc.php.sample create mode 100644 ispc_acmeproxy/web/acmeproxy.php create mode 100644 ispc_acmeproxy/web/utils.php diff --git a/ispc_acmeproxy/README.md b/ispc_acmeproxy/README.md new file mode 100644 index 0000000..8e93b00 --- /dev/null +++ b/ispc_acmeproxy/README.md @@ -0,0 +1,21 @@ +### 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. diff --git a/ispc_acmeproxy/config/config.inc.php.sample b/ispc_acmeproxy/config/config.inc.php.sample new file mode 100644 index 0000000..c6e063b --- /dev/null +++ b/ispc_acmeproxy/config/config.inc.php.sample @@ -0,0 +1,8 @@ + 'https://ispconfig.example.com:8443/remote/json.php', + 'apiUsername' => 'remotingUserName', + 'apiPassword' => 'remotingUserPass', + 'dnsServerId' => 5 +]; diff --git a/ispc_acmeproxy/web/acmeproxy.php b/ispc_acmeproxy/web/acmeproxy.php new file mode 100644 index 0000000..0830479 --- /dev/null +++ b/ispc_acmeproxy/web/acmeproxy.php @@ -0,0 +1,232 @@ +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(); diff --git a/ispc_acmeproxy/web/utils.php b/ispc_acmeproxy/web/utils.php new file mode 100644 index 0000000..1dd8e14 --- /dev/null +++ b/ispc_acmeproxy/web/utils.php @@ -0,0 +1,68 @@ + 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; +} -- GitLab From bc9cf76bcccaf2b5206b45cb748d192a41d6f577 Mon Sep 17 00:00:00 2001 From: kobuki Date: Thu, 15 Nov 2018 19:03:52 +0100 Subject: [PATCH 2/4] fixed README.md --- ispc_acmeproxy/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ispc_acmeproxy/README.md b/ispc_acmeproxy/README.md index 8e93b00..e1eb0b3 100644 --- a/ispc_acmeproxy/README.md +++ b/ispc_acmeproxy/README.md @@ -1,6 +1,6 @@ ### 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. +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 @@ -9,7 +9,7 @@ The ispc_acmeproxy is an attempt to create an environment running alongside an e 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. +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). -- GitLab From e4cb5a465970aefbb057bcce187edc51517e00d9 Mon Sep 17 00:00:00 2001 From: kobuki Date: Thu, 15 Nov 2018 19:06:54 +0100 Subject: [PATCH 3/4] amended README.md --- ispc_acmeproxy/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ispc_acmeproxy/README.md b/ispc_acmeproxy/README.md index e1eb0b3..a825360 100644 --- a/ispc_acmeproxy/README.md +++ b/ispc_acmeproxy/README.md @@ -1,6 +1,6 @@ -### About this repository +### About this module -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. +The experimental ispc_acmeproxy module 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. Experimental means it works fine for me where I need it, but probably needs more rigorous testing. ### Suggested usage -- GitLab From bad4ffeb0b1958c37c3783929fafaf67cf55e93c Mon Sep 17 00:00:00 2001 From: kobuki Date: Thu, 15 Nov 2018 19:17:40 +0100 Subject: [PATCH 4/4] cosmetic fixes, formatting --- ispc_acmeproxy/web/acmeproxy.php | 105 ++++++++++++++----------------- ispc_acmeproxy/web/utils.php | 3 +- 2 files changed, 49 insertions(+), 59 deletions(-) diff --git a/ispc_acmeproxy/web/acmeproxy.php b/ispc_acmeproxy/web/acmeproxy.php index 0830479..59cfe57 100644 --- a/ispc_acmeproxy/web/acmeproxy.php +++ b/ispc_acmeproxy/web/acmeproxy.php @@ -3,7 +3,8 @@ require_once '../config/config.inc.php'; require_once 'utils.php'; -class AcmeWrapper { +class AcmeWrapper +{ private $sessionId; private $client; @@ -12,7 +13,8 @@ class AcmeWrapper { private $username; private $config; - function __construct($config) { + function __construct($config) + { $this->config = $config; $resp = $this->jsonCall('login', [ @@ -25,7 +27,8 @@ class AcmeWrapper { $this->sessionId = $resp->response; } - public function jsonCall($function, $data = null) { + public function jsonCall($function, $data = null) + { if (empty($data)) { $data = new stdClass(); } else { @@ -35,9 +38,10 @@ class AcmeWrapper { return jsonRequest("{$this->config['apiUrl']}?$function", $data); } - public function initClient($username, $password, $sessionId) { + public function initClient($username, $password, $sessionId) + { if (!$username && !$sessionId) { - throw new RuntimeException("Invalid credentials"); + throw new InvalidArgumentException("Invalid credentials"); } if ($sessionId) { session_id($sessionId); @@ -53,7 +57,7 @@ class AcmeWrapper { $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)) { + if ($client->code != 'ok' || !$this->checkCryptValue($password, $client->response->passwort)) { throw new InvalidArgumentException("Invalid credentials"); } $this->client = $client->response; @@ -66,7 +70,8 @@ class AcmeWrapper { return $sessionId; } - function __destruct() { + function __destruct() + { if ($this->sessionId) { $this->jsonCall('logout'); } @@ -77,14 +82,15 @@ class AcmeWrapper { * @param $saved_password * @return bool */ - private static function check_crypt_value($password, $saved_password) { - if($saved_password[0] == '{') { + private static function checkCryptValue($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] == '$') { + 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; @@ -99,7 +105,8 @@ class AcmeWrapper { * @param $message * @param string $data */ - private function returnJson($code, $message, $data = '') { + private function returnJson($code, $message, $data = '') + { $ret = new stdClass; $ret->code = $code; $ret->message = $message; @@ -109,34 +116,37 @@ class AcmeWrapper { echo json_encode($ret); } - private function proxyCall($method, $data) { + 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) { + 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; } @@ -154,7 +164,7 @@ class AcmeWrapper { break; case 'dns_zone_get': $this->initClient(null, null, $data->session_id); - if(!array_key_exists($data->primary_id->origin, $this->getClientZoneNames())) { + if (!array_key_exists($data->primary_id->origin, $this->clientZoneNames)) { throw new RuntimeException('Permission denied'); } $this->proxyCall($method, $data); @@ -198,34 +208,13 @@ class AcmeWrapper { throw new RuntimeException('Permission denied'); } $this->proxyCall($method, $data); - $this->incrementZoneSerial($record->zone); + $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); diff --git a/ispc_acmeproxy/web/utils.php b/ispc_acmeproxy/web/utils.php index 1dd8e14..500090b 100644 --- a/ispc_acmeproxy/web/utils.php +++ b/ispc_acmeproxy/web/utils.php @@ -51,7 +51,8 @@ function endsWith($string, $endString) return (substr($string, -$len) === $endString); } -function jsonRequest($url, $data) { +function jsonRequest($url, $data) +{ $request = array( 'http' => array( 'method' => 'POST', -- GitLab