├── requirements.txt ├── .gitignore ├── .github └── FUNDING.yml ├── .gitattributes ├── test ├── inst.sh ├── watcher.sh ├── gencsr.conf ├── dom.key ├── account.key ├── gencsr ├── check.sh ├── travis_check.sh └── lampi ├── renewcron.sh ├── .travis.yml ├── LICENSE ├── config.json ├── acme_tiny.py ├── letsacme.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | argparse 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ngrok 2 | *.zip 3 | dom.list 4 | dom.csr 5 | dom.crt 6 | /test/config.json 7 | chain.crt 8 | ngrok.yml 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: neurobin 2 | liberapay: neurobin 3 | ko_fi: neurobin 4 | issuehunt: neurobin 5 | open_collective: neurobin 6 | otechie: neurobin 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.md linguist-documentation 2 | *.sh linguist-vendored 3 | /test/* linguist-vendored 4 | *.txt linguist-documentation 5 | *.csr linguist-vendored 6 | *.list linguist-vendored 7 | *.json linguist-vendored 8 | *.crt linguist-vendored 9 | *.log linguist-vendored 10 | *.key linguist-vendored 11 | -------------------------------------------------------------------------------- /test/inst.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$BASH_SOURCE")" 3 | 4 | #sudo add-apt-repository -y ppa:ondrej/apache2 5 | #sudo add-apt-repository -y ppa:ondrej/php5 6 | sudo apt-get update 7 | sudo apt-get install -qq apache2 8 | sudo apt-get install -qq jq 9 | 10 | #download ngrok 11 | wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip -O ngrok-stable-linux-amd64.zip 12 | unzip ngrok-stable-linux-amd64.zip 13 | -------------------------------------------------------------------------------- /test/watcher.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pn=travis_check.sh 3 | ids=$(pidof "$pn" -x) 4 | t1=$(date +%s) 5 | threshold=$(expr 60 \* 1) 6 | 7 | while true;do 8 | t2=$(date +%s) 9 | time=$(expr $t2 - $t1) 10 | if [ $time -ge $threshold ]; then 11 | t1=$(date +%s) 12 | ids=$(pidof "$pn" -x) 13 | if [ "x$ids" != 'x' ]; then 14 | kill -s SIGINT $ids 15 | ./test/$pn 16 | fi 17 | break 18 | fi 19 | done 20 | -------------------------------------------------------------------------------- /test/gencsr.conf: -------------------------------------------------------------------------------- 1 | ############# gencsr config file ##################### 2 | # Do not use quotation marks (', "") 3 | # To prevent any entry being included, comment them 4 | # by adding a # at the beginning 5 | ###################################################### 6 | CountryCode=US # Put two character country code 7 | State=My state # Put state name 8 | Locality=My city # Put city name 9 | Oraganization=My organization # Put organization name 10 | OraganizationUnit=Technology or whatever # Put organization unit name 11 | Email=mymail@somedomain.com # Put email address 12 | -------------------------------------------------------------------------------- /renewcron.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | while true;do 3 | if python /path/to/letsacme.py --account-key /path/to/account.key \ 4 | --csr /path/to/domain.csr \ 5 | --config-json /path/to/config1.json \ 6 | --cert-file /path/to/signed.crt \ 7 | --chain-file /path/to/chain.crt \ 8 | > /path/to/fullchain.crt \ 9 | 2>> /path/to/letsacme.log 10 | then 11 | # echo "Successfully renewed certificate" 12 | service apache2 restart 13 | break 14 | else 15 | sleep `tr -cd 0-9 >/dev/stderr 40 | } 41 | 42 | get_arg(){ 43 | if [ -z "$1" ]; then 44 | err 'Arg missing!' 45 | exit 1 46 | else 47 | echo "$1" 48 | fi 49 | } 50 | 51 | while [ $# -gt 0 ]; do 52 | case "$1" in 53 | -df|--dom-file) 54 | domfile="$(get_arg "$2")" 55 | shift 56 | ;; 57 | -k|--key) 58 | dkeyfile="$(get_arg "$2")" 59 | shift 60 | ;; 61 | 62 | -csr|--csr) 63 | csrfile="$(get_arg "$2")" 64 | shift 65 | ;; 66 | -c|--conf) 67 | conf="$(get_arg "$2")" 68 | shift 69 | ;; 70 | -ks|--key-size) 71 | key_size="$(get_arg "$2")" 72 | if [[ ! "$key_size" =~ ^[0-9]+$ ]]; then 73 | err "Invalid key size: $key_size" 74 | exit 1 75 | fi 76 | shift 77 | ;; 78 | -n|--new) 79 | new=true 80 | ;; 81 | -h|--help) 82 | echo "$help" 83 | exit 0 84 | ;; 85 | -v|--version) 86 | echo "$ver_info" 87 | exit 0 88 | ;; 89 | *) 90 | err "Invalid arg: $1" 91 | exit 1 92 | ;; 93 | esac 94 | shift 95 | done 96 | 97 | if [ ! -f "$dkeyfile" ] || $new; then 98 | echo 'Creating new key file: '"$dkeyfile" 99 | if openssl genrsa $key_size > "$dkeyfile"; then 100 | echo 'Successfully created key file: '"$dkeyfile" 101 | else 102 | err "Failed to created key file: $dkeyfile" 103 | exit 1 104 | fi 105 | else 106 | echo "Using existing key file: $dkeyfile" 107 | fi 108 | 109 | CN="$(head -1 "$domfile")" 110 | subjectAltName="$(sed -e '/^[[:blank:]]*$/d' \ 111 | -e 's/^[[:blank:]]*//' \ 112 | -e 's/[[:blank:]]*$//' \ 113 | -e 's/^/DNS:/' "$domfile" | 114 | tr '\n' ',')" 115 | subjectAltName="${subjectAltName/%,/}" 116 | 117 | country_code="$(sed -n -e 's/^[[:blank:]]*CountryCode[[:blank:]]*=[[:blank:]]*\([^#]*\).*$/\1/pi' "$conf" |sed -e 's/[[:blank:]]*$//')" 118 | state="$(sed -n -e 's/^[[:blank:]]*State[[:blank:]]*=[[:blank:]]*\([^#]*\).*$/\1/pi' "$conf" |sed -e 's/[[:blank:]]*$//')" 119 | locality="$(sed -n -e 's/^[[:blank:]]*Locality[[:blank:]]*=[[:blank:]]*\([^#]*\).*$/\1/pi' "$conf" |sed -e 's/[[:blank:]]*$//')" 120 | org="$(sed -n -e 's/^[[:blank:]]*Oraganization[[:blank:]]*=[[:blank:]]*\([^#]*\).*$/\1/pi' "$conf" |sed -e 's/[[:blank:]]*$//')" 121 | org_unit="$(sed -n -e 's/^[[:blank:]]*OraganizationUnit[[:blank:]]*=[[:blank:]]*\([^#]*\).*$/\1/pi' "$conf" |sed -e 's/[[:blank:]]*$//')" 122 | email="$(sed -n -e 's/^[[:blank:]]*Email[[:blank:]]*=[[:blank:]]*\([^#]*\).*$/\1/pi' "$conf" |sed -e 's/[[:blank:]]*$//')" 123 | 124 | subj="/CN=$CN" 125 | 126 | if [ -n "$country_code" ]; then 127 | subj="$subj/C=$country_code" 128 | fi 129 | 130 | if [ -n "$state" ]; then 131 | subj="$subj/ST=$state" 132 | fi 133 | 134 | if [ -n "$locality" ]; then 135 | subj="$subj/L=$locality" 136 | fi 137 | 138 | if [ -n "$org" ]; then 139 | subj="$subj/O=$org" 140 | fi 141 | 142 | if [ -n "$org_unit" ]; then 143 | subj="$subj/OU=$org_unit" 144 | fi 145 | 146 | if [ -n "$email" ]; then 147 | subj="$subj/emailAddress=$email" 148 | fi 149 | 150 | if openssl req -new -sha256 -key "$dkeyfile" -subj "$subj" -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(printf "[SAN]\nsubjectAltName=$subjectAltName")) -out "$csrfile"; then 151 | echo "Successfully created CSR file: $csrfile" 152 | else 153 | err 'Failed to create CSR file!' 154 | fi 155 | -------------------------------------------------------------------------------- /test/check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$BASH_SOURCE")" 3 | 4 | 5 | ###Some convenience functions 6 | 7 | prnt(){ 8 | printf "$*\n" >>/dev/stdout 9 | } 10 | err() { 11 | printf "$*\n" >>/dev/stderr 12 | } 13 | 14 | Exit(){ 15 | prnt '\nCleaning' 16 | if [ "$2" != '-p' ]; then 17 | kill $pid >/dev/null 2>&1 && prnt "\tKilled pid: $pid" 18 | fi 19 | sudo ./lampi -rmd "$site1" >/dev/null 2>&1 && prnt "\tRemoved site: $site1" 20 | sudo ./lampi -rmd "$site2" >/dev/null 2>&1 && prnt "\tRemoved site: $site2" 21 | exit $1 22 | } 23 | 24 | trap 'Exit 1 2>/dev/null' SIGINT 25 | #trap Exit INT TERM EXIT 26 | 27 | 28 | 29 | doc_root1="$(mktemp -d)" 30 | doc_root2="$(mktemp -d)" 31 | site1=letsacme-host1.local 32 | site2=letsacme-host2.local 33 | acme_dir=$doc_root1/acme-challenge 34 | 35 | 36 | prnt "\nCreating test sites..." 37 | sudo ./lampi -n "$site1" -dr "$doc_root1" >/dev/null && prnt "\tCreated site: $site1" 38 | sudo ./lampi -n "$site2" -dr "$doc_root2" >/dev/null && prnt "\tCreated site: $site2" 39 | 40 | #Test the sites 41 | prnt '\tTesting sites..' 42 | mkdir -p "$doc_root1"/.well-known/acme-challenge 43 | mkdir -p "$doc_root2"/.well-known/acme-challenge 44 | 45 | site_test(){ 46 | # $1: burl, $2:docroot 47 | somefile="$(tr -cd 0-9 "$2/.well-known/acme-challenge/$somefile" 49 | if curl "$1/.well-known/acme-challenge/$somefile" >/dev/null 2>&1;then 50 | prnt "\t\tPassed: $1" 51 | else 52 | prnt "\t\tFailed: $1" 53 | Exit 1 54 | fi 55 | } 56 | 57 | site_test "http://$site1" "$doc_root1" 58 | site_test "http://$site2" "$doc_root2" 59 | 60 | 61 | prnt "\nngrok ..." 62 | 63 | nweb=127.0.0.1:4040 64 | nconf="tunnels: 65 | $site1: 66 | proto: http 67 | host_header: rewrite 68 | addr: $site1:80 69 | web_addr: $nweb 70 | $site2: 71 | proto: http 72 | host_header: rewrite 73 | addr: $site2:80 74 | web_addr: $nweb" 75 | nconf_f=ngrok.yml 76 | echo "$nconf" > "$nconf_f" && prnt '\tCreated ngrok config file' 77 | 78 | nohup ./ngrok start -config "$nconf_f" $site1 $site2 >/dev/null 2>&1 & 79 | pid=$! 80 | prnt "\tRunning ngrok in the background (pid: $pid)" 81 | 82 | t1=$(date +%s) 83 | max_t=7 #limit max try in seconds 84 | while true; do 85 | tunnel_info_json="$(curl -s http://$nweb/api/tunnels)" 86 | #echo $tunnel_info_json 87 | public_url1="$(echo "$tunnel_info_json" | jq -r '.tunnels[0].public_url' | grep 'http://')" 88 | dom1="$(echo "$public_url1" |sed -n -e 's#.*//\([^/]*\)/*.*#\1#p')" 89 | public_url2="$(echo "$tunnel_info_json" | jq -r '.tunnels[2].public_url' | grep 'http://')" 90 | dom2="$(echo "$public_url2" |sed -n -e 's#.*//\([^/]*\)/*.*#\1#p')" 91 | if [ -n "$dom1" ] && [ -n "$dom2" ]; then 92 | break 93 | fi 94 | t2=$(date +%s) 95 | time="$(expr $t2 - $t1)" 96 | if [ $time -ge $max_t ]; then 97 | t1=$(date +%s) 98 | prnt "\tngork froze. Restarting ..." 99 | kill $pid >/dev/null 2>&1 && prnt "\tKilled pid: $pid" 100 | nohup ./ngrok start -config "$nconf_f" $site1 $site2 >/dev/null 2>&1 & 101 | pid=$! 102 | prnt "\tngrok restarted (pid: $pid)" 103 | fi 104 | done 105 | 106 | if [ "$dom1" = "$dom2" ]; then 107 | err '\tE: Both domain can not be same. abort' 108 | Exit 1 2>/dev/null 109 | fi 110 | 111 | prnt "\tSite1: $public_url1" 112 | prnt "\tSite2: $public_url2" 113 | prnt "\tTesting sites ..." 114 | 115 | #tphp='' 116 | 117 | #ls -la "$doc_root1" "$doc_root2" 118 | 119 | site_test "$public_url1" "$doc_root1" 120 | site_test "$public_url2" "$doc_root2" 121 | 122 | #ls -la "$doc_root1" "$doc_root2" 123 | 124 | #sleep 30 125 | 126 | prnt '\nPreparing ...' 127 | if [ -f account.key ]; then 128 | prnt '\tUsing existing account.key' 129 | else 130 | openssl genrsa 4096 > account.key && prnt '\tCreated account.key' 131 | fi 132 | printf "$dom1\n$dom2\n" > dom.list && prnt '\tCreated dom.list file' 133 | ./gencsr >/dev/null && prnt '\tCreated CSR' 134 | 135 | prnt ' 136 | ********************************************************* 137 | *** Test 1: With --config-json and using document root 138 | ********************************************************* 139 | ' 140 | 141 | conf_json='{ 142 | "'$dom1'": 143 | { 144 | "DocumentRoot": "'$doc_root1'" 145 | }, 146 | "'$dom2'": 147 | { 148 | "DocumentRoot": "'$doc_root2'" 149 | }, 150 | "DocumentRoot": "'$doc_root2'", 151 | "AccountKey":"account.key", 152 | "CSR": "dom.csr", 153 | "CertFile":"dom.crt", 154 | "ChainFile":"chain.crt", 155 | "Test":"True" 156 | }' && prnt '\tConfiguration JSON prepared' 157 | #echo "$conf_json" > config.json && prnt '\tCreated config.json file' 158 | 159 | prnt '\tRunning letsacme: python ../letsacme.py --config-json $conf_json' 160 | 161 | t="$(mktemp)" 162 | { python ../letsacme.py --config-json "$conf_json" 2>&1; echo $? >"$t"; } | sed -e 's/.*/\t\t&/' 163 | es=$(cat $t) 164 | rm "$t" 165 | if [ $es -eq 0 ]; then 166 | prnt '\n\t*** success on test 1 ***' 167 | else 168 | err '\tE: Failed to get the certs' 169 | #sleep 30 170 | Exit 1 2>/dev/null 171 | fi 172 | 173 | prnt " 174 | ******************************************************* 175 | *** Test 2: With --acme-dir and without --config-json 176 | ******************************************************* 177 | " 178 | 179 | red_code=" 180 | RewriteEngine On 181 | RewriteBase / 182 | RewriteRule ^.well-known/acme-challenge/(.*)$ http://$dom1/acme-challenge/\$1 [L,R=302] 183 | " 184 | echo "$red_code" > "$doc_root1"/.htaccess && prnt "\tRedirect for $site1 is set" 185 | echo "$red_code" > "$doc_root2"/.htaccess && prnt "\tRedirect for $site2 is set" 186 | 187 | prnt "\tRunning letsacme: 188 | \tpython ../letsacme.py --test\\\\\n\t --account-key account.key\\\\\n\t --csr dom.csr\\\\\n\t --acme-dir $acme_dir\\\\\n\t --chain-file chain.crt\\\\\n\t --cert-file dom.crt" 189 | t="$(mktemp)" 190 | { python ../letsacme.py --test --account-key account.key --csr dom.csr --acme-dir "$acme_dir" --chain-file chain.crt --cert-file dom.crt 2>&1; echo $? >"$t"; } | sed -e 's/.*/\t\t&/' 191 | es=$(cat "$t") 192 | rm $t 193 | if [ $es -eq 0 ]; then 194 | prnt '\n\t*** success on test 2 ***' 195 | else 196 | err '\tE: Failed to get the certs' 197 | Exit 1 2>/dev/null 198 | fi 199 | 200 | ############## Final cleaning ############### 201 | Exit 0 2>/dev/null 202 | ############################################# 203 | -------------------------------------------------------------------------------- /test/travis_check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$BASH_SOURCE")" 3 | 4 | prnt(){ 5 | printf "$*\n" >>/dev/stdout 6 | } 7 | err() { 8 | printf "$*\n" >>/dev/stderr 9 | } 10 | 11 | Exit(){ 12 | prnt '\nCleaning' 13 | if [ "$2" != '-p' ]; then 14 | kill $pid >/dev/null 2>&1 && prnt "\tKilled pid: $pid" 15 | fi 16 | # mv -f "$doc_root1"/.htaccess.bak "$doc_root1"/.htaccess >/dev/null 2>&1 17 | # mv -f "$doc_root2"/.htaccess.bak "$doc_root2"/.htaccess >/dev/null 2>&1 18 | # sudo ./lampi -rm "$site1" >/dev/null 2>&1 && prnt "\tRemoved site: $site1" 19 | # sudo ./lampi -rm "$site2" >/dev/null 2>&1 && prnt "\tRemoved site: $site2" 20 | exit $1 21 | } 22 | 23 | trap 'Exit 1 2>/dev/null' SIGINT 24 | 25 | doc_root1=/var/www/html 26 | doc_root2=/var/www/html 27 | site1=letsacme-host1.local 28 | site2=letsacme-host2.local 29 | acme_dir=/var/www/acme-challenge 30 | 31 | 32 | prnt "\nCreating test sites..." 33 | #sudo ./lampi -n "$site1" -dr "$doc_root1" -nsr >/dev/null && prnt "\tCreated site: $site1" 34 | #sudo ./lampi -n "$site2" -dr "$doc_root2" -nsr >/dev/null && prnt "\tCreated site: $site2" 35 | #sudo sed -i .bak -e 's/ServerName.*//' "/etc/apache2/sites-available/$site1.conf" 36 | #sudo sed -i .bak -e 's/ServerName.*//' "/etc/apache2/sites-available/$site2.conf" 37 | #a2ensite $site1 38 | #a2ensite $site2 39 | #sudo service apache2 restart >/dev/null && prnt "\tReloaded apache2" 40 | 41 | 42 | #create backup .htaccess file 43 | #mv -f "$doc_root1"/.htaccess "$doc_root1"/.htaccess.bak >/dev/null 2>&1 44 | #mv -f "$doc_root2"/.htaccess "$doc_root2"/.htaccess.bak >/dev/null 2>&1 45 | 46 | 47 | 48 | sudo mkdir -p "$doc_root1" 49 | sudo mkdir -p "$doc_root2" 50 | sudo mkdir -p "$acme_dir" 51 | sudo chown -R $USER:$USER "$doc_root2" "$doc_root1" "$acme_dir" 52 | 53 | #get_conf(){ 54 | # #$1: dr 55 | # prnt " 56 | ## ServerName $2 57 | ## ServerAlias $2 58 | # ServerAdmin webmaster@$2 59 | # DocumentRoot $1 60 | # 61 | # Options Indexes FollowSymLinks MultiViews 62 | # AllowOverride All 63 | # Require all granted 64 | # 65 | # " 66 | #} 67 | 68 | #get_conf "$doc_root1" "$site1" | sudo tee /etc/apache2/sites-available/"$site1".conf > /dev/null 69 | ##cat /etc/apache2/sites-available/"$site1".conf 70 | 71 | #get_conf "$doc_root2" "$site2" | sudo tee /etc/apache2/sites-available/"$site2".conf > /dev/null 72 | ##cat /etc/apache2/sites-available/"$site2".conf 73 | 74 | manage_host(){ 75 | #$1:dom 76 | if ! grep -s -e "^127.0.0.1[[:blank:]]*$1[[:blank:]]*$" /etc/hosts >/dev/null 2>&1; then 77 | sudo sed -i.bak -e "1 a 127.0.0.1\t$1" /etc/hosts && 78 | printf "\tAdded $1 to /etc/hosts\n" || 79 | printf "\tE: Failed to add $1 to /etc/hosts\n" 80 | fi 81 | } 82 | #echo "" |sudo tee -a /etc/hosts >/dev/null 83 | #sudo sed -i.bak 's/127\.0\.0\.1.*/127.0.0.1 localhost/' /etc/hosts 84 | manage_host "$site1" 85 | manage_host "$site2" 86 | #echo " $site1 $site2" |sudo tee -a /etc/hosts 87 | #cat /etc/hosts 88 | 89 | #sudo a2dissite 000-default 90 | #sudo a2ensite "$site1" >/dev/null 2>&1 && prnt "\tCreated site: $site1" 91 | #sudo a2ensite "$site2" >/dev/null 2>&1 && prnt "\tCreated site: $site2" 92 | #sudo a2enmod rewrite >/dev/null 2>&1 93 | 94 | prnt "\nngrok ..." 95 | 96 | #download ngrok 97 | #wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip -O ngrok-stable-linux-amd64.zip 98 | #unzip ngrok-stable-linux-amd64.zip 99 | nweb=127.0.0.1:4040 100 | nconf="tunnels: 101 | $site1: 102 | proto: http 103 | host_header: rewrite 104 | addr: $site1:80 105 | web_addr: $nweb 106 | $site2: 107 | proto: http 108 | host_header: rewrite 109 | addr: $site2:80 110 | web_addr: $nweb" 111 | nconf_f=ngrok.yml 112 | echo "$nconf" > "$nconf_f" && prnt '\tCreated ngrok config file' 113 | 114 | nohup ./ngrok start -config "$nconf_f" $site1 $site2 >/dev/null 2>&1 & 115 | pid=$! 116 | prnt "\tRunning ngrok in the background (pid: $pid)" 117 | 118 | t1=$(date +%s) 119 | max_t=7 #limit max try in seconds 120 | while true; do 121 | tunnel_info_json="$(curl -s http://$nweb/api/tunnels)" 122 | #echo $tunnel_info_json 123 | public_url1="$(echo "$tunnel_info_json" | jq -r '.tunnels[0].public_url' | grep 'http://')" 124 | dom1="$(echo "$public_url1" |sed -n -e 's#.*//\([^/]*\)/*.*#\1#p')" 125 | public_url2="$(echo "$tunnel_info_json" | jq -r '.tunnels[2].public_url' | grep 'http://')" 126 | dom2="$(echo "$public_url2" |sed -n -e 's#.*//\([^/]*\)/*.*#\1#p')" 127 | if [ -n "$dom1" ] && [ -n "$dom2" ]; then 128 | break 129 | fi 130 | t2=$(date +%s) 131 | time="$(expr $t2 - $t1)" 132 | if [ $time -ge $max_t ]; then 133 | t1=$(date +%s) 134 | prnt "\tngork froze. Restarting ..." 135 | kill $pid >/dev/null 2>&1 && prnt "\tKilled pid: $pid" 136 | nohup ./ngrok start -config "$nconf_f" $site1 $site2 >/dev/null 2>&1 & 137 | pid=$! 138 | prnt "\tngrok restarted (pid: $pid)" 139 | fi 140 | done 141 | 142 | if [ "$dom1" = "$dom2" ]; then 143 | err '\tE: Both domain can not be same. abort' 144 | Exit 1 2>/dev/null 145 | fi 146 | 147 | prnt "\tSite1: $public_url1" 148 | prnt "\tSite2: $public_url2" 149 | 150 | 151 | #tphp='' 152 | 153 | #echo working1 > "$doc_root1"/somefile 154 | #echo working2 > "$doc_root2"/somefile 155 | 156 | #ls -la "$doc_root1" "$doc_root2" 157 | 158 | #prnt "URL1: $public_url1/somefile" 159 | #prnt "URL2: $public_url2/somefile" 160 | 161 | #curl "$public_url1/somefile" 162 | #curl "$public_url2/somefile" 163 | #curl "$public_url1" 164 | 165 | prnt '\nPreparing ...' 166 | 167 | #openssl genrsa 4096 > account.key && prnt '\tCreated account.key' 168 | printf "$dom1\n$dom2\n" > dom.list && prnt '\tCreated dom.list file' 169 | ./gencsr >/dev/null && prnt '\tCreated CSR' 170 | 171 | prnt ' 172 | ********************************************************* 173 | *** Test 1: With --config-json and using document root 174 | ********************************************************* 175 | ' 176 | 177 | conf_json='{ 178 | "'$dom1'": 179 | { 180 | "DocumentRoot": "'$doc_root1'" 181 | }, 182 | "'$dom2'": 183 | { 184 | "DocumentRoot": "'$doc_root2'" 185 | }, 186 | "DocumentRoot": "'$doc_root2'", 187 | "AccountKey":"account.key", 188 | "CSR": "dom.csr", 189 | "CertFile":"dom.crt", 190 | "ChainFile":"chain.crt", 191 | "CA":"", 192 | "NoChain":"False", 193 | "NoCert":"False", 194 | "Test":"True", 195 | "Force":"False" 196 | }' 197 | echo "$conf_json" > config.json && prnt '\tCreated config.json file' 198 | 199 | prnt "\tRunning letsacme: python ../letsacme.py --config-json config.json" 200 | #echo "" > "$doc_root1"/.htaccess 201 | #echo "" > "$doc_root2"/.htaccess 202 | #sleep 30 203 | t="$(mktemp)" 204 | { python ../letsacme.py --config-json config.json 2>&1; echo $? >"$t"; } | sed -e 's/.*/\t\t&/' 205 | es=$(cat $t) 206 | rm "$t" 207 | if [ $es -eq 0 ]; then 208 | prnt '\n\t*** success on test 1 ***' 209 | else 210 | err '\tE: Failed to get the certs' 211 | Exit 1 2>/dev/null 212 | fi 213 | 214 | prnt " 215 | ******************************************************* 216 | *** Test 2: With --acme-dir and without --config-json 217 | ******************************************************* 218 | " 219 | 220 | #red_code=" 221 | #RewriteEngine On 222 | #RewriteBase / 223 | #RewriteRule ^.well-known/acme-challenge/(.*)$ http://$dom1/acme-challenge/\$1 [L,R=302] 224 | #" 225 | #echo "$red_code" > "$doc_root1"/.htaccess && prnt "\tRedirect for $site1 is set" 226 | #echo "$red_code" > "$doc_root2"/.htaccess && prnt "\tRedirect for $site2 is set" 227 | 228 | alias="Alias /.well-known/acme-challenge $acme_dir\n\nRequire all granted\n" 229 | 230 | sudo sed -i.bak -e "s#]*>#&\n$alias#" /etc/apache2/sites-available/000-default.conf 231 | #cat /etc/apache2/sites-available/000-default.conf 232 | sudo a2enmod alias >/dev/null 2>&1 233 | #sudo a2enmod actions 234 | sudo service apache2 restart >/dev/null 2>&1 && prnt '\tReloaded apache2' || err '\tE: Failed to reload apache2' 235 | 236 | prnt "\tRunning letsacme: 237 | \tpython ../letsacme.py --test\\\\\n\t --account-key account.key\\\\\n\t --csr dom.csr\\\\\n\t --acme-dir $acme_dir\\\\\n\t --chain-file chain.crt\\\\\n\t --cert-file dom.crt" 238 | t="$(mktemp)" 239 | { python ../letsacme.py --test --account-key account.key --csr dom.csr --acme-dir "$acme_dir" --chain-file chain.crt --cert-file dom.crt 2>&1; echo $? >"$t"; } | sed -e 's/.*/\t\t&/' 240 | es=$(cat "$t") 241 | rm $t 242 | if [ $es -eq 0 ]; then 243 | prnt '\n\t*** success on test 2 ***' 244 | else 245 | err '\tE: Failed to get the certs' 246 | Exit 1 2>/dev/null 247 | fi 248 | 249 | ############## Final cleaning ############### 250 | Exit 0 2>/dev/null 251 | ############################################# 252 | -------------------------------------------------------------------------------- /acme_tiny.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright Daniel Roesler, under MIT license, see LICENSE at github.com/diafygi/acme-tiny 3 | import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging 4 | try: 5 | from urllib.request import urlopen, Request # Python 3 6 | except ImportError: 7 | from urllib2 import urlopen, Request # Python 2 8 | 9 | DEFAULT_CA = "https://acme-v02.api.letsencrypt.org" # DEPRECATED! USE DEFAULT_DIRECTORY_URL INSTEAD 10 | DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory" 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | LOGGER.addHandler(logging.StreamHandler()) 14 | LOGGER.setLevel(logging.INFO) 15 | 16 | def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check=False, directory_url=DEFAULT_DIRECTORY_URL, contact=None): 17 | directory, acct_headers, alg, jwk = None, None, None, None # global variables 18 | 19 | # helper functions - base64 encode for jose spec 20 | def _b64(b): 21 | return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") 22 | 23 | # helper function - run external commands 24 | def _cmd(cmd_list, stdin=None, cmd_input=None, err_msg="Command Line Error"): 25 | proc = subprocess.Popen(cmd_list, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 26 | out, err = proc.communicate(cmd_input) 27 | if proc.returncode != 0: 28 | raise IOError("{0}\n{1}".format(err_msg, err)) 29 | return out 30 | 31 | # helper function - make request and automatically parse json response 32 | def _do_request(url, data=None, err_msg="Error", depth=0): 33 | try: 34 | resp = urlopen(Request(url, data=data, headers={"Content-Type": "application/jose+json", "User-Agent": "acme-tiny"})) 35 | resp_data, code, headers = resp.read().decode("utf8"), resp.getcode(), resp.headers 36 | except IOError as e: 37 | resp_data = e.read().decode("utf8") if hasattr(e, "read") else str(e) 38 | code, headers = getattr(e, "code", None), {} 39 | try: 40 | resp_data = json.loads(resp_data) # try to parse json results 41 | except ValueError: 42 | pass # ignore json parsing errors 43 | if depth < 100 and code == 400 and resp_data['type'] == "urn:ietf:params:acme:error:badNonce": 44 | raise IndexError(resp_data) # allow 100 retrys for bad nonces 45 | if code not in [200, 201, 204]: 46 | raise ValueError("{0}:\nUrl: {1}\nData: {2}\nResponse Code: {3}\nResponse: {4}".format(err_msg, url, data, code, resp_data)) 47 | return resp_data, code, headers 48 | 49 | # helper function - make signed requests 50 | def _send_signed_request(url, payload, err_msg, depth=0): 51 | payload64 = "" if payload is None else _b64(json.dumps(payload).encode('utf8')) 52 | new_nonce = _do_request(directory['newNonce'])[2]['Replay-Nonce'] 53 | protected = {"url": url, "alg": alg, "nonce": new_nonce} 54 | protected.update({"jwk": jwk} if acct_headers is None else {"kid": acct_headers['Location']}) 55 | protected64 = _b64(json.dumps(protected).encode('utf8')) 56 | protected_input = "{0}.{1}".format(protected64, payload64).encode('utf8') 57 | out = _cmd(["openssl", "dgst", "-sha256", "-sign", account_key], stdin=subprocess.PIPE, cmd_input=protected_input, err_msg="OpenSSL Error") 58 | data = json.dumps({"protected": protected64, "payload": payload64, "signature": _b64(out)}) 59 | try: 60 | return _do_request(url, data=data.encode('utf8'), err_msg=err_msg, depth=depth) 61 | except IndexError: # retry bad nonces (they raise IndexError) 62 | return _send_signed_request(url, payload, err_msg, depth=(depth + 1)) 63 | 64 | # helper function - poll until complete 65 | def _poll_until_not(url, pending_statuses, err_msg): 66 | result, t0 = None, time.time() 67 | while result is None or result['status'] in pending_statuses: 68 | assert (time.time() - t0 < 3600), "Polling timeout" # 1 hour timeout 69 | time.sleep(0 if result is None else 2) 70 | result, _, _ = _send_signed_request(url, None, err_msg) 71 | return result 72 | 73 | # parse account key to get public key 74 | log.info("Parsing account key...") 75 | out = _cmd(["openssl", "rsa", "-in", account_key, "-noout", "-text"], err_msg="OpenSSL Error") 76 | pub_pattern = r"modulus:[\s]+?00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)" 77 | pub_hex, pub_exp = re.search(pub_pattern, out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() 78 | pub_exp = "{0:x}".format(int(pub_exp)) 79 | pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp 80 | alg = "RS256" 81 | jwk = { 82 | "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), 83 | "kty": "RSA", 84 | "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), 85 | } 86 | accountkey_json = json.dumps(jwk, sort_keys=True, separators=(',', ':')) 87 | thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) 88 | 89 | # find domains 90 | log.info("Parsing CSR...") 91 | out = _cmd(["openssl", "req", "-in", csr, "-noout", "-text"], err_msg="Error loading {0}".format(csr)) 92 | domains = set([]) 93 | common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", out.decode('utf8')) 94 | if common_name is not None: 95 | domains.add(common_name.group(1)) 96 | subject_alt_names = re.search(r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) 97 | if subject_alt_names is not None: 98 | for san in subject_alt_names.group(1).split(", "): 99 | if san.startswith("DNS:"): 100 | domains.add(san[4:]) 101 | log.info("Found domains: {0}".format(", ".join(domains))) 102 | 103 | # get the ACME directory of urls 104 | log.info("Getting directory...") 105 | directory_url = CA + "/directory" if CA != DEFAULT_CA else directory_url # backwards compatibility with deprecated CA kwarg 106 | directory, _, _ = _do_request(directory_url, err_msg="Error getting directory") 107 | log.info("Directory found!") 108 | 109 | # create account, update contact details (if any), and set the global key identifier 110 | log.info("Registering account...") 111 | reg_payload = {"termsOfServiceAgreed": True} 112 | account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering") 113 | log.info("Registered!" if code == 201 else "Already registered!") 114 | if contact is not None: 115 | account, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details") 116 | log.info("Updated contact details:\n{0}".format("\n".join(account['contact']))) 117 | 118 | # create a new order 119 | log.info("Creating new order...") 120 | order_payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]} 121 | order, _, order_headers = _send_signed_request(directory['newOrder'], order_payload, "Error creating new order") 122 | log.info("Order created!") 123 | 124 | # get the authorizations that need to be completed 125 | for auth_url in order['authorizations']: 126 | authorization, _, _ = _send_signed_request(auth_url, None, "Error getting challenges") 127 | domain = authorization['identifier']['value'] 128 | log.info("Verifying {0}...".format(domain)) 129 | 130 | # find the http-01 challenge and write the challenge file 131 | challenge = [c for c in authorization['challenges'] if c['type'] == "http-01"][0] 132 | token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) 133 | keyauthorization = "{0}.{1}".format(token, thumbprint) 134 | wellknown_path = os.path.join(acme_dir, token) 135 | with open(wellknown_path, "w") as wellknown_file: 136 | wellknown_file.write(keyauthorization) 137 | 138 | # check that the file is in place 139 | try: 140 | wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) 141 | assert (disable_check or _do_request(wellknown_url)[0] == keyauthorization) 142 | except (AssertionError, ValueError) as e: 143 | raise ValueError("Wrote file to {0}, but couldn't download {1}: {2}".format(wellknown_path, wellknown_url, e)) 144 | 145 | # say the challenge is done 146 | _send_signed_request(challenge['url'], {}, "Error submitting challenges: {0}".format(domain)) 147 | authorization = _poll_until_not(auth_url, ["pending"], "Error checking challenge status for {0}".format(domain)) 148 | if authorization['status'] != "valid": 149 | raise ValueError("Challenge did not pass for {0}: {1}".format(domain, authorization)) 150 | os.remove(wellknown_path) 151 | log.info("{0} verified!".format(domain)) 152 | 153 | # finalize the order with the csr 154 | log.info("Signing certificate...") 155 | csr_der = _cmd(["openssl", "req", "-in", csr, "-outform", "DER"], err_msg="DER Export Error") 156 | _send_signed_request(order['finalize'], {"csr": _b64(csr_der)}, "Error finalizing order") 157 | 158 | # poll the order to monitor when it's done 159 | order = _poll_until_not(order_headers['Location'], ["pending", "processing"], "Error checking order status") 160 | if order['status'] != "valid": 161 | raise ValueError("Order failed: {0}".format(order)) 162 | 163 | # download the certificate 164 | certificate_pem, _, _ = _send_signed_request(order['certificate'], None, "Certificate download failed") 165 | log.info("Certificate signed!") 166 | return certificate_pem 167 | 168 | def main(argv=None): 169 | parser = argparse.ArgumentParser( 170 | formatter_class=argparse.RawDescriptionHelpFormatter, 171 | description=textwrap.dedent("""\ 172 | This script automates the process of getting a signed TLS certificate from Let's Encrypt using 173 | the ACME protocol. It will need to be run on your server and have access to your private 174 | account key, so PLEASE READ THROUGH IT! It's only ~200 lines, so it won't take long. 175 | 176 | Example Usage: 177 | python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed_chain.crt 178 | 179 | Example Crontab Renewal (once per month): 180 | 0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed_chain.crt 2>> /var/log/acme_tiny.log 181 | """) 182 | ) 183 | parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") 184 | parser.add_argument("--csr", required=True, help="path to your certificate signing request") 185 | parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") 186 | parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") 187 | parser.add_argument("--disable-check", default=False, action="store_true", help="disable checking if the challenge file is hosted correctly before telling the CA") 188 | parser.add_argument("--directory-url", default=DEFAULT_DIRECTORY_URL, help="certificate authority directory url, default is Let's Encrypt") 189 | parser.add_argument("--ca", default=DEFAULT_CA, help="DEPRECATED! USE --directory-url INSTEAD!") 190 | parser.add_argument("--contact", metavar="CONTACT", default=None, nargs="*", help="Contact details (e.g. mailto:aaa@bbb.com) for your account-key") 191 | 192 | args = parser.parse_args(argv) 193 | LOGGER.setLevel(args.quiet or LOGGER.level) 194 | signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca, disable_check=args.disable_check, directory_url=args.directory_url, contact=args.contact) 195 | sys.stdout.write(signed_crt) 196 | 197 | if __name__ == "__main__": # pragma: no cover 198 | main(sys.argv[1:]) 199 | -------------------------------------------------------------------------------- /test/lampi: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | p_name=lampi 3 | p_author='Md Jahidul Hamid' 4 | p_source='https://github.com/neurobin/lampi' 5 | p_bugt="$p_source/issues" 6 | p_version=0.1.7 7 | ver_info=" 8 | Name : $p_name 9 | Version : $p_version 10 | Author : $p_author 11 | Source : $p_source 12 | Bug tracker : $p_bugt 13 | " 14 | help=" 15 | ############################## lampi ##################################### 16 | ################### LAMP stack installer for Ubuntu ###################### 17 | ########################################################################## 18 | # Usage: 19 | # lampi [-n, -dr, -i, -ri, -u, -s, -so, -rm, -rmd, -nsr, -f -nu, -v, -h] 20 | # 21 | # Run simply lampi -i to install the LAMP stack 22 | # with default configs 23 | # 24 | # Options: 25 | # -n | --name | server name (default localhost) 26 | # -dr | --doc-root | arbitrary document root 27 | # -i | --install | install LAMP 28 | # -ri | --reinstall | reinstall LAMP 29 | # -u | --uninstall | uninstall LAMP 30 | # -s | --ssl | enable SSL 31 | # -rm | --remove | remove a site 32 | # -rmd | --remove-with-dir | remove a site and it's directory 33 | # -f | --force | force 34 | # -nu | --no-update | do not run apt-get update 35 | # -so | --ssl-only | configure for https site only 36 | # -npa | --no-php-myadmin | no php my admin 37 | # -nsr | --no-server-restart | no server restart 38 | # -v | --version | show version info 39 | # -h | --help | show help 40 | ########################################################################## 41 | " 42 | dr='' 43 | ssl=false 44 | ssl_only=false 45 | no_update=false 46 | install=false 47 | localhost=localhost 48 | uninstall=false 49 | reinstall=false 50 | remove= 51 | force=false 52 | rmd=false 53 | npa=false 54 | nsr=false 55 | for op in "$@";do 56 | case "$op" in 57 | -v|--version) 58 | echo "$ver_info" 59 | shift 60 | exit 0 61 | ;; 62 | -h|--help) 63 | echo "$help" 64 | shift 65 | exit 0 66 | ;; 67 | -dr|--doc-root) 68 | shift 69 | if [[ "$1" != "" ]]; then 70 | dr="${1/%\//}" 71 | shift 72 | else 73 | echo "E: Arg missing for -dr option" 74 | exit 1 75 | fi 76 | ;; 77 | -s|--ssl) 78 | ssl=true 79 | shift 80 | ;; 81 | -so|--ssl-only) 82 | ssl=true 83 | ssl_only=true 84 | shift 85 | ;; 86 | -nu|--no-update) 87 | no_update=true 88 | shift 89 | ;; 90 | -npa|--no-php-myadmin) 91 | npa=true 92 | shift 93 | ;; 94 | -nsr|--no-server-restart) 95 | nsr=true 96 | shift 97 | ;; 98 | -i|--install) 99 | install=true 100 | reinstall=false 101 | uninstall=false 102 | shift 103 | ;; 104 | -ri|--reinstall) 105 | install=true 106 | reinstall=true 107 | uninstall=false 108 | shift 109 | ;; 110 | -u|--uninstall) 111 | install=false 112 | reinstall=false 113 | uninstall=true 114 | shift 115 | ;; 116 | -f|--force) 117 | force=true 118 | shift 119 | ;; 120 | -rm|--remove) 121 | shift 122 | if [[ "$1" != "" ]]; then 123 | remove="$1" 124 | shift 125 | else 126 | echo "E: Arg missing for -rm option" 127 | exit 1 128 | fi 129 | ;; 130 | -rmd|--remove-with-dir) 131 | shift 132 | if [[ "$1" != "" ]]; then 133 | remove="$1" 134 | rmd=true 135 | shift 136 | else 137 | echo "E: Arg missing for -rm option" 138 | exit 1 139 | fi 140 | ;; 141 | -n|--name) 142 | shift 143 | if [[ "$1" != "" ]]; then 144 | localhost="$1" 145 | shift 146 | else 147 | echo "E: Arg missing for -n option" 148 | exit 1 149 | fi 150 | ;; 151 | -*) 152 | echo "E: Invalid option: $1" 153 | shift 154 | exit 1 155 | ;; 156 | esac 157 | done 158 | 159 | [ "$dr" = "" ] && dr='/var/www/html' 160 | 161 | opts=" 162 | ***** Passed options ***** 163 | DocumentRoot : $dr 164 | ServerName : $localhost 165 | SSL : $ssl 166 | SSL only : $ssl_only 167 | Install : $install 168 | Uninstall : $uninstall 169 | Reinstall : $reinstall 170 | NoUpdate : $no_update 171 | " 172 | #echo "$opts" 173 | 174 | 175 | #check for root 176 | if [ $EUID -ne 0 ]; then 177 | printf "\n\tRoot privilege required\n" 178 | printf "\tSorry! Exiting\n\n" 179 | exit 1 180 | fi 181 | user=www-data 182 | if [ ! -z "$SUDO_USER" ]; then 183 | user="$SUDO_USER" 184 | fi 185 | mkdir -p "$dr" 186 | chown $user:$user "$dr" 187 | chmod 755 "$dr" 188 | 189 | #extract dist version 190 | rel=(`lsb_release -r | grep -o '[0-9]\+'`) 191 | rv=${rel[0]} 192 | 193 | tphp='' 194 | endm=" 195 | ****** Successfully reloaded apache2 ****** 196 | " 197 | 198 | aconf=/etc/apache2/apache2.conf 199 | 200 | if [ -n "$remove" ]; then 201 | a2dissite $remove 202 | remove_file=/etc/apache2/sites-available/"$remove" 203 | file=/etc/hosts 204 | pat="^\([[:blank:]]*127\.0\.0\.1.*\)[[:blank:]]$remove\b\(.*\)$" 205 | while grep -e "$pat" "$file" >/dev/null 2>&1;do 206 | sed -i.bak -e "s/$pat/\1\2/" "$file" 207 | done 208 | if [ -f "$remove_file.conf" ]; then 209 | rm_dir="$(sed -n -e 's/^[[:blank:]]*DocumentRoot[[:blank:]]*\([^#]*\).*/\1/p' "$remove_file.conf")" 210 | if $rmd && ! test -z "$rm_dir" && [ "." != "$rm_dir" ] && [ '..' != "$rm_dir" ]; then 211 | rm -r "$rm_dir" 212 | fi 213 | fi 214 | rm "$remove_file.conf" "$remove_file.conf.bak" 215 | if $ssl; then 216 | a2dissite $remove-ssl 217 | rm "$remove_file-ssl.conf" "$remove_file-ssl.conf.bak" 218 | fi 219 | else 220 | if $install || $uninstall; then 221 | 222 | #update 223 | if ! $no_update && ! $uninstall;then apt-get update;fi 224 | 225 | #apache2 226 | list=( 227 | 'apache2' 228 | ) 229 | 230 | #php 231 | php='mcrypt' 232 | if (( $rv <= 15 ));then 233 | php="$php php5 libapache2-mod-php5 php5-mcrypt php5-cgi php5-cli php5-common php5-curl php5-gd" 234 | elif (( $rv >= 16 ));then 235 | php="$php php libapache2-mod-php php-mcrypt php-cgi php-cli php-common php-curl php-gd" 236 | fi 237 | 238 | list+=("$php") 239 | 240 | # mysql 241 | mysql='mysql-server mysql-client' 242 | if (( $rv <= 15 ));then 243 | mysql="$mysql php5-mysql" 244 | elif (( $rv >= 16 ));then 245 | mysql="$mysql php-mysql" 246 | fi 247 | list+=("$mysql") 248 | 249 | #phpmyadmin 250 | if ! $npa; then 251 | phpmd='phpmyadmin apache2-utils' 252 | else 253 | phpmd='' 254 | fi 255 | if (( $rv >= 16 ));then 256 | phpmd="$phpmd php-gettext php-mbstring" 257 | fi 258 | list+=("$phpmd") 259 | 260 | for app in "${list[@]}";do 261 | if ! $uninstall; then 262 | echo "###### Installing: $app #######" 263 | { if ! $reinstall; then apt-get -y install $app;else apt-get --force-yes -y install --reinstall $app;fi; } || 264 | { 265 | echo "W: Failed to install: $app" 266 | [ "$app" = apache2 ] && exit 1 267 | } 268 | else 269 | echo "###### Removing: $app #######" 270 | apt-get -y purge $app 271 | fi 272 | 273 | done 274 | if ! $npa; then 275 | pdconf=/etc/phpmyadmin/apache.conf 276 | pdincl="Include $pdconf" 277 | if ! grep -s -e "$pdincl" "$aconf" >/dev/null 2>&1; then 278 | sed -i.bak "$ a $pdincl" "$aconf" && 279 | printf "\n\t apache2 conf modified to include phpmyadmin\n" || 280 | printf "\n\tE: failed to modify apache2 conf for phpmyadmin\n" 281 | else 282 | printf "\n\tphpmyadmin is already added in apache2 conf (skipped)\n" 283 | fi 284 | fi 285 | 286 | apt-get autoremove 287 | 288 | if ! $uninstall; then 289 | if (( $rv <= 15 )); then 290 | php5enmod mcrypt 291 | elif (( $rv >= 16 )); then 292 | phpenmod mcrypt 293 | phpenmod mbstring 294 | fi 295 | 296 | fi 297 | 298 | fi 299 | 300 | #if uninstall exit 301 | if $uninstall;then echo "Removed LAMP installation (custom sites are not removed)."; exit 0;fi 302 | 303 | #create info.php 304 | [ ! -f "$dr"/info.php ] && echo "$tphp" > "$dr"/info.php && printf "\n\tinfo.php created\n" || printf "\n\tinfo.php exists (skipped)\n" 305 | chown $user:$user "$dr"/info.php 306 | chmod 640 "$dr"/info.php 307 | 308 | dire_opts="\n\t\tAllowOverride All\n\t\tOptions Indexes FollowSymLinks MultiViews\n\t\tRequire all granted\n\t" 309 | dire="\t$dire_opts\n" 310 | 311 | #custom sites 312 | sites_av=/etc/apache2/sites-available 313 | ds="$(find "$sites_av" -name '*default.conf')" 314 | ds_ssl="$(find "$sites_av" -name '*default*ssl*.conf')" 315 | dsn="$sites_av/$localhost.conf" 316 | ds_ssln="$sites_av/$localhost-ssl.conf" 317 | 318 | if [ "$ds" = "$dsn" ]; then 319 | printf "$ds\m\t\tand\n$dsn\n\t\tcan not be same\nE: ServerName: $(basename "$ds") not allowed\n" 320 | exit 1 321 | elif [ "$ds_ssl" = "$ds_ssln" ]; then 322 | printf "$ds_ssl\m\t\tand\n$ds_ssln\n\t\tcan not be same\nE: ServerName: default not allowed\n" 323 | exit 1 324 | fi 325 | 326 | export localhost 327 | export dr 328 | export dire 329 | copy_default_config(){ 330 | sed -e "s=^\([[:blank:]]*DocumentRoot\).*$=\1 $dr="\ 331 | -e "s=^\([[:blank:]]*ServerName\).*$=\1 $localhost="\ 332 | -e "s=^\([[:blank:]]*<[[:blank:]]*Directory[[:blank:]]*\)[^[:blank:]]\+\([[:blank:]]*>[[:blank:]]*\)$=\1$dr\2="\ 333 | -e "s=^\([[:blank:]]*<[[:blank:]]*VirtualHost[[:blank:]]*\)[^:]*\(:[^>]*>[[:blank:]]*\)$=\1 127.0.0.1\2="\ 334 | "$1" > "$2" && 335 | printf "\n\tcopied $1 --> $2 with required modifications\n" || 336 | printf "\n\tE: Failed to copy $1 --> $2\n" 337 | } 338 | 339 | if ! $ssl_only; then 340 | copy_default_config "$ds" "$dsn" 341 | fi 342 | #no else 343 | if $ssl; then 344 | copy_default_config "$ds_ssl" "$ds_ssln" 345 | fi 346 | 347 | insert_server_property(){ 348 | if ! grep -s -e "^\([[:blank:]]*$1\).*$" "$2" >/dev/null 2>&1; then 349 | sed -i.bak -e "s=^[[:blank:]]*<[[:blank:]]*VirtualHost[[:blank:]]*.*>[[:blank:]]*$=&\n\tServerName $localhost=" "$2" && 350 | printf "\n\tSuccessfully inserted $1 to $2\n" || 351 | printf "\n\tE: Failed to insert $1 to $2\n" 352 | else 353 | printf "\n\t$1 exists in $2 (skipped)\n" 354 | fi 355 | } 356 | if ! $ssl_only; then 357 | insert_server_property ServerName "$dsn" 358 | fi 359 | if $ssl; then 360 | insert_server_property ServerName "$ds_ssln" 361 | fi 362 | 363 | insert_docroot_property(){ 364 | if ! grep -s -e "^[[:blank:]]*<[[:blank:]]*Directory[[:blank:]]*[^[:blank:]]\+[[:blank:]]*>[[:blank:]]*$" "$1" >/dev/null 2>&1; then 365 | sed -i.bak -e "s=^\([[:blank:]]*\)DocumentRoot.*$=&\n$dire=" "$1" && 366 | printf "\n\tSuccessfully inserted DocumentRoot in $1\n" || 367 | printf "\n\tE: Failed to insert DocumentRoot in $1\n" 368 | else 369 | cat "${1-x}" > "${1-x}.bak" 2>&1 && 370 | printf "\n\tBack up: ${1-x} succeded\n" || 371 | printf "\n\tE: Back up: ${1-x} failed\n" 372 | echo "$(awk ' 373 | BEGIN {p=1} 374 | /^[[:blank:]]*<[[:blank:]]*Directory[[:blank:]]*[^[:blank:]]+[[:blank:]]*>[[:blank:]]*/ {print;print "'"$dire_opts"'";p=0} 375 | /[^#]*<\/Directory/ {p=1} 376 | p' "${1-x}")" > "${1-x}" && 377 | printf "\n\tSuccessfully edited ${1-x}\n" || 378 | printf "\n\tE: Failed to edit ${1-x}\n" 379 | fi 380 | } 381 | 382 | if ! $ssl_only; then 383 | insert_docroot_property "$dsn" 384 | fi 385 | if $ssl; then 386 | insert_docroot_property "$ds_ssln" 387 | fi 388 | 389 | #manage /etc/hosts 390 | pat='^[[:blank:]]*127\.0\.0\.1[[:blank:]]\+.*$' 391 | if grep "$pat" /etc/hosts >/dev/null 2>&1; then 392 | sed -i.bak -e "s/$pat/& $localhost/" /etc/hosts >/dev/null && 393 | printf "\n\tAdded $localhost to /etc/hosts\n" || 394 | printf "\n\tE: Failed to add $localhost to /etc/hosts\n" 395 | else 396 | sed -i.bak -e "1 a 127.0.0.1 $localhost" /etc/hosts >/dev/null && 397 | printf "\n\tAdded $localhost to /etc/hosts\n" || 398 | printf "\n\tE: Failed to add $localhost to /etc/hosts\n" 399 | fi 400 | 401 | #enable mods 402 | a2enmod rewrite && 403 | printf "\n\tEnabled mod_rewrite\n" || 404 | printf "\n\tE: Failed to enable mod_rewrite\n" 405 | if $ssl; then 406 | a2enmod ssl && 407 | printf "\n\tEnabled mod_ssl\n" || 408 | printf "\n\tE: Failed to enable mod_ssl\n" 409 | fi 410 | 411 | #enable sites 412 | if ! $ssl_only; then 413 | a2ensite "$localhost" && 414 | printf "\n\tEnabled $localhost\n" || 415 | printf "\n\tE: Failed to enable $localhost\n" 416 | fi 417 | if $ssl; then 418 | a2ensite "$localhost"-ssl && 419 | printf "\n\tEnabled $localhost-ssl\n" || 420 | printf "\n\tE: Failed to enable $localhost-ssl\n" 421 | fi 422 | 423 | endm="$endm 424 | ******* Visit Site ******* 425 | " 426 | if ! $ssl_only; then 427 | endm="${endm}http://$localhost/info.php 428 | " 429 | fi 430 | if $ssl; then 431 | endm="${endm}https://$localhost/info.php 432 | " 433 | fi 434 | fi 435 | 436 | #restart the apache2 service 437 | if ! $nsr; then 438 | { 439 | service apache2 restart || 440 | systemctl restart apache2 441 | } && 442 | echo "$endm" || 443 | echo "\n\tE: Failed to restart apache2\n" 444 | else 445 | echo "$endm after restarting the server." 446 | fi 447 | 448 | -------------------------------------------------------------------------------- /letsacme.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """@package letsacme 3 | ################ letsacme ################### 4 | This script automates the process of getting a signed TLS/SSL certificate 5 | from Let's Encrypt using the ACME protocol. It will need to be run on your 6 | server and have access to your private account key. 7 | It gets both the certificate and the chain (CABUNDLE) and 8 | prints them on stdout unless specified otherwise. 9 | """ 10 | 11 | import argparse # argument parser 12 | import subprocess # Popen 13 | import json # json.loads 14 | import os # os.path 15 | import sys # sys.exit 16 | import base64 # b64encode 17 | import binascii # unhexlify 18 | import time # time 19 | import hashlib # sha256 20 | import re # regex operation 21 | import copy # deepcopy 22 | import textwrap # wrap and dedent 23 | import logging # Logger 24 | import errno # EEXIST 25 | import shutil # rmtree 26 | 27 | try: # Python 3 28 | from urllib.request import urlopen 29 | from urllib.request import build_opener 30 | from urllib.request import HTTPRedirectHandler 31 | from urllib.error import HTTPError 32 | from urllib.error import URLError 33 | except ImportError: # Python 2 34 | from urllib2 import urlopen 35 | from urllib2 import HTTPRedirectHandler 36 | from urllib2 import build_opener 37 | from urllib2 import HTTPError 38 | from urllib2 import URLError 39 | 40 | ##################### letsacme info ##################### 41 | VERSION = "0.1.3" 42 | VERSION_INFO = "letsacme version: "+VERSION 43 | ##################### API info ########################## 44 | CA_VALID = "https://acme-v01.api.letsencrypt.org" 45 | CA_TEST = "https://acme-staging.api.letsencrypt.org" 46 | TERMS = 'https://acme-v01.api.letsencrypt.org/terms' 47 | API_DIR_NAME = 'directory' 48 | NEW_REG_KEY = 'new-reg' 49 | NEW_CERT_KEY = 'new-cert' 50 | NEW_AUTHZ_KEY = 'new-authz' 51 | ##################### Defaults ########################## 52 | DEFAULT_CA = CA_VALID 53 | API_INFO = set({}) 54 | # used as a fallback in DocumentRoot method: 55 | WELL_KNOWN_DIR = ".well-known/acme-challenge" 56 | ##################### Logger ############################ 57 | LOGGER = logging.getLogger(__name__) 58 | LOGGER.addHandler(logging.StreamHandler()) 59 | LOGGER.setLevel(logging.INFO) 60 | ######################################################### 61 | 62 | def error_exit(msg, log): 63 | """Print error message and exit with 1 exit status""" 64 | log.error(msg) 65 | sys.exit(1) 66 | 67 | def get_canonical_url(url, log): 68 | """Follow redirect and return the canonical URL""" 69 | try: 70 | opener = build_opener(HTTPRedirectHandler) 71 | request = opener.open(url) 72 | return request.url 73 | except (URLError, HTTPError) as err: 74 | log.error(str(err)) 75 | return url 76 | 77 | def get_boolean_options_from_json(conf_json, ncn, ncrt, tst, frc, quiet): 78 | """Parse config json for boolean options and return them sequentially. 79 | It takes prioritised values as params. Among these values, non-None/True values are 80 | preserved and their values in config json are ignored.""" 81 | opt = {'NoChain':ncn, 'NoCert':ncrt, 'Test':tst, 'Force':frc, 'Quiet': quiet} 82 | for key in opt: 83 | if not opt[key] and key in conf_json and conf_json[key].lower() == "true": 84 | opt[key] = True 85 | continue 86 | return opt['NoChain'], opt['NoCert'], opt['Test'], opt['Force'], opt['Quiet'] 87 | 88 | def get_options_from_json(conf_json, ack, csr, acmd, crtf, chnf, ca): 89 | """Parse key-value options from config json and return the values sequentially. 90 | It takes prioritised values as params. Among these values, non-None values are 91 | preserved and their values in config json are ignored.""" 92 | opt = {'AccountKey':ack, 'CSR':csr, 'AcmeDir':acmd, 'CertFile':crtf, 'ChainFile':chnf, 'CA':ca} 93 | for key in opt: 94 | if not opt[key] and key in conf_json and conf_json[key]: 95 | opt[key] = conf_json[key] 96 | continue 97 | opt[key] = None if opt[key] == '' or opt[key] == '.' or opt[key] == '..' else opt[key] 98 | return opt['AccountKey'], opt['CSR'], opt['AcmeDir'], opt['CertFile'], opt['ChainFile'],\ 99 | opt['CA'] 100 | 101 | def get_chain(url, log): 102 | """Download chain from chain url and return it""" 103 | resp = urlopen(url) 104 | if resp.getcode() != 200: 105 | error_exit("E: Failed to fetch chain (CABUNDLE) from: "+url, log) 106 | return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( 107 | "\n".join(textwrap.wrap(base64.b64encode(resp.read()).decode('utf8'), 64))) 108 | 109 | def write_file(path, content, log, exc=True): 110 | """Write content to the file specified by path""" 111 | try: 112 | with open(path, "w") as fileh: 113 | fileh.write(content) 114 | except IOError as err: 115 | log.exception("I/O error.") 116 | if exc: 117 | raise 118 | 119 | 120 | def get_crt(account_key, csr, conf_json, well_known_dir, acme_dir, log, CA, force): 121 | """Register account, parse CSR, complete challenges and finally 122 | get the signed SSL certificate and return it.""" 123 | def _b64(bcont): 124 | """helper function base64 encode for jose spec""" 125 | return base64.urlsafe_b64encode(bcont).decode('utf8').replace("=", "") 126 | 127 | def make_dirs(path): 128 | """Make directories including parent directories (if not exist)""" 129 | try: 130 | os.makedirs(path) 131 | except OSError as err: 132 | if err.errno != errno.EEXIST: 133 | log.exception("Exception in make_drs") 134 | raise 135 | 136 | # get challenge directory from json by domain name 137 | def get_challenge_dir(conf_json, dom, acmed): 138 | """Get the challenge directory path from config json""" 139 | if conf_json: 140 | if dom not in conf_json: 141 | if re.match(r'www[^.]*\.', dom): 142 | dom1 = re.sub(r"^www[^.]*\.", "", dom) 143 | else: 144 | dom1 = "www."+dom 145 | if dom1 in conf_json: 146 | dom = dom1 147 | # no else 148 | if dom in conf_json: 149 | if 'AcmeDir' in conf_json[dom]: 150 | return None, conf_json[dom]['AcmeDir'] 151 | elif 'DocumentRoot' in conf_json[dom]: 152 | return conf_json[dom]['DocumentRoot'], None 153 | # if none is given we will try to take challenge dir from global options 154 | if 'AcmeDir' in conf_json: 155 | return None, conf_json['AcmeDir'] 156 | elif 'DocumentRoot' in conf_json: 157 | return conf_json['DocumentRoot'], None 158 | elif acmed: 159 | return None, acmed 160 | else: 161 | error_exit("E: There is no valid entry for \"DocumentRoot\" or \"AcmeDir\" for \ 162 | the domain '"+dom+"' in\n" + 163 | json.dumps(conf_json, indent=4, sort_keys=True), log) 164 | 165 | # parse account key to get public key 166 | log.info("Parsing account key...") 167 | proc = subprocess.Popen(["openssl", "rsa", "-in", account_key, "-noout", "-text"], 168 | stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 169 | out, err = proc.communicate() 170 | if proc.returncode != 0: 171 | error_exit("\tE: OpenSSL Error: {0}".format(err), log) 172 | pub_hex, pub_exp = re.search( 173 | r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", 174 | out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() 175 | pub_exp = "{0:x}".format(int(pub_exp)) 176 | pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp 177 | header = { 178 | "alg": "RS256", 179 | "jwk": { 180 | "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), 181 | "kty": "RSA", 182 | "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), 183 | }, 184 | } 185 | accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) 186 | thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) 187 | log.info('\tParsed!') 188 | 189 | # helper function make signed requests 190 | def _send_signed_request(url, payload): 191 | payload64 = _b64(json.dumps(payload).encode('utf8')) 192 | protected = copy.deepcopy(header) 193 | protected["nonce"] = urlopen(CA + "/directory").headers['Replay-Nonce'] 194 | protected64 = _b64(json.dumps(protected).encode('utf8')) 195 | proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", account_key], 196 | stdin=subprocess.PIPE, 197 | stdout=subprocess.PIPE, 198 | stderr=subprocess.PIPE) 199 | out, err = proc.communicate("{0}.{1}".format(protected64, payload64).encode('utf8')) 200 | if proc.returncode != 0: 201 | error_exit("E: OpenSSL Error: {0}".format(err), log) 202 | data = json.dumps({ 203 | "header": header, "protected": protected64, 204 | "payload": payload64, "signature": _b64(out), 205 | }) 206 | try: 207 | resp = urlopen(url, data.encode('utf8')) 208 | return resp.getcode(), resp.read(), resp.info() 209 | except IOError as err: 210 | return getattr(err, "code", None), getattr(err, "read", err.__str__),\ 211 | getattr(err, "info", None)() 212 | 213 | crt_info = set([]) 214 | 215 | # find domains 216 | log.info("Parsing CSR...") 217 | proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"], 218 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 219 | out, err = proc.communicate() 220 | if proc.returncode != 0: 221 | error_exit("\tE: Error loading {0}: {1}".format(csr, err), log) 222 | domains = set([]) 223 | common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out.decode('utf8')) 224 | if common_name is not None: 225 | domains.add(common_name.group(1)) 226 | log.info("\tCN: "+common_name.group(1)) 227 | subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", 228 | out.decode('utf8'), re.MULTILINE|re.DOTALL) 229 | if subject_alt_names is not None: 230 | for san in subject_alt_names.group(1).split(", "): 231 | if san.startswith("DNS:"): 232 | domains.add(san[4:]) 233 | log.info('\tParsed!') 234 | 235 | # get the certificate domains and expiration 236 | log.info("Registering account...") 237 | agreement_url = get_canonical_url(TERMS, log) 238 | code, result, crt_info = _send_signed_request(API_INFO[NEW_REG_KEY], { 239 | "resource": NEW_REG_KEY, 240 | "agreement": agreement_url, 241 | }) 242 | if code == 201: 243 | log.info("\tRegistered!") 244 | elif code == 409: 245 | log.info("\tAlready registered!") 246 | else: 247 | error_exit("\tE: Error registering: {0} {1}".format(code, result), log) 248 | # verify each domain 249 | for domain in domains: 250 | log.info("Verifying {0}...".format(domain)) 251 | 252 | # get new challenge 253 | code, result, crt_info = _send_signed_request(API_INFO[NEW_AUTHZ_KEY], { 254 | "resource": NEW_AUTHZ_KEY, 255 | "identifier": {"type": "dns", "value": domain}, 256 | }) 257 | if code != 201: 258 | error_exit("\tE: Error requesting challenges: {0} {1}".format(code, result), log) 259 | 260 | # create the challenge file 261 | challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] \ 262 | if c['type'] == "http-01"][0] 263 | token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) 264 | keyauthorization = "{0}.{1}".format(token, thumbprint) 265 | wellknown_url = None 266 | if 'validationRecord' in challenge: 267 | for item in challenge['validationRecord']: 268 | if 'url' in item: 269 | res_m = re.match('.*://'+domain+r'/([\w\W]*)/'+token, item['url']) 270 | if res_m: 271 | well_known_dir = res_m.group(1) 272 | wellknown_url = res_m.group(0) 273 | log.info('\tWell known path was parsed: '+well_known_dir) 274 | # paranoid check 275 | if os.path.sep in token or (os.path.altsep or '\\') in token or not token: 276 | error_exit("\tE: Invalid and possibly dangerous token.", log) 277 | # take either acme-dir or document dir method 278 | doc_root, acme_dir = get_challenge_dir(conf_json, domain, acme_dir) 279 | if acme_dir: 280 | chlng = acme_dir.rstrip(os.path.sep+(os.path.altsep or "\\")) 281 | make_dirs(chlng) 282 | wellknown_path = os.path.join(chlng, token) 283 | elif doc_root: 284 | doc_root = doc_root.rstrip(os.path.sep+(os.path.altsep or "\\")) 285 | chlng = os.path.join(doc_root, well_known_dir.strip(os.path.sep+\ 286 | (os.path.altsep or "\\"))) 287 | make_dirs(chlng) 288 | wellknown_path = os.path.join(chlng, token) 289 | else: 290 | error_exit("\tE: Couldn't get DocumentRoot or AcmeDir for domain: "+domain, log) 291 | # another paranoid check 292 | if os.path.isdir(wellknown_path): 293 | log.warning("\tW: "+wellknown_path+" exists.") 294 | try: 295 | os.rmdir(wellknown_path) 296 | except OSError: 297 | if force: 298 | try: 299 | # This is why we have done paranoid check on token 300 | shutil.rmtree(wellknown_path) 301 | # though it itself is inside a paranoid check 302 | # which will probably never be reached 303 | log.info("\tRemoved "+wellknown_path) 304 | except OSError as err: 305 | error_exit("\tE: Failed to remove "+wellknown_path+'\n'+str(err), log) 306 | else: 307 | error_exit("\tE: "+wellknown_path+" is a directory. \ 308 | It shouldn't even exist in normal cases. \ 309 | Try --force option if you are sure about \ 310 | deleting it and all of its' content", log) 311 | 312 | write_file(wellknown_path, keyauthorization, log) 313 | 314 | # check that the file is in place 315 | if not wellknown_url: 316 | wellknown_url = ("http://{0}/"+well_known_dir+"/{1}").format(domain, token) 317 | try: 318 | resp = urlopen(wellknown_url) 319 | resp_data = resp.read().decode('utf8').strip() 320 | assert resp_data == keyauthorization 321 | except (IOError, AssertionError): 322 | os.remove(wellknown_path) 323 | log.critical("\tE: Wrote file to {0}, but couldn't download {1}".format(\ 324 | wellknown_path, wellknown_url,)) 325 | raise 326 | 327 | # notify challenge is met 328 | code, result, crt_info = _send_signed_request(challenge['uri'], { 329 | "resource": "challenge", 330 | "keyAuthorization": keyauthorization, 331 | }) 332 | if code != 202: 333 | os.remove(wellknown_path) 334 | error_exit("\tE: Error triggering challenge: {0} {1}".format(code, result.read()), log) 335 | 336 | # wait for challenge to be verified 337 | while True: 338 | try: 339 | resp = urlopen(challenge['uri']) 340 | challenge_status = json.loads(resp.read().decode('utf8')) 341 | except IOError as err: 342 | os.remove(wellknown_path) 343 | log.critical("\tE: Error checking challenge: {0} {1}\n{2}".format(\ 344 | resp.code, json.dumps(resp.read().decode('utf8'), indent=4), str(err),)) 345 | raise 346 | if challenge_status['status'] == "pending": 347 | time.sleep(1) 348 | elif challenge_status['status'] == "valid": 349 | os.remove(wellknown_path) 350 | log.info("\tverified!") 351 | break 352 | else: 353 | os.remove(wellknown_path) 354 | error_exit("\tE: {0} challenge did not pass: {1}".format(\ 355 | domain, challenge_status), log) 356 | 357 | # get the new certificate 358 | test_mode = " (test mode)" if CA == CA_TEST else "" 359 | log.info("Signing certificate..."+test_mode) 360 | proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"], 361 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 362 | csr_der, err = proc.communicate() 363 | code, result, crt_info = _send_signed_request(API_INFO[NEW_CERT_KEY], { 364 | "resource": NEW_CERT_KEY, 365 | "csr": _b64(csr_der), 366 | }) 367 | if code != 201: 368 | error_exit("\tE: Error signing certificate: {0} {1}".format(code, result), log) 369 | 370 | log.info('\tParsing chain url...') 371 | res_m = re.match("\\s*<([^>]+)>;rel=\"up\"", crt_info['Link']) 372 | chain_url = res_m.group(1) if res_m else None 373 | if not chain_url: 374 | log.error('\tW: Failed to parse chain url!') 375 | 376 | # return signed certificate! 377 | log.info("\tSigned!"+test_mode) 378 | return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( 379 | "\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64))), chain_url 380 | 381 | def main(argv): 382 | """Parse arguments and run helper functions to get the certs""" 383 | parser = argparse.ArgumentParser( 384 | formatter_class=argparse.RawDescriptionHelpFormatter, 385 | description=textwrap.dedent("""\ 386 | This script automates the process of getting a signed TLS/SSL certificate from 387 | Let's Encrypt using the ACME protocol. It will need to be run on your server 388 | and have access to your private account key, so PLEASE READ THROUGH IT!. 389 | 390 | ===Example Usage=== 391 | python letsacme.py --config-json /path/to/config.json 392 | =================== 393 | 394 | ===Example Crontab Renewal (once per month)=== 395 | 0 0 1 * * python /path/to/letsacme.py --config-json /path/to/config.json > /path/to/full-chain.crt 2>> /path/to/letsacme.log 396 | ============================================== 397 | """) 398 | ) 399 | parser.add_argument("--account-key", help="Path to your Let's Encrypt account private key.") 400 | parser.add_argument("--csr", help="Path to your certificate signing request.") 401 | parser.add_argument("--config-json", default=None, help="Configuration JSON string/file. \ 402 | Must contain \"DocumentRoot\":\"/path/to/document/root\" entry \ 403 | for each domain.") 404 | parser.add_argument("--acme-dir", default=None, help="Path to the acme challenge directory") 405 | parser.add_argument("--cert-file", default=None, help="File to write the certificate to. \ 406 | Overwrites if file exists.") 407 | parser.add_argument("--chain-file", default=None, help="File to write the certificate to. \ 408 | Overwrites if file exists.") 409 | parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="Suppress \ 410 | output except for errors.") 411 | parser.add_argument("--ca", default=None, help="Certificate authority, default is Let's \ 412 | Encrypt.") 413 | parser.add_argument("--no-chain", action="store_true", help="Fetch chain (CABUNDLE) but\ 414 | do not print it on stdout.") 415 | parser.add_argument("--no-cert", action="store_true", help="Fetch certificate but do not\ 416 | print it on stdout.") 417 | parser.add_argument("--force", action="store_true", help="Apply force. If a directory\ 418 | is found inside the challenge directory with the same name as\ 419 | challenge token (paranoid), this option will delete the directory\ 420 | and it's content (Use with care).") 421 | parser.add_argument("--test", action="store_true", help="Get test certificate (Invalid \ 422 | certificate). This option won't have any effect if --ca is passed.") 423 | parser.add_argument("--version", action="version", version=VERSION_INFO, help="Show version \ 424 | info.") 425 | 426 | args = parser.parse_args(argv) 427 | if not args.config_json and not args.acme_dir: 428 | parser.error("One of --config-json or --acme-dir must be given") 429 | 430 | # parse config_json 431 | conf_json = None 432 | if args.config_json: 433 | config_json_s = args.config_json 434 | # config_json can point to a file too. 435 | if os.path.isfile(args.config_json): 436 | try: 437 | with open(args.config_json, "r") as fileh: 438 | config_json_s = fileh.read() 439 | except IOError as err: 440 | LOGGER.critical("E: Failed to read json file: "+args.config_json) 441 | raise 442 | # Now we are sure that config_json_s is a json string, not file 443 | try: 444 | conf_json = json.loads(config_json_s) 445 | except ValueError as err: 446 | LOGGER.critical("E: Failed to parse json") 447 | raise 448 | args.account_key, args.csr, args.acme_dir, args.cert_file,\ 449 | args.chain_file, args.ca = get_options_from_json(conf_json, 450 | args.account_key, 451 | args.csr, 452 | args.acme_dir, 453 | args.cert_file, 454 | args.chain_file, 455 | args.ca) 456 | args.no_chain, args.no_cert, args.test, args.force, args.quiet = \ 457 | get_boolean_options_from_json(conf_json, args.no_chain, args.no_cert, 458 | args.test, args.force, args.quiet) 459 | 460 | LOGGER.setLevel(logging.ERROR if args.quiet else LOGGER.level) 461 | 462 | # show error in case args are missing 463 | if not args.account_key: 464 | error_exit("E: Account key path not specified.", LOGGER) 465 | if not args.csr: 466 | error_exit("E: CSR path not specified", LOGGER) 467 | if not args.config_json and not args.acme_dir: 468 | error_exit("E: Either --acme-dir or --config-json must be given", log=LOGGER) 469 | # we need to set a default CA if not specified 470 | if not args.ca: 471 | args.ca = CA_TEST if args.test else DEFAULT_CA 472 | 473 | global API_INFO # this is where we will pull our information from 474 | API_INFO = json.loads(urlopen(args.ca+'/'+API_DIR_NAME).read().decode('utf8')) 475 | 476 | # lets do the main task 477 | signed_crt, chain_url = get_crt(args.account_key, args.csr, 478 | conf_json, well_known_dir=WELL_KNOWN_DIR, 479 | acme_dir=args.acme_dir, log=LOGGER, 480 | CA=args.ca, force=args.force) 481 | 482 | if args.cert_file: 483 | write_file(args.cert_file, signed_crt, LOGGER, False) 484 | if not args.no_cert: 485 | sys.stdout.write(signed_crt) 486 | 487 | if chain_url: 488 | chain = get_chain(chain_url, log=LOGGER) 489 | if args.chain_file: 490 | write_file(args.chain_file, chain, LOGGER, False) 491 | if not args.no_chain: 492 | sys.stdout.write(chain) 493 | 494 | if __name__ == "__main__": # pragma: no cover 495 | main(sys.argv[1:]) 496 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PROJECT ABANDONED 2 | ----------------- 3 | 4 | **This project is outdated and orphaned. Please use selectively the acme_tiny method described in this readme for both shared hosting and dedicated hosting.** 5 | 6 | The **letsacme** script automates the process of getting a signed TLS/SSL certificate from Let's Encrypt using the ACME protocol. It will need to be run on your server and have **access to your private Let's Encrypt account key**. It gets both the certificate and the chain (CABUNDLE) and prints them on stdout unless specified otherwise. 7 | 8 | **PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT WITH YOUR PRIVATE LET'S ENCRYPT ACCOUNT KEY!** 9 | 10 | # Dependencies: 11 | 1. Python 12 | 2. openssl 13 | 14 | # How to use: 15 | 16 | If you just want to renew an existing certificate, you will only have to do Steps 4~6. 17 | 18 | **For shared servers/hosting:** Get only the certificate (step 1~4) by running the script on your server and then install the certificate with cpanel or equivalent control panels. If you don't want to go all technical about it and just want to follow a step by step process to get the certificate, then [this tutorial](https://neurobin.org/docs/web/letsacme/get-letsencrypt-certficate-for-shared-hosting/) may be the right choice for you. 19 | 20 | If you are on a cpanel hosting then [this tutorial](https://neurobin.org/docs/web/fully-automated-letsencrypt-integration-with-cpanel/) will help you automate the whole process. 21 | 22 | ## 1: Create a Let's Encrypt account private key (if you haven't already): 23 | You must have a public key registered with Let's Encrypt and use the corresponding private key to sign your requests. Thus you first need to create a key, which **letsacme** will use to register an account for you and sign all the following requests. 24 | 25 | If you don't understand what the account is for, then this script likely isn't for you. Please, use the official Let's Encrypt client. Or you can read the [howitworks](https://letsencrypt.com/howitworks/technology/) page under the section: **Certificate Issuance and Revocation** to gain a little insight on how certificate issuance works. 26 | 27 | The following command creates a 4096bit RSA (private) key: 28 | ```sh 29 | openssl genrsa 4096 > account.key 30 | ``` 31 | 32 | **Or use an existing Let's Encrypt key (privkey.pem from official Let's Encrypt client)** 33 | 34 | **Note:** **letsacme** is using the [PEM](https://tools.ietf.org/html/rfc1421) key format. 35 | 36 | ## 2: Create a certificate signing request (CSR) for your domains. 37 | 38 | The ACME protocol used by Let's Encrypt requires a CSR to be submitted to it (even for renewal). Once you have the CSR, you can use the same CSR as many times as you want. You can create a CSR from the terminal which will require you to create a domain key first, or you can create it from the control panel provided by your hosting provider (for example: cpanel). Whatever method you may use to create the CSR, the script doesn't require the domain key, only the CSR. 39 | 40 | **Note:** Domain key is not the private key you previously created. 41 | 42 | ### An example of creating CSR using openssl: 43 | The following command will create a 4096bit RSA (domain) key: 44 | ```sh 45 | openssl genrsa 4096 > domain.key 46 | ``` 47 | Now to create a CSR for a single domain: 48 | ```sh 49 | openssl req -new -sha256 -key domain.key -subj "/CN=example.com" > domain.csr 50 | ``` 51 | For multi-domain: 52 | ```sh 53 | openssl req -new -sha256 -key domain.key -subj "/C=US/ST=CA/O=MY Org/CN=example.com" -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(printf "[SAN]\nsubjectAltName=DNS:example.com,DNS:www.example.com,DNS:subdomain.example.com,DNS:www.subdomain.com")) -out domain.csr 54 | ``` 55 | It would probably be easier if you use [gencsr](https://github.com/neurobin/gencsr) to create CSR for multiple domains. 56 | 57 | ## 3: Prepare the challenge directory/s: 58 | **letsacme** provides two methods to prepare the challenge directory/s to complete the acme challenges. One of them is the same as [acme-tiny](https://github.com/diafygi/acme-tiny) (with `--acme-dir`), the other is quite different and simplifies things for users who doesn't have full access to their servers i.e for shared servers or shared hosting. 59 | 60 | **Whatever method you use, note that the challenge directory needs to be accessible with normal http on port 80.** 61 | 62 | Otherwise, you may get an **error message** like this one: 63 | ```sh 64 | Wrote file to /var/www/public_html/.well-known/acme-challenge/rgGoLnQ8VkBOPyXZn-PkPD-A3KH4_2biYVOxbrYRDuQ, but couldn't download http://example.com/.well-known/acme-challenge/rgGoLnQ8VkBOPyXZn-PkPD-A3KH4_2biYVOxbrYRDuQ 65 | ``` 66 | See section 3.3 on how you can work this around. 67 | 68 | ### 3.1: Using acme-dir as in acme-tiny (method 1): 69 | This method is the same as acme-tiny except the fact that letsacme prints a fullchain (cert+chain) on stdout (by default), while acme-tiny prints only the cert. If you provide `--no-chain` option (or equivalent `"NoChain": "False"` in config json) then the output will match that of acme-tiny. 70 | 71 | You can pass the acme-dir with `--acme-dir` option or define AcmeDir in json file like `"AcmeDir": "/path/to/acme/dir"`. 72 | 73 | This is how you can prepare an acme-dir: 74 | ```sh 75 | #make some challenge directory (modify to suit your needs) 76 | mkdir -p /var/www/challenges/ 77 | ``` 78 | Then you need to configure your server. 79 | 80 | Example for nginx (copied from acme-tiny readme): 81 | ```nginx 82 | server { 83 | listen 80; 84 | server_name yoursite.com www.yoursite.com; 85 | 86 | location /.well-known/acme-challenge/ { 87 | alias /var/www/challenges/; 88 | try_files $uri =404; 89 | } 90 | 91 | ...the rest of your config 92 | } 93 | ``` 94 | On apache2 you can set Aliases: 95 | ```apache 96 | Alias /.well-known/acme-challenge /var/www/challenges 97 | ``` 98 | 99 | **You can't use this method on shared server** as most of the shared server won't allow Aliases in AccessFile. For shared server/hosting, you should either use your site's document root as the destination for acme-challenges, or redirect the challenges to a different directory which has a valid and active URL and allows http file download without hindrance. Follow the steps mentioned in section 3.3 to do that. 100 | 101 | ### 3.2: Using Document Root of each of your sites (method 2): 102 | This method (using document root) is different than the acme-tiny script which this script is based on. Acme-tiny requires you to configure your server for completing the challenge; contrary to that, the intention behind this method is to not have to do anything at all on the server configuration until we finally get the certificate. Instead of setting up your server, you can provide the document root (or path to acme challenge directory) of each domain in a JSON format. It will create the *.well-known/acme-challenge* directory under document root (if not exists already) and put the temporary challenge files there. 103 | 104 | **For sites using Wordpress or framework like Laravel, the use of document root as the destination for challenge directory may or may not work. Use the method described in section 3.3 (or section 3.2 if you have full access to the server)** 105 | 106 | To pass document root for each of your domain/subdomain you will need create a json file like this: 107 | 108 | **config.json:** 109 | ```json 110 | { 111 | "example.com": { 112 | "DocumentRoot":"/var/www/public_html" 113 | }, 114 | "subdomain1.example.com": { 115 | "DocumentRoot":"/var/www/subdomain1" 116 | }, 117 | "subdomain2.example.com": { 118 | "DocumentRoot":"/var/www/subdomain2" 119 | }, 120 | "subdomain3.example.com": { 121 | "DocumentRoot":"/var/www/subdomain3" 122 | }, 123 | "subdomain4.example.com": { 124 | "DocumentRoot":"/var/www/subdomain4" 125 | }, 126 | "subdomain5.example.com": { 127 | "DocumentRoot":"/var/www/subdomain5" 128 | } 129 | } 130 | ``` 131 | 132 | **Note:** You can pass all other options in this config json too. see Options for more details. 133 | 134 |
135 | 136 | ### 3.3 What will you do if the challenge directory/document root doesn't allow normal http on port 80 (workaround): 137 | 138 | **The challenge directory must be accessible with normal http on port 80.** 139 | 140 | But this may not be possible all the time. So, what will you do? 141 | 142 | And also there's another scenario: if it happens that your site is behind a firewall or your WordPress site or site with Laravel or other tools and framework is preventing direct access to that challenge directory, what will you do? 143 | 144 | In the above cases most commonly you will be encountered with an error message like this: 145 | 146 | >Wrote file to /var/www/public_html/.well-known/acme-challenge/rgGoLnQ8VkBOPyXZn-PkPD-A3KH4_2biYVOxbrYRDuQ, but couldn't download http://example.com/.well-known/acme-challenge/rgGoLnQ8VkBOPyXZn-PkPD-A3KH4_2biYVOxbrYRDuQ 147 | 148 | This means what it **exactly means**, it can't access the challenge files on the URL. It is either being redirected in a weird way or being blocked. 149 | 150 | You can however work this around with an effective but peculiar way: 151 | 152 | > The basic logic is to redirect all requests to http://example.com/.well-know/acme-challenge/ to another address which permits http access on port 80 and you have access to it's document root (because the script needs to create challenge files there) through terminal. 153 | 154 | Create a subdomain (or use an existing one with no additional framework, just plain old http site). Check if the subdomain is accessible (by creating a simple html file inside). Create a directory named `challenge` inside it's document root (don't use `.well-known/acme-challenge` instead of `challenge`, it will create an infinite loop if this new subdomain also contains the following line of redirection code). And then redirect all *.well-know/acme-challenge* requests to all of the domains you want certificate for to this directory of this new subdomain. A mod_rewrite rule for apache2 would be (add it in the .htaccess file or whatever AccessFile you have): 155 | ```apache 156 | RewriteRule ^.well-known/acme-challenge/(.*)$ http://challenge.example.com/challenge/$1 [L,R=302] 157 | ## If you have any rule that redirects http to https, make sure this rule stays above that one 158 | ## To keep it simple, add this rule above all other rewrite rules. 159 | ``` 160 | And provide the challenge directory as acme-dir (not document root) by either `--acme-dir` option or defining `AcmeDir` json property in config json i.e `--acme-dir /var/www/challenge` or using global acme-dir definition in config json: 161 | 162 | ```json 163 | "AcmeDir": "/var/www/challenge" 164 | ``` 165 | **Do not install SSL on this site. If you do, at least do not redirect http to https, otherwise it won't work.** 166 | 167 | **Even though it's peculiar and a bit tedious, it is supposed to work with all the situations** as long as the subdomain is properly active. So if you want to move to this method instead of all the other methods available, that wouldn't be a bad idea at all. 168 | 169 | ## 4: Get a signed certificate: 170 | To get a signed certificate, all you need is the private key, the CSR, the JSON configuration file (optional) and a single line of python command (**one of the commands mentioned below**, choose according to your requirements). 171 | 172 | If you created a *config.json* (it contains all options) file: 173 | ```sh 174 | python letsacme.py --config-json ./config.json > ./fullchain.crt 175 | ``` 176 | If you didn't create the config.json file and want to use acme-dir, then: 177 | ```sh 178 | python letsacme.py --no-chain --account-key ./account.key --csr ./domain.csr --acme-dir /path/to/acme-dir > ./signed.crt 179 | ``` 180 | Notice the `--no-chain` option; if you omitted this option then you would get a fullchain (cert+chain). Also, you can get the chain, cert and fullchain separately: 181 | 182 | ```sh 183 | python letsacme.py --account-key ./account.key --csr ./domain.csr --acme-dir /path/to/acme-dir --cert-file ./signed.cert --chain-file ./chain.crt > ./fullchain.crt 184 | ``` 185 | This will create three files: **signed.crt** (the certificate), **chain.crt** (chain), **fullchain.crt** (fullchain). 186 | 187 | 188 | # 5: Install the certificate: 189 | This is an scope beyond the script. You will have to install the certificate manually and setup the server as it requires. 190 | 191 | An example for nginx (nginx requires the *fullchain.crt*): 192 | 193 | ```nginx 194 | server { 195 | listen 443; 196 | server_name example.com, www.example.com; 197 | 198 | ssl on; 199 | ssl_certificate /path/to/fullchain.crt; 200 | ssl_certificate_key /path/to/domain.key; 201 | ssl_session_timeout 5m; 202 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 203 | ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA; 204 | ssl_session_cache shared:SSL:50m; 205 | ssl_dhparam /path/to/server.dhparam; 206 | ssl_prefer_server_ciphers on; 207 | 208 | ...the rest of your config 209 | } 210 | ``` 211 | An example for apache2: 212 | ```apache 213 | 214 | ...other configurations 215 | SSLEngine on 216 | SSLCertificateKeyFile /path/to/domain.key 217 | SSLCertificateFile /path/to/signed.crt 218 | SSLCertificateChainFile /path/to/chain.crt 219 | 220 | ``` 221 | 222 | **For shared servers, it is possible to install the certificate with cpanel or equivalent control panels (if it's supported).** [See this link for how to install it with cpanel](https://neurobin.org/docs/web/installing-tls-ssl-certificate-using-cpanel/) 223 | 224 | # 6: Setup an auto-renew cron job: 225 | Let's Encrypt certificate only lasts for 90 days. So you need to renew it in a timely manner. You can setup a cron job to do this for you. An example monthly cron job: 226 | ```sh 227 | 0 0 1 * * /usr/bin/python /path/to/letsacme.py --account-key /path/to/account.key --csr /path/to/domain.csr --config-json /path/to/config.json --cert-file /path/to/signed.crt --chain-file /path/to/chain.crt > /path/to/fullchain.crt 2>> /var/log/letsacme.log && service apache2 restart 228 | ``` 229 | But the above code is not recommended as it only tries for once in a month. It may not be enough to renew the certificate on such few tries as they can be timed out due to heavy load or network failures or outage. Let's employ a little retry mechanism. First we need a dedicated script for this: 230 | ```sh 231 | #!/bin/sh 232 | while true;do 233 | if /usr/bin/python /path/to/letsacme.py --account-key /path/to/account.key \ 234 | --csr /path/to/domain.csr \ 235 | --acme-dir /path/to/acme-dir \ 236 | --cert-file /path/to/signed.crt \ 237 | --chain-file /path/to/chain.crt \ 238 | > /path/to/fullchain.crt \ 239 | 2>> /path/to/letsacme.log 240 | then 241 | # echo "Successfully renewed certificate" 242 | service apache2 restart 243 | break 244 | else 245 | sleep `tr -cd 0-9 /path/to/fullchain1.crt 2>> /var/log/letsacme.log 264 | ``` 265 | The above cron job runs the command once every day at a random time as it has to wait until perl gets its' sleep (max range 12 hours (43200s)). 266 | 267 | Instead of using the long command, it will be much more readable and easy to maintain if you put those codes into a script and call that script instead: 268 | ```sh 269 | /usr/bin/python /path/to/letsacme.py --account-key /path/to/account.key \ 270 | --csr /path/to/domain.csr \ 271 | --acme-dir /path/to/acme-dir \ 272 | --cert-file /path/to/signed1.crt \ 273 | --chain-file /path/to/chain1.crt \ 274 | > /path/to/fullchain1.crt \ 275 | 2>> /path/to/letsacme.log 276 | ``` 277 | cron: 278 | ```sh 279 | 0 12 * * * /usr/local/bin/perl -le 'sleep rand 43200' && /bin/sh /path/to/script 280 | ``` 281 | 282 | **Notice** that the names for the crt files are different (\*1.crt) and there's no server restart command. 283 | 284 | We are renewing the certificate every day but that doesn't mean we have to install it every day and restart the server along with it. We can graciously wait for 60 days and then install the certificate as the certificate (\*1.crt) is always renewed. We just need to overwrite the existing one with \*1.crt's. To do that you can set up another cron to overwrite old crt's with new ones and restart the server at a 60 day interval. 285 | ```sh 286 | 0 0 1 */2 * /bin/cp /old/crt/path/signed1.crt /old/crt/path/signed.crt && /bin/cp /old/crt/path/fullchain1.crt /old/crt/path/fullchain.crt && service apache2 restart 287 | ``` 288 | 289 | # Permissions: 290 | 291 | 1. **Challenge directory:** The script needs **write permission** to the challenge directory (document root or acme-dir). If writing into document root seems to be a security issue then you can work it around by creating the challenge directories first. If the challenge directory already exists it will only need permission to write to the challenge directory not the document root. The acme-dir method needs **write permission** to the directory specified by `--acme-dir`. 292 | 2. **Account key:** Save the *account.key* file to a secure location. **letsacme** only needs **read permission** to it. 293 | 3. **Domain key:** Save the *domain.key* file to a secure location. **letsacme** doesn't use this file. So **no permission** should be allowed for this file. 294 | 4. **Cert files:** Save the *signed.crt*, *chain.crt* and *fullchain.crt* in a secure location. **letsacme** needs **write permission** for these files as it will update these files in a timely basis. 295 | 5. **Config json:** Save it in a secure location. **letsacme** needs only **read permission** to this file. 296 | 297 | As you will want to secure yourself as much as you can and thus give as less permission as possible to the script, I suggest you create an extra user for this script and give that user write permission to the challenge directory and the cert files, and read permission to the private key (*account.key*) and the config file (*config.json*) and nothing else. 298 | 299 | # Options: 300 | 301 | Run it with `-h` flag to get help and view the options. 302 | ```sh 303 | python letsacme.py -h 304 | ``` 305 | It will show you all the available options that are supported by the script. These are the options currently supported: 306 | 307 | Command line option | Equivalent JSON | Details 308 | ---------- | ------------- | ------- 309 | `-h`, `--help` | N/A | show this help message and exit 310 | `--account-key PATH` | `"AccountKey": "PATH"` | Path to your Let's Encrypt account private key. 311 | `--csr PATH` | `"CSR": "PATH"` | Path to your certificate signing request. 312 | `--config-json PATH/JSON_STRING` | N/A |Configuration JSON string/file. 313 | `--acme-dir PATH` | `"AcmeDir": "PATH"` | Path to the .well-known/acme-challenge/ directory 314 | `--cert-file PATH` | `"CertFile": "PATH"` | File to write the certificate to. Overwrites if file exists. 315 | `--chain-file PATH` | `"ChainFile": "PATH"` | File to write the certificate to. Overwrites if file exists. 316 | `--quiet` | `"Quiet": "True/False"` | Suppress output except for errors. 317 | `--ca URL` | `"CA": "URL"` | Certificate authority, default is Let's Encrypt. 318 | `--no-chain` | `"NoChain": "True/False"` | Fetch chain (CABUNDLE) but do not print it on stdout. 319 | `--no-cert` | `"NoCert": "True/False"` | Fetch certificate but do not print it on stdout. 320 | `--force` | `"Force: "True/False"` | Apply force. If a directory is found inside the challenge directory with the same name as challenge token (paranoid), this option will delete the directory and it's content (Use with care). 321 | `--test` | `"Test": "True/False"` | Get test certificate (Invalid certificate). This option won't have any effect if --ca is passed. 322 | `--version` | N/A | Show version info. 323 | 324 | 325 | # Passing options: 326 | 327 | You can either pass the options directly as command line parameters when you run the script or save the options in a configuration json file and pass the path to the configuration file with `--config-json`. The `--config-json` option can also take a raw json string instead of a file path. 328 | 329 | If you want to use the acme-dir method, then there's no need to use the config json unless you want to keep your options together, saved somewhere and shorten the command that will be run. But if you use document root as the challenge directory, it is a must to define them in a config json. 330 | 331 | **Most of the previous examples show how you can use it without config json**, and this is how you use letsacme with a configuration json: 332 | 333 | ```sh 334 | python letsacme.py --config-json /path/to/config.json 335 | ``` 336 | Now letsacme will take all options from that configuration file. `--config-json` can also take a raw JSON string. This may come in handy if you are writing a script and you don't want to create a separate file to put the JSON. In that case, you can put it in a variable and pass the variable as the parameter: 337 | 338 | ```sh 339 | python letsacme.py --config-json "$conf_json" 340 | ``` 341 | 342 |
343 | 344 | # Advanced info about the configuration file: 345 | 346 | 1. Arguments passed in the command line takes priority over properties/options defined in the JSON file. 347 | 2. DocumentRoot and AcmeDir can be defined both globally and locally (per domain basis). If any local definition isn't found, then global definition will be searched for. 348 | 3. AcmeDir takes priority over DocumentRoot, and local definition takes priority over global definition. 349 | 4. The properties (keys/options) are case sensitive. 350 | 5. **True** **False** values are case insensitive. 351 | 6. If the challenge directory (AcmeDir or DocumentRoot) for non-www site isn't defined, then a definition for it's www version will be searched for and vice versa. If both are defined, they are taken as is. 352 | 353 | A full fledged JSON configuration file: 354 | ```json 355 | { 356 | "example.com": { 357 | "DocumentRoot":"/var/www/public_html", 358 | "_comment":"Global defintion AcmeDir won't be used as DocumentRoot is defined" 359 | }, 360 | "subdomain1.example.com": { 361 | "DocumentRoot":"/var/www/subdomain1", 362 | "_comment":"Local defintion of DocumentRoot" 363 | }, 364 | "www.subdomain2.example.com": { 365 | "AcmeDir":"/var/www/subdomain2", 366 | "_comment":"Local defintion of AcmeDir" 367 | }, 368 | "subdomain3.example.com": { 369 | "AcmeDir":"/var/www/subdomain3" 370 | }, 371 | "subdomain4.example.com": { 372 | "AcmeDir":"/var/www/subdomain4" 373 | }, 374 | "www.subdomain5.example.com": { 375 | "AcmeDir":"/var/www/subdomain5" 376 | }, 377 | "AccountKey":"account.key", 378 | "CSR": "domain.csr", 379 | "AcmeDir":"/var/www/html", 380 | "DocumentRoot":"/var/www/public_html", 381 | "_comment":"Global definition of DocumentRoot and AcmeDir", 382 | "CertFile":"domain.crt", 383 | "ChainFile":"chain.crt", 384 | "CA":"", 385 | "__comment":"For CA default value will be used. please don't change this option. Use the Test property if you want the staging api.", 386 | "NoChain":"False", 387 | "NoCert":"False", 388 | "Test":"False", 389 | "Force":"False", 390 | "Quiet":"False" 391 | } 392 | ``` 393 | Another full-fledged JSON file using only a single AcmeDir as challenge directory for every domain: 394 | 395 | ```json 396 | { 397 | "AcmeDir":"/var/www/challenge", 398 | "_comment":"Global definition of AcmeDir", 399 | "AccountKey":"account.key", 400 | "CSR": "domain.csr", 401 | "CertFile":"domain.crt", 402 | "ChainFile":"chain.crt", 403 | "CA":"", 404 | "__comment":"For CA default value will be used. please don't change this option. Use the Test property if you want the staging api.", 405 | "NoChain":"False", 406 | "NoCert":"False", 407 | "Test":"False", 408 | "Force":"False", 409 | "Quiet":"False" 410 | } 411 | ``` 412 | The above JSON is exactly for the scenario where you are using it in acme-tiny compatible mode or using the 3.3 workaround for shared servers. 413 | 414 | For 3.3 workaround, change the AcmeDir to `/var/www/challenge/challenge` where `/var/www/challenge` is the document root of your dedicated http site (challenge.example.com); in that way you can use the exact redirection code mentioned in section 3.3. 415 | 416 | 417 | # Test suit: 418 | The **test** directory contains a simple Bash script named **check.sh**. It creates two temporary local sites (in */tmp*) and exposes them publicly (careful) on Internet using ngrok, then creates *account key*, *dom.key*, *CSR* for these two sites and gets the certificate and chain. The certificates are retrieved twice: first, by using Document Root and second, by using Acme Dir. 419 | 420 | Part of this script requires root privilege (It needs to create custom local sites and configure/restart apache2). 421 | 422 | This script depends on various other scripts/tools. An **inst.sh** file is provided to install/download the dependencies. 423 | 424 | **Dependencies:** 425 | 426 | 1. Debian based OS 427 | 2. Apache2 server 428 | 3. jq 429 | 4. ngrok 430 | 5. [gencsr](https://github.com/neurobin/gencsr) \[included\] 431 | 5. [lampi](https://github.com/neurobin/lampi) \[included\] (This script creates the local sites) 432 | 433 | You can get the dependencies by running the *inst.sh* script: 434 | 435 | ```sh 436 | chmod +x ./test/inst.sh 437 | ./test/inst.sh 438 | ``` 439 | If you already have Apache2 server installed, then just install `jq`. Then download and unzip [ngrok](wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip -O ngrok-stable-linux-amd64.zip) (see *inst.sh* file) in the *test* directory. 440 | 441 | After getting the dependencies, you can run **check.sh** to perform the test: 442 | 443 | ```sh 444 | chmod +x ./test/check.sh 445 | ./test/check.sh 446 | ``` 447 | 448 | **Do not run the ./test/travis_check.sh on your local machine.** It's written for [travis build](https://travis-ci.org/neurobin/letsacme) only and contains 449 | unguarded code that can harm your system. 450 | 451 | If you don't want to perform the test yourself but just want to see the outcome, then visit [travis build page for letsacme](https://travis-ci.org/neurobin/letsacme). Travis test uses apache2 Alias in AcmeDir method while the local test uses redirect through .htaccess (the 3.3 workaround). 452 | 453 | **Both tests perform:** 454 | 455 | 1. A test defining document roots in config json. 456 | 2. A test using acme-dir without config json. 457 | --------------------------------------------------------------------------------