From cb81ac1e06c3b95fd2af366d8017c3cf1d35cc9f Mon Sep 17 00:00:00 2001 From: Lucian Cristian Date: Sun, 29 Sep 2019 17:33:56 +0300 Subject: [PATCH] uacme: add package lightweight client for the RFC8555 ACMEv2 protocol, written in plain C code with minimal dependencies (libcurl and one of GnuTLS, OpenSSL or mbedTLS). Signed-off-by: Lucian Cristian --- net/uacme/Makefile | 93 +++++++++ net/uacme/files/acme.config | 14 ++ net/uacme/files/acme.init | 35 ++++ net/uacme/files/run.sh | 394 ++++++++++++++++++++++++++++++++++++ 4 files changed, 536 insertions(+) create mode 100644 net/uacme/Makefile create mode 100644 net/uacme/files/acme.config create mode 100644 net/uacme/files/acme.init create mode 100644 net/uacme/files/run.sh diff --git a/net/uacme/Makefile b/net/uacme/Makefile new file mode 100644 index 000000000..60fbd561b --- /dev/null +++ b/net/uacme/Makefile @@ -0,0 +1,93 @@ +# +# Copyright (C) 2019 Lucian Cristian +# +# This is free software, licensed under the GNU General Public License v2. +# See /LICENSE for more information. +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=uacme +PKG_VERSION:=1.0.20 +PKG_RELEASE:=1 + +PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz +PKG_SOURCE_URL:=https://codeload.github.com/ndilieto/uacme/tar.gz/upstream/$(PKG_VERSION)? +PKG_HASH:=c9106e166156685fcf9f164f0b2935cf680251a653f460b04da26a290e946bf9 + +PKG_MAINTAINER:=Lucian Cristian +PKG_LICENSE:=GPL-3.0-or-later +PKG_LICENSE_FILES:=COPYING + +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-upstream-$(PKG_VERSION) +PKG_INSTALL:=1 +PKG_BUILD_PARALLEL:=1 + +PKG_CONFIG_DEPENDS:= \ + CONFIG_LIBCURL_GNUTLS \ + CONFIG_LIBCURL_MBEDTLS \ + CONFIG_LIBCURL_OPENSSL \ + CONFIG_LIBCURL_WOLFSSL \ + CONFIG_LIBCURL_NOSSL + +include $(INCLUDE_DIR)/package.mk + +define Package/uacme + SECTION:=net + CATEGORY:=Network + DEPENDS:=+libcurl +LIBCURL_WOLFSSL:libmbedtls + TITLE:=lightweight client for ACMEv2 + URL:=https://github.com/ndilieto/uacme +endef + +define Package/uacme/Default/description + lightweight client for the RFC8555 ACMEv2 protocol, written in plain C code + with minimal dependencies (libcurl and one of GnuTLS, OpenSSL or mbedTLS). + The ACMEv2 protocol allows a Certificate Authority (https://letsencrypt.org + is a popular one) and an applicant to automate the process of verification + and certificate issuance. The protocol also provides facilities for other + certificate management functions, such as certificate revocation. +endef + +define Package/uacme/config +if PACKAGE_uacme && LIBCURL_WOLFSSL + comment "libcurl uses WolfSSL; uacme will install mbedtls" + comment "Choose another SSL lib in libcurl to avoid this" +endif +if PACKAGE_uacme && LIBCURL_NOSSL + comment "libcurl is built without ssl; uacme needs ssl support" + comment "Choose another SSL lib in libcurl to avoid this" +endif +endef +TARGET_LDFLAGS += -Wl,--gc-sections,--as-needed + +CONFIGURE_ARGS+= \ + --disable-maintainer-mode \ + --disable-docs \ + $(if $(CONFIG_LIBCURL_GNUTLS),--with-gnutls --without-mbedtls --without-openssl,) \ + $(if $(CONFIG_LIBCURL_MBEDTLS),--without-gnutls --with-mbedtls --without-openssl,) \ + $(if $(CONFIG_LIBCURL_OPENSSL),--without-gnutls --without-mbedtls --with-openssl,) \ + $(if $(CONFIG_LIBCURL_WOLFSSL),--without-gnutls --with-mbedtls --without-openssl,) + +define Package/uacme/conffiles +/etc/config/acme +/etc/acme +endef + +define Package/uacme/install + $(INSTALL_DIR) \ + $(1)/usr/sbin \ + $(1)/etc/acme \ + $(1)/etc/config \ + $(1)/etc/init.d \ + $(1)/usr/share/uacme + + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/uacme $(1)/usr/sbin/uacme + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/share/uacme/uacme.sh $(1)/usr/share/uacme/ + $(SED) '/^CHALLENGE_PATH=/d' $(1)/usr/share/uacme/uacme.sh + $(INSTALL_CONF) ./files/acme.config $(1)/etc/config/acme + $(INSTALL_BIN) ./files/run.sh $(1)/usr/share/uacme/run-uacme + $(INSTALL_BIN) ./files/acme.init $(1)/etc/init.d/acme +endef + +$(eval $(call BuildPackage,uacme)) diff --git a/net/uacme/files/acme.config b/net/uacme/files/acme.config new file mode 100644 index 000000000..8846d12e8 --- /dev/null +++ b/net/uacme/files/acme.config @@ -0,0 +1,14 @@ +config acme + option state_dir '/etc/acme' + option account_email 'email@example.org' + option debug 0 + +config cert 'example' + option enabled 0 + option use_staging 1 + option keylength 2048 + option update_uhttpd 1 + option update_nginx 1 + option update_haproxy 1 + option webroot "/www/.well-known/acme-challenge" + list domains example.org diff --git a/net/uacme/files/acme.init b/net/uacme/files/acme.init new file mode 100644 index 000000000..f32a6ad18 --- /dev/null +++ b/net/uacme/files/acme.init @@ -0,0 +1,35 @@ +#!/bin/sh /etc/rc.common + +USE_PROCD=1 + +START=50 +SCRIPT=/usr/share/uacme/run-uacme + +start_service() +{ + procd_open_instance + procd_set_param command $SCRIPT + procd_set_param file /etc/config/acme + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +reload_service() { + rc_procd start_service "$@" + return 0 +} + +stop_service() { + return 0 +} + +boot() { + touch "/var/run/uacme_boot" + start +} + +service_triggers() +{ + procd_add_reload_trigger acme +} diff --git a/net/uacme/files/run.sh b/net/uacme/files/run.sh new file mode 100644 index 000000000..6998e4a20 --- /dev/null +++ b/net/uacme/files/run.sh @@ -0,0 +1,394 @@ +#!/bin/sh +# Wrapper for uacme to work on openwrt. +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Initial Author: Toke Høiland-Jørgensen +# Adapted for uacme: Lucian Cristian + +CHECK_CRON=$1 + +#check for installed packages, for now, support only one +if [ -e "/usr/lib/acme/acme.sh" ]; then + ACME=/usr/lib/acme/acme.sh + APP=acme +elif [ -e "/usr/sbin/uacme" ]; then + ACME=/usr/sbin/uacme + HPROGRAM=/usr/share/uacme/uacme.sh + APP=uacme +else + echo "Please install ACME or uACME package" + return 1 +fi + +export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +export NO_TIMESTAMP=1 + +UHTTPD_LISTEN_HTTP= +STATE_DIR='/etc/acme' +STAGING_STATE_DIR='/etc/acme/staging' + +ACCOUNT_EMAIL= +DEBUG=0 +NGINX_WEBSERVER=0 +UPDATE_NGINX=0 +UPDATE_UHTTPD=0 +UPDATE_HAPROXY=0 + +. /lib/functions.sh + +check_cron() +{ + [ -f "/etc/crontabs/root" ] && grep -q '/etc/init.d/acme' /etc/crontabs/root && return + echo "0 0 * * * /etc/init.d/acme start" >> /etc/crontabs/root + /etc/init.d/cron start +} + +log() +{ + logger -t $APP -s -p daemon.info "$@" +} + +err() +{ + logger -t $APP -s -p daemon.err "$@" +} + +debug() +{ + [ "$DEBUG" -eq "1" ] && logger -t $APP -s -p daemon.debug "$@" +} + +get_listeners() { + local proto rq sq listen remote state program + netstat -nptl 2>/dev/null | while read proto listen program; do + case "$proto#$listen#$program" in + tcp#*:80#[0-9]*/*) echo -n "${program%% *} " ;; + esac + done +} + +pre_checks() +{ + main_domain="$1" + + log "Running pre checks for $main_domain." + + listeners="$(get_listeners)" + + debug "port80 listens: $listeners" + + for listener in $(get_listeners); do + pid="${listener%/*}" + cmd="${listener#*/}" + + case "$cmd" in + uhttpd) + debug "Found uhttpd listening on port 80" + if [ "$APP" = "acme" ]; then + UHTTPD_LISTEN_HTTP=$(uci get uhttpd.main.listen_http) + if [ -z "$UHTTPD_LISTEN_HTTP" ]; then + err "$main_domain: Unable to find uhttpd listen config." + err "Manually disable uhttpd or set webroot to continue." + return 1 + fi + uci set uhttpd.main.listen_http='' + uci commit uhttpd || return 1 + if ! /etc/init.d/uhttpd reload ; then + uci set uhttpd.main.listen_http="$UHTTPD_LISTEN_HTTP" + uci commit uhttpd + return 1 + fi + fi + ;; + nginx*) + debug "Found nginx listening on port 80" + NGINX_WEBSERVER=1 + if [ "$APP" = "acme" ]; then + local tries=0 + while grep -sq "$cmd" "/proc/$pid/cmdline" && kill -0 "$pid"; do + /etc/init.d/nginx stop + if [ $tries -gt 10 ]; then + debug "Can't stop nginx. Terminating script." + return 1 + fi + debug "Waiting for nginx to stop..." + tries=$((tries + 1)) + sleep 1 + done + fi + ;; + "") + err "Nothing listening on port 80." + err "Standalone mode not supported, setup uhttpd or nginx" + return 1 + ;; + *) + err "$main_domain: unsupported (apache/haproxy?) daemon is listening on port 80." + err "if webroot is set on your current webserver comment line 132 (return 1) from this script." + return 1 + ;; + esac + done + + iptables -I input_rule -p tcp --dport 80 -j ACCEPT -m comment --comment "ACME" || return 1 + debug "v4 input_rule: $(iptables -nvL input_rule)" + if [ -e "/usr/sbin/ip6tables" ]; then + ip6tables -I input_rule -p tcp --dport 80 -j ACCEPT -m comment --comment "ACME" || return 1 + debug "v6 input_rule: $(ip6tables -nvL input_rule)" + fi + return 0 +} + +post_checks() +{ + log "Running post checks (cleanup)." + # The comment ensures we only touch our own rules. If no rules exist, that + # is fine, so hide any errors + iptables -D input_rule -p tcp --dport 80 -j ACCEPT -m comment --comment "ACME" 2>/dev/null + if [ -e "/usr/sbin/ip6tables" ]; then + ip6tables -D input_rule -p tcp --dport 80 -j ACCEPT -m comment --comment "ACME" 2>/dev/null + fi + if [ -e /etc/init.d/uhttpd ] && [ "$UPDATE_UHTTPD" -eq 1 ]; then + uci commit uhttpd + /etc/init.d/uhttpd reload + log "Restarting uhttpd..." + fi + + if [ -e /etc/init.d/nginx ] && ( [ "$NGINX_WEBSERVER" -eq 1 ] || [ "$UPDATE_NGINX" -eq 1 ]; ); then + NGINX_WEBSERVER=0 + /etc/init.d/nginx restart + log "Restarting nginx..." + fi + + if [ -e /etc/init.d/haproxy ] && [ "$UPDATE_HAPROXY" -eq 1 ]; then + /etc/init.d/haproxy restart + log "Restarting haproxy..." + fi +} + +err_out() +{ + post_checks + exit 1 +} + +int_out() +{ + post_checks + trap - INT + kill -INT $$ +} + +is_staging() +{ + local main_domain="$1" + + grep -q "acme-staging" "$STATE_DIR/$main_domain/${main_domain}.conf" + return $? +} + +issue_cert() +{ + local section="$1" + local acme_args= + local debug= + local enabled + local use_staging + local update_uhttpd + local update_nginx + local update_haproxy + local keylength + local domains + local main_domain + local failed_dir + local webroot + local dns + local ret + local staging= + local HOOK= + + config_get_bool enabled "$section" enabled 0 + config_get_bool use_staging "$section" use_staging + config_get_bool update_uhttpd "$section" update_uhttpd + config_get_bool update_nginx "$section" update_nginx + config_get_bool update_haproxy "$section" update_haproxy + config_get domains "$section" domains + config_get keylength "$section" keylength + config_get webroot "$section" webroot + config_get dns "$section" dns + + UPDATE_NGINX=$update_nginx + UPDATE_UHTTPD=$update_uhttpd + UPDATE_HAPROXY=$update_haproxy + + [ "$enabled" -eq "1" ] || return + + if [ "$APP" = "uacme" ]; then + [ "$DEBUG" -eq "1" ] && debug="--verbose --verbose" + elif [ "$APP" = "acme" ]; then + [ "$DEBUG" -eq "1" ] && acme_args="$acme_args --debug" + fi + [ "$use_staging" -eq "1" ] && STATE_DIR="$STAGING_STATE_DIR" && staging="--staging" + + set -- $domains + main_domain=$1 + + [ -n "$webroot" ] || [ -n "$dns" ] || pre_checks "$main_domain" || return 1 + + log "Running $APP for $main_domain" + + if [ "$APP" = "uacme" ]; then + if [ ! -f "$STATE_DIR/private/key.pem" ]; then + log "Create a new ACME account with email $ACCOUNT_EMAIL use staging=$use_staging" + $ACME $debug --confdir "$STATE_DIR" $staging --yes new $ACCOUNT_EMAIL + fi + + if [ -f "$STATE_DIR/$main_domain/cert.pem" ]; then + log "Found previous cert config, use staging=$use_staging. Issuing renew." + export CHALLENGE_PATH="$webroot" + $ACME $debug --confdir "$STATE_DIR" $staging --never-create issue $domains --hook=$HPROGRAM && ret=0 || ret=1 + post_checks + return $ret + fi + fi + if [ "$APP" = "acme" ]; then + handle_credentials() { + local credential="$1" + eval export "$credential" + } + config_list_foreach "$section" credentials handle_credentials + + if [ -e "$STATE_DIR/$main_domain" ]; then + if [ "$use_staging" -eq "0" ] && is_staging "$main_domain"; then + log "Found previous cert issued using staging server. Moving it out of the way." + mv "$STATE_DIR/$main_domain" "$STATE_DIR/$main_domain.staging" + else + log "Found previous cert config. Issuing renew." + $ACME --home "$STATE_DIR" --renew -d "$main_domain" "$acme_args" && ret=0 || ret=1 + post_checks + return $ret + fi + fi + fi + + acme_args="$acme_args --bits $keylength" + acme_args="$acme_args $(for d in $domains; do echo -n " $d "; done)" + if [ "$APP" = "acme" ]; then + [ -n "$ACCOUNT_EMAIL" ] && acme_args="$acme_args --accountemail $ACCOUNT_EMAIL" + [ "$use_staging" -eq "1" ] && acme_args="$acme_args --staging" + fi + if [ -n "$dns" ]; then +#TO-DO + if [ "$APP" = "acme" ]; then + log "Using dns mode" + acme_args="$acme_args --dns $dns" + else + log "Using dns mode, dns-01 is not wrapped yet" + return 1 +# uacme_args="$uacme_args --dns $dns" + fi + elif [ -z "$webroot" ]; then + if [ "$APP" = "acme" ]; then + log "Using standalone mode" + acme_args="$acme_args --standalone --listen-v6" + else + log "Standalone not supported by $APP" + return 1 + fi + else + if [ ! -d "$webroot" ]; then + err "$main_domain: Webroot dir '$webroot' does not exist!" + post_checks + return 1 + fi + log "Using webroot dir: $webroot" + if [ "$APP" = "uacme" ]; then + export CHALLENGE_PATH="$webroot" + else + acme_args="$acme_args --webroot $webroot" + fi + fi + + if [ "$APP" = "uacme" ]; then + workdir="--confdir" + HOOK="--hook=$HPROGRAM" + else + workdir="--home" + fi + if ! $ACME $debug $workdir "$STATE_DIR" $staging issue $acme_args $HOOK; then + failed_dir="$STATE_DIR/${main_domain}.failed-$(date +%s)" + err "Issuing cert for $main_domain failed. Moving state to $failed_dir" + [ -d "$STATE_DIR/$main_domain" ] && mv "$STATE_DIR/$main_domain" "$failed_dir" + [ -d "$STATE_DIR/private/$main_domain" ] && mv "$STATE_DIR/private/$main_domain" "$failed_dir" + post_checks + return 1 + fi + + if [ -e /etc/init.d/uhttpd ] && [ "$update_uhttpd" -eq "1" ]; then + if [ "$APP" = "uacme" ]; then + uci set uhttpd.main.key="$STATE_DIR/private/${main_domain}/key.pem" + uci set uhttpd.main.cert="$STATE_DIR/${main_domain}/cert.pem" + else + uci set uhttpd.main.key="$STATE_DIR/${main_domain}/${main_domain}.key" + uci set uhttpd.main.cert="$STATE_DIR/${main_domain}/fullchain.cer" + fi + # commit and reload is in post_checks + fi + + if [ -e /etc/init.d/nginx ] && [ "$update_nginx" -eq "1" ]; then + if [ "$APP" = "uacme" ]; then + sed -i "s#ssl_certificate\ .*#ssl_certificate $STATE_DIR/${main_domain}/cert.pem;#g" /etc/nginx/nginx.conf + sed -i "s#ssl_certificate_key\ .*#ssl_certificate_key $STATE_DIR/private/${main_domain}/key.pem;#g" /etc/nginx/nginx.conf + else + sed -i "s#ssl_certificate\ .*#ssl_certificate $STATE_DIR/${main_domain}/fullchain.cer;#g" /etc/nginx/nginx.conf + sed -i "s#ssl_certificate_key\ .*#ssl_certificate_key $STATE_DIR/${main_domain}/${main_domain}.key;#g" /etc/nginx/nginx.conf + fi + # commit and reload is in post_checks + fi + + if [ -e /etc/init.d/haproxy ] && [ "$update_haproxy" -eq 1 ]; then + if [ "$APP" = "uacme" ]; then + cat $STATE_DIR/${main_domain}/cert.pem $STATE_DIR/private/${main_domain}/key.pem > $STATE_DIR/${main_domain}/full_haproxy.pem + else + cat $STATE_DIR/${main_domain}/fullchain.cer $STATE_DIR/${main_domain}/${main_domain}.key > $STATE_DIR/${main_domain}/full_haproxy.pem + fi + fi + + post_checks +} + +load_vars() +{ + local section="$1" + + STATE_DIR=$(config_get "$section" state_dir) + STAGING_STATE_DIR=$STATE_DIR/staging + ACCOUNT_EMAIL=$(config_get "$section" account_email) + DEBUG=$(config_get "$section" debug) +} + +check_cron +[ -n "$CHECK_CRON" ] && exit 0 +[ -e "/var/run/acme_boot" ] && rm -f "/var/run/acme_boot" && exit 0 + +config_load acme +config_foreach load_vars acme + +if [ -z "$STATE_DIR" ] || [ -z "$ACCOUNT_EMAIL" ]; then + err "state_dir and account_email must be set" + exit 1 +fi + +[ -d "$STATE_DIR" ] || mkdir -p "$STATE_DIR" +[ -d "$STAGING_STATE_DIR" ] || mkdir -p "$STAGING_STATE_DIR" + +trap err_out HUP TERM +trap int_out INT + +config_foreach issue_cert cert + +exit 0