LetsEncrypt.php 32KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. #!/usr/bin/php
  2. <?php
  3. set_time_limit(0);
  4. function exception_handler($e) {
  5. $error = error_get_last();
  6. fwrite(STDERR, "\nFataler Fehler: ".$e->getMessage()."\n".($error['message'] && $e->getCode()%2 ? " (".$error['message'].")\n" : "")."\n");
  7. exit(1);
  8. }
  9. set_exception_handler('exception_handler');
  10. function info($text) {
  11. if (!is_string($text)) throw new Exception("Unbekannte Eingabe in debug()");
  12. else fwrite(STDERR, $text);
  13. return $text;
  14. }
  15. class LetsEncrypt {
  16. private
  17. $testonly = false,
  18. $directory = 'https://acme-v01.api.letsencrypt.org/directory',
  19. $resources = null,
  20. $nonce = '',
  21. $header,
  22. $account_key;
  23. protected
  24. $thumbprint,
  25. $acme_path = '.well-known/acme-challenge/';
  26. static $keytypes = array(
  27. OPENSSL_KEYTYPE_RSA => "RSA",
  28. OPENSSL_KEYTYPE_DSA => "DSA",
  29. OPENSSL_KEYTYPE_DH => "DH",
  30. OPENSSL_KEYTYPE_EC => "EC",
  31. -1 => "Unbekannt"
  32. );
  33. public function __construct($account_key_pem, $testonly=false) {
  34. if ($testonly || $this->testonly) { $this->directory = 'https://acme-staging.api.letsencrypt.org/directory'; $this->testonly = true; }
  35. if (false === ($this->account_key=openssl_pkey_get_private('file://'.$account_key_pem))) throw new Exception("Der Account-Schlüssel unter ".$account_key_pem." konnte nicht geladen werden:\n".openssl_error_string());
  36. if (false === ($details = openssl_pkey_get_details($this->account_key))) throw new Exception("Der Account-Schlüssel unter ".$account_key_pem." kann nicht ausgelesen werden:\n".openssl_error_string());
  37. if ($details['type'] != OPENSSL_KEYTYPE_RSA) throw new Exception("Der Account-Schlüssel unter ".$account_key_pem." ist vom falschen Typ (".$this->keytypes[$details['type']]."). Er muss vom Typ RSA sein.");
  38. if ($details['bits'] < 2048) throw new Exception("Der Account-Schlüssel unter ".$account_key_pem." ist zu kurz (".$details['bits']."). Er muss mindestens 2048 Bit lang sein.");
  39. $this->header = array( // JOSE Header - RFC7515
  40. 'alg'=>'RS256',
  41. 'jwk'=>array( // JSON Web Key
  42. 'e'=>$this->base64url($details['rsa']['e']), // public exponent
  43. 'kty'=>'RSA',
  44. 'n'=>$this->base64url($details['rsa']['n']) // public modulus
  45. ));
  46. $this->thumbprint = $this->base64url(hash('sha256', json_encode($this->header['jwk']), true)); // JSON Web Key (JWK) Thumbprint - RFC7638
  47. }
  48. public function __destruct() {
  49. if ($this->account_key) openssl_pkey_free($this->account_key);
  50. }
  51. private function init() {
  52. $ret = $this->http_request($this->directory);
  53. $this->resources = $ret['body'];
  54. $this->nonce = $ret['headers']['replay-nonce'];
  55. }
  56. private function jws_encapsulate($payload) { // Encapsulate $payload into JSON Web Signature (JWS) - RFC7515
  57. $protected64 = $this->base64url(json_encode(array_merge($this->header, array("nonce" => $this->nonce))));
  58. $payload64 = $this->base64url(json_encode($payload));
  59. if (false === openssl_sign($protected64.'.'.$payload64, $signature, $this->account_key, OPENSSL_ALGO_SHA256)) throw new Exception("Die Nachricht konnte nicht signiert werden:\n".openssl_error_string());
  60. return array(
  61. 'header'=>$this->header,
  62. 'protected'=>$protected64,
  63. 'payload'=>$payload64,
  64. 'signature'=>$this->base64url($signature)
  65. );
  66. }
  67. final protected function base64url($data) { // RFC7515 - Appendix C
  68. return rtrim(strtr(base64_encode($data),'+/','-_'),'=');
  69. }
  70. final protected function request($type, $payload=array(), $url=null, $raw=false, $accept=null) {
  71. if ($this->resources === null) $this->init();
  72. $data = json_encode($this->jws_encapsulate(array_merge($payload, array('resource' => $type))));
  73. $ret = $this->http_request($url === null ? $this->resources[$type] : $url, $data, $raw, $accept);
  74. $this->nonce = $ret['headers']['replay-nonce'];
  75. return $ret;
  76. }
  77. final protected function http_request($url, $data=null, $raw=false, $accept=null) {
  78. $ctx = stream_context_create(array(
  79. 'http' => array(
  80. 'header' => $data === null ? '' : 'Content-Type: application/json',
  81. 'method' => $data === null ? 'GET' : 'POST',
  82. 'user_agent' => 'PHP-Client, https://www.gitlab.de/christopher/Scripts/src/master/LetsEncrypt.php',
  83. 'ignore_errors' => true,
  84. 'timeout' => 60,
  85. 'content' => $data
  86. ), 'ssl' => array('verify_peer' => false, 'verify_peer_name' => false)
  87. ));
  88. $body = @file_get_contents($url, false, $ctx);
  89. if (false === $body) throw new Exception("Fehler beim Aufrufen des folgenden URLs: ".$url);
  90. $start = 0;
  91. foreach ($http_response_header as $k => $v) if (false === strpos($v, ":")) $start = $k;
  92. $http_response_header = array_slice($http_response_header, $start);
  93. list(, $code, $status) = explode(" ", reset($http_response_header), 3);
  94. $headers = array_reduce(array_slice($http_response_header, 1), function($carry, $item) {
  95. list($k, $v) = explode(':', $item, 2);
  96. $k = strtolower(trim($k));
  97. $v = trim($v);
  98. if ($k === 'link') { if (preg_match("/<(.*)>\\s*;\\s*rel=\"(.*)\"/", $v, $matches)) $carry['link'][$matches[2]] = $matches[1]; }
  99. else $carry[$k] = $v;
  100. return $carry;
  101. }, array());
  102. if (!$raw) $json = $body == "" ? "" : json_decode($body, true);
  103. else $json = null;
  104. if (is_array($json)) {
  105. if (($code != $accept) && isset($json['detail'])) throw new Exception($json['detail']." (".$code.")");
  106. if (($code != $accept) && isset($json['error']) && is_array($json['error']) && isset($json['error']['detail'])) throw new Exception($json['error']['detail']." (".$code.")");
  107. }
  108. if (($code != $accept) && ($code[0] != '2')) throw new Exception("Fehler beim Aufruf des URLs ".$url.": [".$code."] ".$status);
  109. if (!$raw) {
  110. if ($json === null) throw new Exception('Fehler beim Decodieren der JSON: '.print_r($headers,true).$body);
  111. else $body=$json;
  112. }
  113. $ret = array(
  114. 'code' => $code,
  115. 'status' => $status,
  116. 'headers' => $headers,
  117. 'body' => $body
  118. );
  119. return $ret;
  120. }
  121. final protected function write_challenge($webroots, $challenge) {
  122. foreach ($webroots as $webroot) {
  123. $webroot = dirname($webroot."/x")."/";
  124. @mkdir($webroot.$this->acme_path, 0755, true);
  125. if (!is_dir($webroot.$this->acme_path)) throw new Exception("Das Verzeichnis ".$webroot.$this->acme_path." konnte nicht angelegt werden");
  126. if (false === @file_put_contents($webroot.$this->acme_path.$challenge['token'], $challenge['token'].'.'.$this->thumbprint)) throw new Exception("Die Datei ".$webroot.$this->acme_path.$challenge['token']." konnte nicht angelegt werden.");
  127. }
  128. }
  129. final protected function remove_challenge($webroots, $challenge) {
  130. foreach ($webroots as $webroot) {
  131. $old = error_reporting(0);
  132. @unlink($webroot.$this->acme_path.$challenge['token']);
  133. @rmdir($webroot.$this->acme_path);
  134. @rmdir($webroot.dirname($this->acme_path));
  135. error_reporting($old);
  136. }
  137. }
  138. final protected function pem2der($pem) {
  139. return base64_decode(implode('',array_slice(array_map('trim', explode("\n", trim($pem))), 1, -1)));
  140. }
  141. final protected function der2pem($der) {
  142. return "-----BEGIN CERTIFICATE-----\n".chunk_split(base64_encode($der), 64, "\n")."-----END CERTIFICATE-----\n";
  143. }
  144. final protected function generate_csr($domain_key_pem, $domains) {
  145. if (false === ($domain_key = openssl_pkey_get_private('file://'.$domain_key_pem))) throw new Exception("Der Domain-Schlüssel unter ".$domain_key_pem." konnte nicht geladen werden:\n".openssl_error_string());
  146. if (false === ($details = openssl_pkey_get_details($domain_key))) throw new Exception("Der Account-Schlüssel unter ".$domain_key_pem." kann nicht ausgelesen werden:\n".openssl_error_string());
  147. if ($details['type'] != OPENSSL_KEYTYPE_RSA) throw new Exception("Der Account-Schlüssel unter ".$domain_key_pem." ist vom falschen Typ (".$this->keytypes[$details['type']]."). Er muss vom Typ RSA sein.");
  148. if ($details['bits'] < 2048) throw new Exception("Der Account-Schlüssel unter ".$domain_key_pem." ist zu kurz (".$details['bits']."). Er muss mindestens 2048 Bit lang sein.");
  149. if (false === ($conffile = tempnam("/tmp", "csrconfig_"))) throw new Exception('Kann keine temporäre Datei für die OpenSSL-Config eines neuen Zertifikat-Antrags in /tmp anlegen.');
  150. if (false === @file_put_contents($conffile,
  151. 'HOME = .'."\n".
  152. 'RANDFILE=$ENV::HOME/.rnd'."\n".
  153. '[req]'."\n".
  154. 'distinguished_name=req_distinguished_name'."\n".
  155. '[req_distinguished_name]'."\n".
  156. '[v3_req]'."\n".
  157. '[v3_ca]'."\n".
  158. '[SAN]'."\n".
  159. 'subjectAltName='.implode(',',array_map(function($domain) {return 'DNS:'.$domain;},$domains))."\n"
  160. )) throw new Exception('Kann die temporäre Datei '.$conffile.' für die OpenSSL-Config eines neuen RSA-Schlüssels nicht beschreiben.');
  161. $csr = openssl_csr_new(array('commonName' => reset($domains)), $domain_key, array(
  162. 'config' => $conffile,
  163. 'req_extensions'=>'SAN',
  164. 'digest_alg'=>'sha512'
  165. ));
  166. @unlink($conffile);
  167. openssl_pkey_free($domain_key);
  168. if (!$csr) throw new Exception("Der Zertifikats-Antrag konnte nicht erstellt werden:\n".openssl_error_string());
  169. if (false === openssl_csr_export($csr, $out)) throw new Exception("Der Zertifikats-Antrag konnte nicht exportiert werden:\n".openssl_error_string());
  170. return $out;
  171. }
  172. public function register($email=null) {
  173. $data = $email ? array('contact' => array('mailto:'.$email)) : array();
  174. $ret = $this->request('new-reg', $data, null, false, 409);
  175. switch ($ret['code']) {
  176. case 409:
  177. $reg = $ret['headers']['location'];
  178. $ret = $this->request('reg', $data, $reg);
  179. info("Der Account existiert bereits.\n");
  180. break;
  181. case 201:
  182. $reg = $ret['headers']['location'];
  183. info("Der Account wurde neu erstellt\n");
  184. break;
  185. default:
  186. throw new Exception('Fehler bei der Registration: '.$ret['body']['detail']);
  187. break;
  188. }
  189. echo 'Account ID: '.$ret['body']['id']."\n";
  190. echo 'Erstellt am: '.$ret['body']['createdAt']."\n";
  191. if (!isset($ret['body']['contact']) || empty($ret['body']['contact'])) echo 'Kontakt: [nicht definiert]'."\n";
  192. else echo 'Kontakt: '.implode(', ',$ret['body']['contact'])."\n";
  193. if (!isset($ret['body']['agreement'])) {
  194. $data['agreement'] = $ret['headers']['link']['terms-of-service'];
  195. $this->request('reg', $data, $reg);
  196. }
  197. }
  198. public function get_cert($domain_key_pem, $webroots=array(), $domains=array(), $outputs=array()) {
  199. if (!is_string($domain_key_pem) || !is_array($webroots) || !is_array($domains) || !is_array($outputs)) throw new Exception("Ungültige Eingabe in get_cert");
  200. info("Validiere Domains:\n");
  201. foreach ($domains as $domain) {
  202. if (!is_string($domain)) throw new Exception("Ungültige Eingabe in get_cert");
  203. info(" - Starte Anfrage für ".$domain);
  204. $ret = $this->request('new-authz', array('identifier' => array('type' => 'dns', 'value' => $domain)));
  205. if ($ret['code']!=201) throw new Exception("Unerwarteter HTTP-Code: ".$ret['code'].' (Sollte sein: 201)');
  206. info(".");
  207. foreach ($ret['body']['challenges'] as $challenge) if ($challenge["type"] == "http-01") break;
  208. if (empty($challenge)) throw new Exception('http-01-Challenge nicht gefunden');
  209. $this->write_challenge($webroots, $challenge);
  210. info(".");
  211. $ret = $this->request('challenge', array('keyAuthorization' => $challenge['token'].'.'.$this->thumbprint), $challenge['uri']);
  212. if ($ret['code'] != 202) {
  213. $this->remove_challenge($webroots,$challenge);
  214. throw new Exception('Unerwarteter HTTP-Code: '.$ret['code']. '(Sollte sein: 202)');
  215. }
  216. info(". OK\n Erwarte positive Antwort vom ACME-Server");
  217. $tries = 10;
  218. $delay = 1;
  219. do {
  220. sleep($delay*=2);
  221. $ret = $this->http_request($challenge['uri']);
  222. if ($ret['body']['status'] === 'valid') break;
  223. if (!--$tries) {
  224. $this->remove_challenge($webroots, $challenge);
  225. throw new Exception("Fehler bei der Validierung, auch nach 10 Versuchen");
  226. }
  227. info(".");
  228. } while ($ret['body']['status'] === 'pending');
  229. $this->remove_challenge($webroots, $challenge);
  230. if ($ret['body']['status'] !== 'valid') throw new Exception('Validierung fehlgeschlagen');
  231. info("... Erhalten");
  232. if (isset($ret['body']['validationRecord']) && is_array($ret['body']['validationRecord']) ) {
  233. $record = reset($ret['body']['validationRecord']);
  234. if (isset($record['addressUsed'])) info(" [".$record['addressUsed']."]");
  235. }
  236. info("\n");
  237. }
  238. info("Erstelle Zertifikats-Antrag (CSR)\n");
  239. $csr = $this->generate_csr($domain_key_pem, $domains);
  240. info("Beantrage Zertifikat\n");
  241. $ret = $this->request('new-cert', array('csr' => $this->base64url($this->pem2der($csr))), null, true);
  242. if ($ret['code'] != 201) throw new Exception("Unerwarteter HTTP-Code: ".$ret['code']." (Sollte sein: 201)");
  243. if ($ret['headers']['content-type'] != "application/pkix-cert") throw new Error("Unerwarteter Datentyp des Zertifikats: ".$ret['headers']['content-type']." (Sollte sein: application/pkix-cert)");
  244. $cert = $this->der2pem($ret['body']);
  245. info("Erfrage Zertifikats-Kette\n");
  246. $ret = $this->http_request($ret['headers']['link']['up'], null, true);
  247. if ($ret['code'] != 200) throw new Exception("Unerwarteter HTTP-Code: ".$ret['code']." (Sollte sein: 200)");
  248. if ($ret['headers']['content-type'] != "application/pkix-cert") throw new Error("Unerwarteter Datentyp des Zertifikats: ".$ret['headers']['content-type']." (Sollte sein: application/pkix-cert)");
  249. echo trim(file_get_contents($domain_key_pem))."\n".trim($cert)."\n".trim($this->der2pem($ret['body']))."\n";
  250. foreach ($outputs as $output) {
  251. switch (substr($output, -4)) {
  252. case ".pem": if (!file_put_contents($output, trim(file_get_contents($domain_key_pem))."\n".trim($cert)."\n".trim($this->der2pem($ret['body']))."\n")) info("Die Datei ".$output.".pem konnte nicht geschrieben werden.\n"); break;
  253. case ".crt": if (!file_put_contents($output, trim($cert)."\n")) info("Die Datei ".$output.".crt konnte nicht geschrieben werden.\n"); break;
  254. case ".key": if (!file_put_contents($output, trim(file_get_contents($domain_key_pem))."\n")) info("Die Datei ".$output.".key konnte nicht geschrieben werden.\n"); break;
  255. case ".cha": if (!file_put_contents($output, trim($this->der2pem($ret['body']))."\n")) info("Die Datei ".$output.".chain konnte nicht geschrieben werden.\n"); break;
  256. default:
  257. if (!file_put_contents($output.".pem", trim(file_get_contents($domain_key_pem))."\n".trim($cert)."\n".trim($this->der2pem($ret['body']))."\n")) info("Die Datei ".$output.".pem konnte nicht geschrieben werden.\n");
  258. if (!file_put_contents($output.".crt", trim($cert)."\n")) info("Die Datei ".$output.".crt konnte nicht geschrieben werden.\n");
  259. if (!file_put_contents($output.".key", trim(file_get_contents($domain_key_pem))."\n")) info("Die Datei ".$output.".key konnte nicht geschrieben werden.\n");
  260. if (!file_put_contents($output.".cha", trim($this->der2pem($ret['body']))."\n")) info("Die Datei ".$output.".chain konnte nicht geschrieben werden.\n");
  261. break;
  262. }
  263. }
  264. }
  265. public function revoke($certfile){
  266. if (false === ($data = @file_get_contents($certfile))) throw new Exception("Das Zertifikat unter ".$certfile." kann nicht geöffnet werden.");
  267. if (false === ($x509 = @openssl_x509_read($data))) throw new Exception("Das Zertifikat unter ".$fn_cert." kann nicht gelesen werden:\n".openssl_error_string());
  268. if (false === (@openssl_x509_export($x509, $cert))) throw new Exception("Das Zertifikat unter ".$fn_cert."kann nicht ausgewertet werden:\n".openssl_error_string());
  269. $cert = $this->base64url($this->pem2der($cert));
  270. info("Beantrage Sperrung des Zertifikats\n");
  271. $ret = $this->request('revoke-cert', array('certificate' => $cert));
  272. if ($ret['code'] != 200) throw new Exception("Unerwarteter HTTP-Code: ".$ret['code']." (Sollte sein: 200)");
  273. else echo "Das Zertifikat wurde widerrufen\n";
  274. }
  275. public function simulate_challenges($webroots, $domains) {
  276. if (!is_array($webroots) || !is_array($domains)) throw new Exception("Ungültige Eingabe in simulate_challenges");
  277. info("Simuliere Validierung:\n");
  278. $token = uniqid();
  279. $challenge = array('token' => $token);
  280. foreach ($domains as $domain) {
  281. info(" - Teste: ".$domain);
  282. $this->write_challenge($webroots, $challenge);
  283. info(".");
  284. try {
  285. $ret = $this->http_request('http://'.$domain.'/'.$this->acme_path.$challenge['token'], null, true);
  286. usleep(500000);
  287. info(".");
  288. } catch(Exception $e) {
  289. throw $e;
  290. } finally {
  291. $this->remove_challenge($webroots, $challenge);
  292. info(".");
  293. }
  294. if ($ret['body'] != $token.'.'.$this->thumbprint) throw new Exception("Das Token des Webverzeichnisses konnte nicht per HTTP-Request unter der Domain aufgerufen werden: http://".$domain."/".$this->acme_path.$challenge['token']);
  295. info(" OK\n");
  296. }
  297. }
  298. }
  299. function get_args($offset=1) {
  300. global $argv;
  301. $pairs = array(array(),array());
  302. foreach (array_slice($argv,$offset) as $key => $value) $pairs[$key%2 == 0 ? 0 : 1][] = $value;
  303. $args = array();
  304. foreach ($pairs[0] as $key => $value) $args[] = array(ltrim($value,'-') => isset($pairs[1][$key]) ? $pairs[1][$key] : null);
  305. unset($pairs);
  306. return $args;
  307. }
  308. if (isset($argv[1])){
  309. $simulateonly = false;
  310. $testonly = false;
  311. switch ($argv[1]) {
  312. case 'genrsa': case 'rsa': case 'key': case 'keygen':
  313. $bits = isset($argv[2])?intval($argv[2]):4096;
  314. if ($bits < 2048) throw new Exception('Die Mindestgröße für RSA-Schlüssel beträgt 2048 Bit');
  315. if (false === ($conffile=tempnam("/tmp", "rsaconf_"))) throw new Exception('Kann keine temporäre Datei für die OpenSSL-Config eines neuen RSA-Schlüssels in /tmp anlegen.');
  316. if (false === @file_put_contents($conffile,
  317. 'HOME = .'."\n".
  318. 'RANDFILE=$ENV::HOME/.rnd'."\n".
  319. '[v3_ca]'."\n"
  320. )) throw new Exception('Kann die temporäre Datei '.$conffile.' für die OpenSSL-Config eines neuen RSA-Schlüssels nicht beschreiben.');
  321. $config = array('config'=>$conffile, 'private_key_bits'=>$bits, 'private_key_type'=>OPENSSL_KEYTYPE_RSA);
  322. $key = openssl_pkey_new($config);
  323. openssl_pkey_export($key,$pem);
  324. @unlink($conffile);
  325. echo $pem;
  326. break;
  327. case 'test-register': case 'testregister': case 'registertest': case 'register-test':
  328. $testonly = true;
  329. case 'register':
  330. if (!isset($argv[2])) throw new Exception('Diese Funktion benötigt einen Account-Schlüssel (RSA, PEM) als Argument.');
  331. $LetsEncrypt = new LetsEncrypt($argv[2], $testonly);
  332. $LetsEncrypt->register(isset($argv[3]) ? $argv[3] : null);
  333. break;
  334. case 'simulate-sign': case 'simulatesign': case 'signsimulate': case 'sign-simulate':
  335. case 'simulate-cert': case 'simulatecert': case 'certsimulate': case 'cert-simulate':
  336. case 'simulate-csr': case 'simulatecsr': case 'csrsimulate': case 'csr-simulate':
  337. $simulateonly = true;
  338. case 'test-sign': case 'testsign': case 'signtest': case 'sign-test':
  339. case 'test-cert': case 'testcert': case 'certtest': case 'cert-test':
  340. case 'test-csr': case 'testcsr': case 'csrtest': case 'csr-test':
  341. $testonly = true;
  342. case 'sign': case 'cert': case 'csr':
  343. if (!isset($argv[2])) throw new Exception('Diese Funktion benötigt einen Account-Schlüssel (RSA, PEM) als Argument.');
  344. if (!isset($argv[3])) throw new Exception('Diese Funktion benötigt einen RSA-Schlüssel im Format PEM für die Erzeugung eines Zertifikats als Argument.');
  345. $webroots = $domains = $outputs = array();
  346. foreach (array_slice($argv,4) as $arg) {
  347. if (preg_match("/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])*\.)+[a-zA-Z]+$/", $arg)) $domains[] = $arg;
  348. elseif (is_dir($arg)) {
  349. if (!is_writable($arg)) throw new Exception('Das Webroot-Verzeichnis '.$arg.' ist nicht beschreibbar.');
  350. else $webroots[] = $arg;
  351. }
  352. elseif (substr($arg, -1) != "/" && is_dir(dirname($arg))) {
  353. if (!is_writable(dirname($arg))) throw new Exception('Das Output-Verzeichnis '.dirname($arg).' ist nicht beschreibbar.');
  354. else $outputs[] = $arg;
  355. }
  356. elseif (!substr($arg, 0, 1) == "-") info("Ignoriere das Argument ".$arg."\n");
  357. else throw new Exception("Der Pfad ".$arg." konnte nicht gefunden werden.");
  358. }
  359. if (empty($webroots)) throw new Exception('Mindestens ein Webverzeichnis muss angegeben werden.');
  360. if (empty($domains)) throw new Exception('Mindestens eine Domain muss angegeben werden.');
  361. $LetsEncrypt = new LetsEncrypt($argv[2], $testonly);
  362. if ($simulateonly) $LetsEncrypt->simulate_challenges($webroots, $domains);
  363. else $LetsEncrypt->get_cert($argv[3], $webroots, $domains, $outputs);
  364. break;
  365. case 'test-revoke': case 'testrevoke': case 'revoketest': case 'revoke-test':
  366. $testonly = true;
  367. case 'revoke':
  368. if (!isset($argv[2])) throw new Exception('Es wird der Account- oder Domain-Schlüssel für das zu widerrufende Zertifikat benötigt.');
  369. if (!isset($argv[3])) throw new Exception('Das Zertifikat (im PEM-Format) wird benötigt. Die Datei kann auch eine Chain beinhalten.');
  370. $LetsEncrypt = new LetsEncrypt($argv[2], $testonly);
  371. $LetsEncrypt->revoke($argv[3]);
  372. break;
  373. case 'details': case 'show':
  374. if (!isset($argv[2])) throw new Exception('Ein Zertifikat zum Auszeigen der Details wird benötigt.');
  375. if (!($details = openssl_x509_parse("file://".$argv[2]))) throw new Exception('Das Zertifikat konnte nicht gelesen werden.');
  376. echo "Zertifikatsinhaber: ".(isset($details["subject"]["CN"]) ? $details["subject"]["CN"] : "Unbekannt")."\n";
  377. echo "Ausgestellt von: ".(isset($details["issuer"]["CN"]) ? $details["issuer"]["CN"] : "Unbekannt")."\n";
  378. echo "Gültig außerdem für: ".(isset($details["extensions"]["subjectAltName"]) ? $details["extensions"]["subjectAltName"] : "--- nichts sonst ---")."\n";
  379. echo "Gültig nicht vor: ".(isset($details["validFrom_time_t"]) ? date("d.m.Y \u\m H:i:s", $details["validFrom_time_t"]) : "Unbekannt")."\n";
  380. echo "Gültig nicht nach: ".(isset($details["validTo_time_t"]) ? date("d.m.Y \u\m H:i:s", $details["validTo_time_t"]) : "Unbekannt")."\n";
  381. echo "Gültigkeit: ";
  382. if (!isset($details["validFrom_time_t"])) $FROM = "u";
  383. elseif ($details["validFrom_time_t"] > time()) $FROM = "0";
  384. else $FROM = "1";
  385. if (!isset($details["validTo_time_t"])) $TO = "u";
  386. elseif ($details["validTo_time_t"] < time()) $TO = "0";
  387. else $TO = "1";
  388. if ($FROM.$TO == "uu") echo "undefiniert\n";
  389. else {
  390. if ($details["validTo_time_t"] < $details["validFrom_time_t"]) echo "FEHLER: Das Zertifikat läuft ab, bevor es gültig wird\n";
  391. elseif ($details["validTo_time_t"] == $details["validFrom_time_t"]) echo "FEHLER: Das Zertifikat ist 0 Sekunden lang gültig\n";
  392. else switch ($FROM.$TO) {
  393. case "u0": echo "UNGÜLTIG: Seit ".ceil((time() - $details["validTo_time_t"]) / 86400)." Tagen abgelaufen, Beginn undefiniert !\n"; break;
  394. case "u1": echo "GÜLTIG: Noch ".floor(($details["validTo_time_t"] - time()) / 86400)." Tage gültig, Beginn undefiniert\n"; break;
  395. case "0u": echo "UNGÜLTIG: Erst in ".ceil(($details["validFrom_time_t"] - time()) / 86400)." Tagen gültig, Ende undefiniert\n"; break;
  396. case "01": echo "GÜLTIG: Erst in ".ceil(($details["validTo_time_t"] - time()) / 86400)." Tagen gültig, ab dann ".floor(($details["validTo_time"] - $details["validFrom_time_t"]) / 86400)." Tage gültig\n"; break;
  397. case "1u": echo "GÜLTIG: Seit ".floor((time() - $details["validFrom_time_t"]) / 86400)." Tagen gültig, Ende undefiniert\n"; break;
  398. case "10": echo "UNGÜLTIG: Seit ".ceil((time() - $details["validTo_time_t"]) / 86400)." Tagen abgelaufen, war ".ceil(($details["validTo_time_t"] - $details["validFrom_time_t"]) / 86400)." Tage gültig\n"; break;
  399. case "11": echo "GÜLTIG: Seit ".floor((time() - $details["validFrom_time_t"]) / 86400)." Tagen gültig, läuft ab in ".floor(($details["validTo_time_t"] - time()) / 86400)." Tagen\n"; break;
  400. }
  401. }
  402. break;
  403. case 'keyhash': case 'hash':
  404. if (in_array(strtolower(ltrim($argv[2],"-")), array("md5","sha1","sha256","sha384","sha512"))) { $hashalgo = strtolower(ltrim($argv[2],"-")); array_shift($argv); } else $hashalgo = "sha256";
  405. if (!isset($argv[2])) throw new Exception('Ein Zertifikat zum Auslesen des öffentlichen Schlüssels wird benötigt.');
  406. if (!($pubkey = openssl_pkey_get_public("file://".$argv[2]))) throw new Exception('Das Zertifikat konnte nicht gelesen werden.');
  407. if (false === ($details = openssl_pkey_get_details($pubkey))) throw new Exception("Der Schlüssel unter ".$argv[2]." kann nicht ausgelesen werden:\n".openssl_error_string());
  408. if ($details['type'] != OPENSSL_KEYTYPE_RSA) throw new Exception("Der Schlüssel unter ".$argv[2]." ist vom falschen Typ (".LetsEncrypt::$keytypes[$details['type']]."). Er muss vom Typ RSA sein.");
  409. if ($details['bits'] < 2048) throw new Exception("Der Account-Schlüssel unter ".$argv[2]." ist zu kurz (".$details['bits']."). Er muss mindestens 2048 Bit lang sein.");
  410. echo base64_encode(hash($hashalgo,base64_decode(implode("",array_slice(explode("\n",trim(openssl_pkey_get_details($pubkey)["key"])),1,-1))),true))."\n";
  411. break;
  412. case 'fingerprint': case 'fp':
  413. if (in_array(strtolower(ltrim($argv[2],"-")), array("md5","sha1","sha256","sha384","sha512"))) { $hashalgo = strtolower(ltrim($argv[2],"-")); array_shift($argv); } else $hashalgo = "sha256";
  414. if (!isset($argv[2])) throw new Exception('Ein Zertifikat zum Auslesen des öffentlichen Schlüssels wird benötigt.');
  415. if (!($fingerprint = @openssl_x509_fingerprint("file://".$argv[2],$hashalgo))) throw new Exception('Das Zertifikat konnte nicht gelesen werden.');
  416. echo strtoupper(implode(":",str_split($fingerprint,2)))."\n";
  417. break;
  418. case 'hpkp':
  419. echo 'Public-Key-Pins: max-age=2592000; includeSubDomains; pin-sha256="YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg="; pin-sha256="sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis="'."\n";
  420. break;
  421. case 'bash':
  422. echo '#!/bin/bash
  423. if [ $# -lt 2 ]; then echo "Syntax: $0 [Zertifikats-Datei] [Liste von Domänen]"; exit 1; fi; OUTFILE="$1"; shift;
  424. if [ ! -f "/root/.letsencrypt/account.key" ] || [ ! -f "/root/.letsencrypt/domain.key" ]; then echo "Der account.key oder domain.key unter /root/.letsencrypt/ existiert nicht." exit 1; fi
  425. CERT=$(/home/scripts/LetsEncrypt.php cert /root/.letsencrypt/account.key /root/.letsencrypt/domain.key /var/www/default $@ 2> >(cat | tee /var/log/letsencrypt.log >&2));
  426. if [ "$?" != "0" ]; then echo -e "Bei der automatischen Zertifikats-Ausstellung via Let\'s Encrypt fuer die Domains {${@}} ist ein Fehler aufgetreten:\n\n$(cat /var/log/letsencrypt.log)" | mail -s "Fehler bei der automatischen Zertifikats-Ausstellung via Let\'s Encrypt" root; exit 1; fi
  427. echo "$CERT" > $OUTFILE; for i in $(cat /root/.letsencrypt/servers.txt 2>/dev/null); do echo "Kopiere Zertifikat auf Server $i"; scp $OUTFILE $i:$OUTFILE; done; exit 0;
  428. '; break;
  429. case 'apache': case 'apache2':
  430. echo "\n\n\nAlias /.well-known/acme-challenge /var/www/default/.well-known/acme-challenge\n\n\n\n";
  431. break;
  432. case 'slave': case 'redirect':
  433. echo "\n\n\n## Apache2: Weiterleitung, falls kein lokales Token existiert.\n\n # In der Apache2-vHost-Config oder der \".htaccess\"-Datei:
  434. <IfModule mod_rewrite.c>
  435. <Location />
  436. RewriteEngine On
  437. RewriteBase /
  438. RewriteCond %{REQUEST_URI} !^/favicon.ico$
  439. RewriteCond %{REQUEST_FILENAME} !-f
  440. RewriteCond %{REQUEST_FILENAME} !-d
  441. RewriteRule .* 404.php
  442. </Location>
  443. </IfModule>";
  444. echo "\n\n # Am Anfang der \"404.php\":\n if (substr(\$_SERVER['SCRIPT_URL'], 0, 27) == '/.well-known/acme-challenge') { http_response_code(302); header('Location: https://[HAUPTSERVER]'.\$_SERVER['SCRIPT_URL']); exit(); }\n\n\n\n";
  445. echo "## nginx: Grundsätzliche Weiterleitung an den Hauptserver\n\n # In der nginx-vHost-Config:\n rewrite ^/(.well-known/acme-challenge.*)\$ https://[HAUPTSERVER]/\$1 last;\n\n\n\n";
  446. break;
  447. case 'limits': case 'limit':
  448. echo "\n\n\nLimits von Let's Encrypt:\n\n * 20 neue Zertifikate pro Domain und Woche\n * 1 Common Name + 99 Subject Alternative Names pro Zertifikat\n * 5 identische Zertifikate pro Woche (einschließlich Renewals)\n * 500 Registrationen pro IP-Adresse alle 3 Stunden\n * 300 gleichzeitig laufende Authorisierungsanfragen pro Account\n\nSiehe auch: https://letsencrypt.org/docs/rate-limits/\n\n\n\n";
  449. break;
  450. default:
  451. throw new Exception('Unbekannter Befehl: '.$argv[1]);
  452. break;
  453. }
  454. } else info('
  455. Werkzeug für das automatische Signieren und Widerrufen von Zertifikaten mittels Let\'s Encrypt
  456. Alle Schlüssel und Zertifikate werden im PEM-Format benötigt.
  457. Ausdrücke in eckigen Klammern sind Pflicht-Angaben.
  458. Ausdrücke in geschweiften Klammern sind optional.
  459. Um einen Testdurchlauf gegen das Stage-System durchzuführen, hängen Sie einfach "test-" vor die Befehle "register", "sign" und "revoke".
  460. Benutzung:
  461. '.$argv[0].' [Befehl] [Argumente]
  462. Befehle:
  463. genrsa {Länge} . . . . . . . . . . . . . . . . . . . . . . . . . . Erzeugt einen RSA-Schlüssel der angebenen Länge in Bit, Standard: 4096. Minimum: 2048.
  464. register [Account-Schlüssel] {E-Mail-Adresse} . . . . . . . . . . Registriert einen Account bei Let\'s Encrypt mit dem angegebenen Account-Schlüssel und der optional angegebenen E-Mail-Adresse.
  465. sign [Account-Schlüssel] [Domain-Schlüssel] [Argumente] . . . . . Lässt ein Zertifikat mit dem übergebenen Domain-Schlüssel signieren. Die notwendigen Argumente siehe unten.
  466. simulate-sign [Account-Schlüssel] [Domain-Schlüssel] [Argumente] . Simuliert einen Sign-Request. D.h., es wird geprüft, ob die Tokens korrekt in den Webverzeichnissen per HTTP(S) aufrufbar sind.
  467. revoke [RSA-Schlüssel] [Zertifikatsdatei] . . . . . . . . . . . . Widerruft das als PEM-Datei übergebene Zertifikat mittels des Account- oder Domain-Schlüssels.
  468. details [Zertifikatsdatei] . . . . . . . . . . . . . . . . . . . . Zeigt wichtige Details zum Zertifikat übersichtlich an.
  469. keyhash {Hash-Algorithmus} [Zertifikatsdatei] . . . . . . . . . . Gibt den Hash des öffentlichen Schlüssels eines übergebenen Zertifikates als Base64-Zeichnkette aus.
  470. fingerprint {Hash-Algorithmus} [Zertifikatsdatei] . . . . . . . . Gibt den Fingerabdruck eines übergebenen Zertifikates als durch Doppelpunkte getrennte Hex-Byte-Folge aus.
  471. hpkp . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Zeigt den zu implementierenden HPKP-Header für Let\'s Encrypt an.
  472. bash . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Zeigt ein triviales Bash-Script an, um dieses Tool noch einfacher für Cronjobs zu gestalten.
  473. apache2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Zeigt ein Konfigurations-Beispiel zum Umleiten aller ACME-Verzeichnisse auf ein einziges Verzeichnis an.
  474. slave . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Zeigt ein Konfigurations-Beispiel zum Umleiten aller ACME-Anfragen auf einen anderen Server. Sinnvoll z.B. bei Diensten mit mehreren Servern unter gemeinsamen Namen.
  475. limits . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Zeigt die Limits von Let\'s Encrypt.
  476. Argumente für die Befehle "sign", "simulate-sign" und "test-sign":
  477. * Domains, für die das Zertifikat gültig sein soll. Die erste Domain wird als Common Name eingetragen.
  478. * Pfade für Webverzeichnisse, auf welche mindestens eine der Domains per HTTP zeigt.
  479. * Templatepfade für Outputs. Z.B. /etc/ssl/mycert, aus welchem /etc/ssl/mycert.pem, /etc/ssl/mycert.key, /etc/ssl/mycert.crt und /etc/ssl/mycert.cha wird.
  480. * Ein diskreter Output. Dieser muss mit ".pem", ".key", ".crt" oder ".cha" enden.
  481. ');