Advertisement
outsider

Letsencrypt Tool

Sep 11th, 2017
603
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
PHP 16.72 KB | None | 0 0
  1. #!/usr/bin/env php
  2. <?php
  3.     /*
  4.         MYSQL TABLE:
  5.        
  6.         CREATE TABLE IF NOT EXISTS `sites` (
  7.           `id` bigint(20) NOT NULL AUTO_INCREMENT,
  8.           `active` enum('0','1') COLLATE utf8_unicode_ci DEFAULT NULL,
  9.           `name` varchar(256) COLLATE utf8_unicode_ci NOT NULL,
  10.           `webroot` varchar(256) COLLATE utf8_unicode_ci NOT NULL,
  11.           `domains` longtext COLLATE utf8_unicode_ci NOT NULL,
  12.           `nsupdate` varchar(256) COLLATE utf8_unicode_ci NOT NULL,
  13.           `renewdays` int(11) NOT NULL DEFAULT '30',
  14.           PRIMARY KEY (`id`)
  15.         ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
  16.     */
  17.    
  18.     $le_key = '/usr/local/sbin/account_key.pem';
  19.     $le_email = 'youremailhere';
  20.     $rootca = file_get_contents('https://letsencrypt.org/certs/isrgrootx1.pem');
  21.  
  22.     $sslroot = '/etc/ssl/web/';
  23.     $db = new mysqli('localhost', 'user', 'pass', 'db');
  24.  
  25.     class LE {
  26.  
  27.         private
  28.             $directory='https://acme-v01.api.letsencrypt.org/directory', // live
  29.             //$directory='https://acme-staging.api.letsencrypt.org/directory', // staging
  30.             $resources=null,
  31.             $nonce='',
  32.             $header,  // JOSE Header
  33.             $account_key;
  34.  
  35.         public
  36.             $thumbprint,
  37.             $acme_path='.well-known/acme-challenge/';
  38.  
  39.         public function __construct($account_key_pem)
  40.         {
  41.             // load account key
  42.             if (false===($this->account_key=openssl_pkey_get_private('file://'.$account_key_pem))){
  43.                 throw new Exception(
  44.                     'Could not load account key: '.$account_key_pem."\n".
  45.                     openssl_error_string()
  46.                 );
  47.             }
  48.  
  49.             // get account key details
  50.             if (false===($details=openssl_pkey_get_details($this->account_key))){
  51.                 throw new Exception(
  52.                     'Could not get account key details: '.$account_key_pem."\n".
  53.                     openssl_error_string()
  54.                 );
  55.             }
  56.  
  57.             // JOSE Header - RFC7515
  58.             $this->header=array(
  59.                 'alg'=>'RS256',
  60.                 'jwk'=>array( // JSON Web Key
  61.                     'e'=>$this->base64url($details['rsa']['e']), // public exponent
  62.                     'kty'=>'RSA',
  63.                     'n'=>$this->base64url($details['rsa']['n']) // public modulus
  64.                 )
  65.             );
  66.  
  67.             // JSON Web Key (JWK) Thumbprint - RFC7638
  68.             $this->thumbprint=$this->base64url(
  69.                 hash(
  70.                     'sha256',
  71.                     json_encode($this->header['jwk']),
  72.                     true
  73.                 )
  74.             );
  75.         }
  76.  
  77.         public function __destruct(){
  78.             if ($this->account_key) {
  79.                 openssl_pkey_free($this->account_key);
  80.             }
  81.         }
  82.  
  83.         private function init(){
  84.             $ret=$this->http_request($this->directory); // Read ACME Directory
  85.             $this->resources=$ret['body']; // store resources for later use
  86.             $this->nonce=$ret['headers']['replay-nonce']; // capture first replay-nonce
  87.         }
  88.  
  89.         // Encapsulate $payload into JSON Web Signature (JWS) - RFC7515
  90.         private function jws_encapsulate($payload){
  91.             $protected=$this->header;
  92.             $protected['nonce']=$this->nonce; // replay-nonce
  93.  
  94.             $protected64=$this->base64url(json_encode($protected));
  95.             $payload64=$this->base64url(json_encode($payload));
  96.  
  97.             if (false===openssl_sign(
  98.                 $protected64.'.'.$payload64,
  99.                 $signature,
  100.                 $this->account_key,
  101.                 OPENSSL_ALGO_SHA256
  102.             )){
  103.                 throw new Exception(
  104.                     'Failed to sign payload !'."\n".
  105.                     openssl_error_string()
  106.                 );
  107.             }
  108.  
  109.             return array(
  110.                 'header'=>$this->header,
  111.                 'protected'=>$protected64,
  112.                 'payload'=>$payload64,
  113.                 'signature'=>$this->base64url($signature)
  114.             );
  115.         }
  116.  
  117.         // RFC7515 - Appendix C
  118.         final public function base64url($data){
  119.             return rtrim(strtr(base64_encode($data),'+/','-_'),'=');
  120.         }
  121.  
  122.         final public function request($type,$payload=array(),$url=null,$raw=false,$accept=null){
  123.  
  124.             if ($this->resources===null){
  125.                 $this->init(); // read AMCE directory and get first replay-nonce
  126.             }
  127.  
  128.             $data=json_encode(
  129.                 $this->jws_encapsulate(
  130.                     array_merge(
  131.                         $payload,
  132.                         array('resource'=>$type)
  133.                     )
  134.                 )
  135.             );
  136.  
  137.             $ret=$this->http_request($url===null?$this->resources[$type]:$url,$data,$raw,$accept);
  138.  
  139.             $this->nonce=$ret['headers']['replay-nonce']; // capture replay-nonce
  140.             return $ret;
  141.         }
  142.  
  143.         final public function http_request($url,$data=null,$raw=false,$accept=null){
  144.             $ctx=stream_context_create(
  145.                 array(
  146.                     'http'=>array(
  147.                         'header'=>$data===null?'':'Content-Type: application/json',
  148.                         'method'=>$data===null?'GET':'POST',
  149.                         'user_agent'=>'CertLE (PHP LE Client)',
  150.                         'ignore_errors'=>true,
  151.                         'timeout'=>60,
  152.                         'content'=>$data
  153.                     )
  154.                 )
  155.             );
  156.  
  157.             $body=@file_get_contents($url,false,$ctx);
  158.             if ($body===false){
  159.                 throw new Exception('request error: '.$url);
  160.             }
  161.  
  162.             list(,$code,$status)=explode(' ',reset($http_response_header),3);
  163.  
  164.             $headers=array_reduce( // parse http headers into array
  165.                 array_slice($http_response_header,1),
  166.                 function($carry,$item){
  167.                     list($k,$v)=explode(':',$item,2);
  168.  
  169.                     $k=strtolower(trim($k));
  170.                     $v=trim($v);
  171.  
  172.                     if ($k==='link'){ // parse Link Headers
  173.                         if (preg_match("/<(.*)>\\s*;\\s*rel=\"(.*)\"/",$v,$matches)){
  174.                             $carry['link'][$matches[2]]=$matches[1];
  175.                         }
  176.                     }else{
  177.                         $carry[$k]=$v;
  178.                     }
  179.  
  180.                     return $carry;
  181.                 },
  182.                 array()
  183.             );
  184.  
  185.             if (!$raw) {
  186.                 if ($body==''){
  187.                     $json='';
  188.                 }else{
  189.                     $json=json_decode($body,true);
  190.                 }
  191.             }else{
  192.                 $json=null;
  193.             }
  194.  
  195.             if (is_array($json)){
  196.                 if ($accept!='409' && isset($json['detail'])) {
  197.                     throw new Exception($json['detail']);
  198.                 }
  199.                 if (isset($json['error']) && is_array($json['error']) && isset($json['error']['detail'])) {
  200.                     var_dump($json);
  201.                     throw new Exception($json['error']['detail']);
  202.                 }
  203.             }
  204.  
  205.             if ( ($code!=$accept) && ($code[0]!='2') ){
  206.                 throw new Exception('request failed: '.$code.' ['.$status.']: '.$url);
  207.             }
  208.  
  209.             if (!$raw) {
  210.                 if ($json===null) {
  211.                     throw new Exception('json_decode failed: '.print_r($headers,true).$body);
  212.                 }else{
  213.                     $body=$json;
  214.                 }
  215.             }
  216.  
  217.             $ret=array(
  218.                 'code'=>$code,
  219.                 'status'=>$status,
  220.                 'headers'=>$headers,
  221.                 'body'=>$body
  222.             );
  223.  
  224.             //print_r($ret);
  225.  
  226.             return $ret;
  227.         }
  228.  
  229.         final public function write_challenge($docroot,$challenge){
  230.             echo ' * Write challenge files'.PHP_EOL;
  231.             if (!is_dir($docroot)){
  232.                 throw new Exception('docroot does not exist: '.$docroot);
  233.             }
  234.  
  235.             @mkdir($docroot.$this->acme_path,0755,true);
  236.  
  237.             if (!is_dir($docroot.$this->acme_path)){
  238.                 throw new Exception('failed to create acme challenge directory: '.$docroot.$this->acme_path);
  239.             }
  240.  
  241.             $keyAuthorization=$challenge['token'].'.'.$this->thumbprint;
  242.  
  243.             if (false===@file_put_contents($docroot.$this->acme_path.$challenge['token'],$keyAuthorization)){
  244.                 throw new Exception('failed to create challenge file: '.$docroot.$this->acme_path.$challenge['token']);
  245.             }
  246.  
  247.             file_put_contents($docroot.$this->acme_path.'.htaccess', 'RewriteEngine Off'.PHP_EOL.'Allow from all'.PHP_EOL);
  248.         }
  249.  
  250.         final public function remove_challenge($docroot,$challenge){
  251.             echo ' * Remove challenge files'.PHP_EOL;
  252.             unlink($docroot.$this->acme_path.$challenge['token']);
  253.             unlink($docroot.$this->acme_path.'.htaccess');
  254.             @rmdir($docroot.$this->acme_path);
  255.             @rmdir($docroot.dirname($this->acme_path));
  256.         }
  257.  
  258.         final public function pem2der($pem) {
  259.             return base64_decode(
  260.                 implode(
  261.                     '',
  262.                     array_slice(
  263.                         array_map('trim',explode("\n",trim($pem))),
  264.                         1,
  265.                         -1
  266.                     )
  267.                 )
  268.             );
  269.         }
  270.  
  271.         final public function der2pem($der) {
  272.             return "-----BEGIN CERTIFICATE-----\n".
  273.                 chunk_split(base64_encode($der),64,"\n").
  274.                 "-----END CERTIFICATE-----\n";
  275.         }
  276.  
  277.         final public function generate_csr($domain_key_pem,$domains){
  278.  
  279.             if (false===($domain_key=openssl_pkey_get_private('file://'.$domain_key_pem))){
  280.                 throw new Exception(
  281.                     'Could not load domain key: '.$domain_key_pem."\n".
  282.                     openssl_error_string()
  283.                 );
  284.             }
  285.  
  286.             if (false===($fn=tempnam("/tmp", "CNF_"))){
  287.                 throw new Exception('Failed to create temp file !');
  288.             }
  289.  
  290.             if (false===@file_put_contents($fn,
  291.                 'HOME = .'."\n".
  292.                 'RANDFILE=$ENV::HOME/.rnd'."\n".
  293.                 '[req]'."\n".
  294.                 'distinguished_name=req_distinguished_name'."\n".
  295.                 '[req_distinguished_name]'."\n".
  296.                 '[v3_req]'."\n".
  297.                 '[v3_ca]'."\n".
  298.                 '[SAN]'."\n".
  299.                 'subjectAltName='.
  300.                 implode(',',array_map(function($domain){
  301.                     return 'DNS:'.$domain;
  302.                 },$domains)).
  303.                 "\n"
  304.             )){
  305.                 throw new Exception('Failed to write tmp file: '.$fn);
  306.             }
  307.  
  308.             $dn=array('commonName'=>reset($domains));
  309.  
  310.             $csr=openssl_csr_new($dn,$domain_key,array(
  311.                 'config'=>$fn,
  312.                 'req_extensions'=>'SAN',
  313.                 'digest_alg'=>'sha512'
  314.             ));
  315.  
  316.             unlink($fn);
  317.             openssl_pkey_free($domain_key);
  318.  
  319.             if (!$csr) {
  320.                 throw new Exception(
  321.                     'Could not generate CSR !'."\n".
  322.                     openssl_error_string()
  323.                 );
  324.             }
  325.  
  326.             if (false===openssl_csr_export($csr,$out)){
  327.                 throw new Exception(
  328.                     'Could not export CSR !'."\n".
  329.                     openssl_error_string()
  330.                 );
  331.             }
  332.  
  333.             return $out;
  334.         }
  335.     }
  336.  
  337.     function nsupdate($site, $domain, $challenge) {
  338.         // prepare command
  339.         $file = tempnam(sys_get_temp_dir(), uniqid());
  340.         $zone = implode('.',array_slice(explode('.', $domain),-2));
  341.         $data = 'server localhost'.PHP_EOL.'zone '.$zone.PHP_EOL.'update delete _acme-challenge.'.$domain.'. TXT'.PHP_EOL.'update add _acme-challenge.'.$domain.'. 60 IN TXT "'.$challenge.'"'.PHP_EOL.'send'.PHP_EOL;
  342.         file_put_contents($file, $data);
  343.         passthru('/usr/bin/nsupdate -k '.$site['nsupdate'].' '.$file);
  344.         unlink($file);
  345.         return;
  346.     }
  347.  
  348.     if (!file_exists($le_key))
  349.     {
  350.         // Create the private key for the account
  351.         $file = tempnam(sys_get_temp_dir(), uniqid());
  352.         $data = 'HOME = .'.PHP_EOL.'RANDFILE=\$ENV::HOME/.rnd'.PHP_EOL.'[v3_ca]'.PHP_EOL;
  353.         file_put_contents($file, $data);
  354.         $config = array(
  355.             'config'=> $file,
  356.             'private_key_bits' => 4096,
  357.             'private_key_type' => OPENSSL_KEYTYPE_RSA
  358.         );
  359.         openssl_pkey_export(openssl_pkey_new($config), $le_key);
  360.         unlink($file);
  361.  
  362.         // Register with LetsEncrypt
  363.         $le = new LE($le_key);
  364.         $data = array('contact'=>array('mailto:'.$le_email));
  365.         $ret = $le->request('new-reg',$data,null,false,409);
  366.         switch($ret['code'])
  367.         {
  368.             case 409: // account already registered
  369.                 $reg = $ret['headers']['location'];
  370.                 $ret = $le->request('reg',$data,$reg);
  371.                 break;
  372.             case 201: // account created
  373.                 $reg = $ret['headers']['location'];
  374.                 break;
  375.             default:
  376.                 die('register error: '.$ret['body']['detail']);
  377.                 break;
  378.         }
  379.         if ( !isset($ret['body']['agreement']) ){
  380.             $data['agreement'] = $ret['headers']['link']['terms-of-service'];
  381.             $ret = $le->request('reg', $data, $reg);
  382.         }
  383.     }
  384.     else
  385.         $le = new LE($le_key);
  386.  
  387.     $query = $db->query("SELECT * FROM `sites` WHERE `active`='1'");
  388.     while ($site = $query->fetch_array())
  389.     {
  390.         echo 'Checking '.$site['name'].': '.PHP_EOL;
  391.         if (!file_exists($sslroot.$site['name']))
  392.         {
  393.             mkdir($sslroot.$site['name']);
  394.             echo ' - Created folder for ssl'.PHP_EOL;
  395.         }
  396.  
  397.         $csrfile = $sslroot.$site['name'].'/server.csr';
  398.         $crtfile = $sslroot.$site['name'].'/server.crt';
  399.         $pemfile = $sslroot.$site['name'].'/server.pem';
  400.  
  401.         $domains = preg_split("/[\s,;\n]+/", $site['domains']);
  402.         // echo ' - Domains in DB: '.implode(' ',$domains).PHP_EOL;
  403.  
  404.         if (file_exists($crtfile))
  405.         {
  406.             echo ' - Reading existing cert (CRT)'.PHP_EOL;
  407.             $data = openssl_x509_parse(file_get_contents($crtfile));
  408.             preg_match_all('/DNS:([\w\.]+)/', $data['extensions']['subjectAltName'], $cert_sni);
  409.             // echo ' - Domains in CRT: '.implode(' ',$cert_sni[1]).PHP_EOL;
  410.             $diff1 = array_diff($domains, $cert_sni[1]);
  411.             $diff2 = array_diff($cert_sni[1], $domains);
  412.             $renew = $data['validTo_time_t'] - ($site['renewdays']*86400);
  413.  
  414.             if (!empty($diff1) || !empty($diff2))
  415.             {
  416.                 echo ' - Regenerating because domain list differs:'.PHP_EOL;
  417.                 echo ' * Added  : '.implode(' ',$diff1).PHP_EOL;
  418.                 echo ' * Removed: '.implode(' ',$diff2).PHP_EOL;
  419.                 if (file_exists($csrfile))
  420.                     unlink($csrfile);
  421.             }
  422.             else if (time() < $renew)
  423.             {
  424.                 echo ' * Valid  : '.date('d-m-Y H:i:s', $data['validTo_time_t']).PHP_EOL;
  425.                 echo ' * Renewal: '.date('d-m-Y', $renew).PHP_EOL;
  426.                 continue;
  427.             }
  428.         }
  429.  
  430.         if (!file_exists($pemfile))
  431.         {
  432.             echo ' - Creating private key (PEM)'.PHP_EOL;
  433.             openssl_pkey_export(openssl_pkey_new(), $pem);
  434.             file_put_contents($pemfile, $pem);
  435.         }
  436.  
  437.         foreach($domains AS $d)
  438.         {
  439.             $domain = trim($d);
  440.             echo ' - Validating '.$domain.PHP_EOL;
  441.             $ret = $le->request('new-authz',array('identifier'=>array('type'=>'dns', 'value'=>$domain )));
  442.             if ($ret['code'] != 201)
  443.             {
  444.                 echo ' E new-authz failed'.PHP_EOL;
  445.                 var_dump($ret);
  446.                 continue(2);
  447.             }
  448.  
  449.             $validated = false;
  450.             $challenge = false;
  451.             foreach($ret['body']['challenges'] AS $ch)
  452.             {
  453.                 if ($ch['status'] == 'valid')
  454.                 {
  455.                     $validated = true;
  456.                     break;
  457.                 }
  458.                 if ($ch['type']===(!empty($site['nsupdate']) ? 'dns-01' : 'http-01'))
  459.                     $challenge = $ch;
  460.             }
  461.  
  462.             if (!$validated)
  463.             {
  464.                 if (!$challenge)
  465.                 {
  466.                     echo ' E No challenge found'.PHP_EOL;
  467.                     continue(2);
  468.                 }
  469.  
  470.                 echo ' - Preparing validation'.PHP_EOL;
  471.                 if (!empty($site['nsupdate']))
  472.                 {
  473.                     $auth = $le->base64url(hash('sha256', $challenge['token'].'.'.$le->thumbprint, true));
  474.                     $data = dns_get_record('_acme-challenge.'.$domain, DNS_TXT);
  475.                     if ($data[0]['txt'] != $auth)
  476.                     {
  477.                         echo ' * Setting up DNS TXT records'.PHP_EOL;
  478.                         nsupdate($site, $domain, $auth);
  479.  
  480.                         // Confirm updates on all
  481.                         $zone = implode('.',array_slice(explode('.', $domain),-2));
  482.                         $data = dns_get_record($zone, DNS_NS);
  483.                         $ok = array();
  484.                         while (true)
  485.                         {
  486.                             foreach($data AS $server)
  487.                             {
  488.                                 if (in_array($server['target'], $ok))
  489.                                     continue;
  490.                                 $test = exec('host -t TXT _acme-challenge.'.$domain.' '.$server['target']);
  491.                                 echo $server['target'].': '.$test.PHP_EOL;
  492.                                 if (strstr($test, $auth))
  493.                                     $ok[] = $server['target'];
  494.                             }
  495.                             if (count($ok) == count($data))
  496.                                 break;
  497.                             else
  498.                                 sleep(5);
  499.                         }
  500.                     }
  501.                 }
  502.                 else
  503.                     $le->write_challenge($site['webroot'], $challenge);
  504.  
  505.                 $ret = $le->request('challenge', array('keyAuthorization'=>$challenge['token'].'.'.$le->thumbprint), $challenge['uri']);
  506.                 if ($ret['code'] != 202)
  507.                 {
  508.                     echo ' E Unexpected http status code: '.$ret['code'].PHP_EOL;
  509.                     if (empty($site['nsupdate']))
  510.                         $le->remove_challenge($site['webroot'],$challenge);
  511.                     continue(2);
  512.                 }
  513.                 sleep(3); // waiting for ACME-Server to verify challenge
  514.  
  515.                 // poll
  516.                 $tries=10;
  517.                 $delay=2;
  518.                 do
  519.                 {
  520.                     $ret = $le->http_request($challenge['uri']);
  521.                     if ($ret['body']['status']==='valid')
  522.                         break;
  523.                     echo '.';
  524.                     sleep($delay); // still waiting..
  525.                     $delay=min($delay*2,32);
  526.                     if (--$tries==0)
  527.                     {
  528.                         echo ' E Failed to verify challenge after 10 tries !'.PHP_EOL;
  529.                         if (empty($site['nsupdate']))
  530.                             $le->remove_challenge($site['webroot'], $challenge);
  531.                         continue(3);
  532.                     }
  533.                 } while($ret['body']['status']==='pending');
  534.  
  535.                 if (empty($site['nsupdate']))
  536.                     $le->remove_challenge($site['webroot'],$challenge);
  537.  
  538.                 if ($ret['body']['status']!=='valid')
  539.                 {
  540.                     echo ' E Challenge failed'.PHP_EOL;
  541.                     continue(2);
  542.                 }
  543.             }
  544.             else
  545.                 echo ' * Already validated'.PHP_EOL;
  546.         }
  547.  
  548.         $csrfile = $sslroot.$site['name'].'/server.csr';
  549.         if (!file_exists($sslroot.$site['name'].'/server.csr'))
  550.         {
  551.             echo ' - Generating signing request (CSR)'.PHP_EOL;
  552.             $csr = $le->generate_csr($pemfile, $domains);
  553.             file_put_contents($csrfile, $csr);
  554.         }
  555.         else
  556.             $csr = file_get_contents($csrfile);
  557.  
  558.         echo ' - Requesting certificate (CRT)'.PHP_EOL;
  559.         $ret = $le->request('new-cert',array('csr'=>$le->base64url($le->pem2der($csr))),null,true);
  560.         if ($ret['code']!=201)
  561.         { // HTTP: Created
  562.             echo ' E Unexpected http status code: '.$ret['code'].PHP_EOL;
  563.             continue;
  564.         }
  565.         if ($ret['headers']['content-type']!='application/pkix-cert')
  566.         {
  567.             echo ' E Unexpected content-type: '.$ret['headers']['content-type'].PHP_EOL;
  568.             continue;
  569.         }
  570.         $crt = $le->der2pem($ret['body']);
  571.  
  572.         echo ' - Requesting Intermediate CA Certificate (SUB)'.PHP_EOL;
  573.         $ret = $le->http_request($ret['headers']['link']['up'],null,true);
  574.         if ($ret['code']!=200)
  575.         {
  576.             echo ' E Unexpected http status code: '.$ret['code'].PHP_EOL;
  577.             continue;
  578.         }
  579.  
  580.         if ($ret['headers']['content-type']!='application/pkix-cert')
  581.         {
  582.             echo ' E Unexpected content-type: '.$ret['headers']['content-type'].PHP_EOL;
  583.             continue;
  584.         }
  585.  
  586.  
  587.         if ($rootca)
  588.             file_put_contents($sslroot.$site['name'].'/server.ca', $rootca);
  589.         file_put_contents($sslroot.$site['name'].'/server.crt', $crt);
  590.         file_put_contents($sslroot.$site['name'].'/server.sub', $le->der2pem($ret['body']));
  591.         file_put_contents($sslroot.$site['name'].'/server.all', $crt.PHP_EOL.$le->der2pem($ret['body']).($rootca ? PHP_EOL.$rootca : ''));
  592.  
  593.         echo ' - Succeeded'.PHP_EOL;
  594.     }
  595.     passthru('/usr/sbin/apache2ctl restart');
  596. ?>
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement