diff --git a/ispc_acmeproxy/README.md b/ispc_acmeproxy/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a825360b3fbb33fea1b2b1b1aa5d9f04b2334daf --- /dev/null +++ b/ispc_acmeproxy/README.md @@ -0,0 +1,21 @@ +### About this module + +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 + +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 0000000000000000000000000000000000000000..c6e063bad6bcef2b2d7d209b6f0b8f4c8cca9306 --- /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 0000000000000000000000000000000000000000..59cfe574e8cf7c3a8387fb2875839f4a6d434c12 --- /dev/null +++ b/ispc_acmeproxy/web/acmeproxy.php @@ -0,0 +1,221 @@ +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 InvalidArgumentException("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->checkCryptValue($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 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] == '$') { + // 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->clientZoneNames)) { + 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()); + } + } +} + +$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 0000000000000000000000000000000000000000..500090be209c1eee278531a124ac309256a4e9f3 --- /dev/null +++ b/ispc_acmeproxy/web/utils.php @@ -0,0 +1,69 @@ + 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; +}