pax_global_header00006660000000000000000000000064143565211330014515gustar00rootroot0000000000000052 comment=d3428cff2614cbd31d91484e7c7bd188db53bcf1 acmetool-0.2.2/000077500000000000000000000000001435652113300133215ustar00rootroot00000000000000acmetool-0.2.2/.drone.yml000066400000000000000000000005771435652113300152420ustar00rootroot00000000000000pipeline: #doc: # image: asciidoctor/docker-asciidoctor # secrets: [ rsync_password ] # commands: # - apk add --no-cache libxslt docbook-xsl rsync # - "(cd _doc/guide && make && make deploy; )" test: image: golang:latest commands: - export GOPATH=/drone - export CGO_ENABLED=0 - export PEBBLE_VA_ALWAYS_VALID=1 - .drone/script acmetool-0.2.2/.drone/000077500000000000000000000000001435652113300145065ustar00rootroot00000000000000acmetool-0.2.2/.drone/script000077500000000000000000000004111435652113300157340ustar00rootroot00000000000000#!/usr/bin/env bash set -euxo pipefail ACMEAPI="$GOPATH/src/gopkg.in/hlandau/acmeapi.v2" go get ./... github.com/letsencrypt/pebble/cmd/pebble go install ./... github.com/letsencrypt/pebble/cmd/pebble "$ACMEAPI/.drone/with-pebble" go test -tags integration ./... acmetool-0.2.2/.gitignore000066400000000000000000000000761435652113300153140ustar00rootroot00000000000000acmetool _doc/guide/out _doc/guide/tmp _doc/guide/docbook-xsl acmetool-0.2.2/.travis.yml000066400000000000000000000102501435652113300154300ustar00rootroot00000000000000language: go go: - 1.x addons: hosts: - dom1.acmetool-test.devever.net - dom2.acmetool-test.devever.net - boulder - boulder-mysql - boulder-rabbitmq - publisher.boulder - ra.boulder - ca.boulder - va.boulder - sa.boulder apt: packages: - lsb-release - gcc - libssl-dev - libffi-dev - ca-certificates - rsyslog - libcap-dev - gcc-multilib - libc6-dev-i386 - libcap-dev:i386 - debhelper - devscripts - realpath - dput - gnupg - lintian - ncftp - rpm - fakeroot - jq mariadb: "10.1" sudo: false services: - rabbitmq before_install: - source ./.travis/before_install install: - go get -v -t ./... script: - source ./.travis/script after_success: - source ./.travis/after_success before_cache: - source ./.travis/before_cache env: global: # GITHUB_TOKEN for automatic releases - secure: "OA/Trkip03Ee3145oxrbHv3oM7dFpoX2h3y65CzyecQ2v8X4/l5pOwyMiJei5i20zm+QrK0iP9JttbDR9hY71d1DoxMXRGW0YHGFEutUQLZFpkPHLv7klSq8RjRGzpusSaxAtpEF27ZS+7NU42awYynWDzVsK4cglH9CimrS1glr2lKA5bXucqFROlqbI5GzXEdZJXhdGlKZWQWo83Hwe8JTwvIN8xRn5xZ33yxeMDl6SgQ3UhEs6zmsAQphGZ1pNcQaPjtyFtwEBeVQCsYW0loo8gUyjsfippSfGciu+g1J6sGVBj3HxGWWKmMa7lMaCEpL5CUKVcT2WH+LefYLHX5ZkyK8EQwt8QzrO1+X268+SulbWu2rf9SFQlLgoazIa8N8qfd8wVlo6Z3Jiy5YNHhHImMRYtgh5q3lo/5COUrPSgPBx4+VdciuMLxVYw96lTrPcMd4/J2gVYAf7f3AXeOpi/zF0T1WyD/64X0xKquYrbBzGbrEH4EM68vXQBiK5Q2sAEwhMUZNhgAqlKRzpqQoe/Cdx/Stm6cuFt6r87TbJfYiHGCZehveASWwH/Nk1HogOXjv/iVikxOqUiuqy0Q7GLPuFdcAGuLjqxS3wmdN1pBEGVqtSKA/3xrJptKlniz6+1hWr+H1ttTRTgok6ViX/POf+CW11VsfVo7qjyc=" # PPA_ENCRYPTION_ID - secure: "oYuMlIP0jJZpvw1V6HKcieHW/HcYX2X+5znZ7lLcroyz3uW8ZtdRo0mDBFmSJuxpxWA/6uNdB/ReV5hhSBGM+XsIB04FAhgp6dOOT9Z7ncE92d4SBkofYh0Le7gX/2DbtsDXBWJt8RLrCbnh/b7Nu51XXELu4vFPrp9RB28iYiCZqJxnEFf/4XMoWsfV/qUL7xaa54KC3Fhmyx5TpTtneJemhkPHc91z2SFv/v//QON6h/HZla5jgu0Ncxm6sCzGvLI6Rp4UGT1x0jifzqJ4WwCOvLCdHwy2KOq0hJFrRybfgWgo8o36CT7uTmisanWNvI/kQMZr/WqvRP7+OXBrA9dnGX6TUpHW+nigq+AopIjAWkshKUZDL53oMl3zWUdryD36fjxSYnxHo4I/6ocoZFRCh/hSClLwNvDyjsugqQhBY6gUSlFItHyubdFV8L5r1ehhwafE6Mz9OqqVZhW3LAlUOhvKruv8WA7gGKYc2IwRNRCql/Glun7OZk2JB2SuwJnNCn63HqAAs1QMWHaHrFCeGLj8GqZM0P2dNXYfS2M/g1691l/IYtQLwNFCLmzBEdkNF2uytoqq+VGwZSx6waxCybWwI9selPjvFrWB9dk3WVjiDmg2g1qZshr0jPLaCBC5imw0oSobjV0lJefANeTsmrX6PAZlTbLZhjvclIg=" # PPA_ENCRYPTION_PASS - secure: "Edr/h71sDFi2aXxICO3Ij5twLl/83HEwTgWfQ6/dJ7BcavjONTDyzB8cNQ0dGjlljujtbyyoD0+89Wu5pVotkv49JUZpQoWOJdn/9kyxFi9u61cpABSZvU/Sr1pWkOkDra7oAxgcJTAwNg5j1OVJ3+wfxJGGRVTotqPXc+hpIKx6z7jKR22D0Adz4uu1hWzRMdw8Qp8opqBJG2YHwvIF51U/Ztz4FcNwq1LJ1kdZ5YJYvU4SG6zm9+Q2XdjNQivLPuMdNL+s5Ik6J8Iiftu/OvxsSdfPClxyg0r8VCnoM8vpPAJc0BAOo6FBwUFLHfhFkUHUuLtZR/gyh5zkTd7fhRvdM/Sc94Dd9r2PeN8Jh5sTpn5a8/Qyhq/JItjcuRBB0Ysl4cZR81eIvPMeW4R3cnZ5mTA3rOpYjswiWAxBvJ6ZCOmGbtDG3lTkMUZ8Po6DmTqXMRRfWa/Nsuju5360UC65Q7mmHZx+hOTgeDw1LlMEhcG+ac2QH/FbnVM/SnRsYw+y5QORWJlFMcqPCwsGEVD2FxkuxX/tOtbIdyyBvQNEbdx+3/NpmwmUnQgH0v4i0o6rlQ65ETw6CdMNt9P+RuhRvrisbDvm/lwwfPT2IJenElB6Xu3Xz/i2WbAty92XJYfxpiIz1Rpivfu89OsyqKsMKzmhOqSfq6W2QxPuW8k=" # COPR_LOGIN_TOKEN expires=2018-08-11 # format: $login:$token - secure: "dSUWpyqzr8bncxjzMr4yY4BEx0rjYVpRE+Iqp9goltxGH7LjlNYFqfQPmBxzz4M/4qz9ncUdKCUrhZFq3YiWhHayFDiSDoc9Cy8C6Mdnd7a+/vpseDe5sdmXzaKFNh5qMItldv3pKbvWy+xmx2+1vcdnBr7O1zTmM9MSL3mJH3z+IMj10MAvAwYGITCD3FRP6PPJqXd9JzxpkZjcndZCu1XMIuaT3+NfFT1uVoR4CbUhG6UvdxmjYiwZ+TV1gCNLwx1hj61MtsKh/46VP00lUH+c7ziD6Pv8RYknITQChFxhOLQT0S0BLzekHBxV7xpN3rkCiW6+Mv3UvpxuvR1Mz6tDV21r+q0M7x1IiNFSR5PZsGq+kMANqX1X/B3/7GQkA7Z8x7/T7U8GONPNkXHH1mlzvMSewx5pJXxKr7cb8Th1+IIepbeLsWsdpM/YFnUbk2Vbf/Ic118bTjPGO/fM/WLaHpc5I6X+6jXw9xwNsI24JolSkDKmtmrljmnenNXc3vuttexRU7IPrRIiNtURtQprNK4z0r57IEaDRlhEJYoXWNJXhVfOAOcanmjZM/sTf4ydWOzI634coY7jZ7MDPEk2FZ8s8F28jskVmZTXQNnwgBDtoYL6qprQyykWtnUtjqpF5xvjtJ4Mdl03Dzi1DD6fI54eZcQ+VlHG2mSqOyg=" branches: only: - master - dev - /^test-.*$/ - /^v[0-9].*$/ cache: directories: - $HOME/tcache notifications: webhooks: urls: - "https://scalar.vector.im/api/neb/services/hooks/dHJhdmlzLWNpLyU0MDE0ODgzMyUzQW1hdHJpeC5vcmcvJTIxSWhNWURnRHl4aXVjUlpMSW1yJTNBbWF0cml4Lm9yZw" on_success: change on_failure: always on_start: never acmetool-0.2.2/.travis/000077500000000000000000000000001435652113300147075ustar00rootroot00000000000000acmetool-0.2.2/.travis/after_success000066400000000000000000000075451435652113300174760ustar00rootroot00000000000000#!bash # Only upload version tags. if ! [[ "$TRAVIS_TAG" =~ ^v[0-9] ]]; then echo Skipping release upload because this build is not for a release tag. return 0 fi [ -n "$GITHUB_TOKEN" ] || { echo "Don't appear to have GitHub token, cannot continue."; return 0; } [ -e "/tmp/crosscompiled" ] || { echo "Not crosscompiled?"; return 1; } # Make archives. echo Archiving releases... ACME_DIR="$(pwd)" cd "$GOPATH/releasing/idist" for x in *; do echo "$x" cp "$GOPATH/src/github.com/$TRAVIS_REPO_SLUG/.travis/dist-readme.md" "$x"/README.md || \ cp "$GOPATH/src/github.com/$TRAVIS_REPO_SLUG/README.md" "$x"/ || true tar -zcf "../dist/$(basename "$x").tar.gz" "$x" done # Must be in the right directory when calling ghr. cd "$ACME_DIR" echo Uploading releases... PPA_NAME=rhea COPR_PROJECT_ID=5993 grep -F '[draft]' /tmp/commit-message && \ GHR_OPTIONS="--draft" PPA_NAME=testppa COPR_PROJECT_ID=6071 TRAVIS_REPO_OWNER="$(echo "$TRAVIS_REPO_SLUG" | sed 's#/.*##g')" travis_retry ghr $GHR_OPTIONS -u "$TRAVIS_REPO_OWNER" "$TRAVIS_TAG" "$GOPATH/releasing/dist/" # Prepare Ubuntu PPA signing key. echo Preparing Ubuntu PPA signing key... wget -qO ppa-private.asc.enc "https://www.devever.net/~hl/f/ppa-private-${PPA_ENCRYPTION_ID}.asc.enc" export PPA_ENCRYPTION_ID= openssl enc -d -aes-128-cbc -md sha256 -salt -pass env:PPA_ENCRYPTION_PASS -in "ppa-private.asc.enc" -out "ppa-private.asc" export PPA_ENCRYPTION_PASS= shred -u ppa-private.asc.enc export GNUPGHOME="$ACME_DIR/.travis/.gnupg" mkdir -p "$GNUPGHOME" gpg --batch --import < ppa-private.asc shred -u ppa-private.asc cat < "$HOME/.devscripts" DEBSIGN_KEYID="Hugo Landau (2017 PPA Signing) " END UBUNTU_RELEASES="precise trusty xenial yakkety zesty vivid" for distro_name in $UBUNTU_RELEASES; do echo Creating Debian source environment for ${distro_name}... $GOPATH/src/github.com/$TRAVIS_REPO_SLUG/.travis/make_debian_env "$GOPATH/releasing/dbuilds/$distro_name" "$GOPATH/releasing/dist/" "$TRAVIS_TAG" "$distro_name" echo Creating Debian source archive for ${distro_name}... cd $GOPATH/releasing/dbuilds/$distro_name/acmetool_*[0-9] debuild -S done echo Deleting keys... find "$GNUPGHOME" -type f -exec shred -u '{}' ';' rm -rf "$GNUPGHOME" echo Uploading Debian source archives... cd "$GOPATH/releasing/dbuilds" ( echo 'open ppa.launchpad.net' echo 'set passive on' echo "cd ~hlandau/$PPA_NAME" for f in ./*/acmetool_*.dsc ./*/acmetool*.diff.gz ./*/acmetool_*_source.changes ./xenial/acmetool_*.orig.tar.gz; do echo "put $f" done echo 'quit' ) | ncftp # RPM. cd "$ACME_DIR/.travis" mkdir -p "$HOME/rpmbuild/SPECS" "$HOME/rpmbuild/SOURCES" RPMS="acmetool acmetool-nocgo" for x in $RPMS; do $GOPATH/src/github.com/$TRAVIS_REPO_SLUG/.travis/make_rpm_spec "$TRAVIS_TAG" "$x" > "$HOME/rpmbuild/SPECS/${x}.spec" done ln $GOPATH/releasing/dist/acmetool_*.orig.tar.gz $HOME/rpmbuild/SOURCES/ echo travis_fold:start:build-srpm for x in $RPMS; do rpmbuild -bs "$HOME/rpmbuild/SPECS/${x}.spec" done echo travis_fold:end:build-srpm COPR_CHROOTS="$(curl "https://copr.fedorainfracloud.org/api_2/projects/$COPR_PROJECT_ID/chroots" | jq '.chroots|map(.chroot.name)')" COPR_CHROOTS_CGO="$(echo "$COPR_CHROOTS" | jq 'map(select(contains("86")))')" for srpm in $HOME/rpmbuild/SRPMS/acmetool-*.rpm; do if [[ $srpm != *nocgo* ]]; then cat < /tmp/rpm-metadata { "project_id": $COPR_PROJECT_ID, "chroots": $COPR_CHROOTS_CGO } END else cat < /tmp/rpm-metadata { "project_id": $COPR_PROJECT_ID, "chroots": $COPR_CHROOTS } END fi echo Uploading $srpm curl -u "$COPR_LOGIN_TOKEN" \ -F 'metadata=&1 | grep -qvE '(\.(md|txt)$)|_doc/' || { echo Documentation-only update. Skipping travis proper. exit } previous_build_failed() { let TRAVIS_PREVIOUS_BUILD_NUMBER="$TRAVIS_BUILD_NUMBER - 1" [ "$TRAVIS_BUILD_NUMBER" == "1" ] || \ curl -s -H 'Accept: application/vnd.travis-ci.2+json' "https://api.travis-ci.org/builds?slug=$TRAVIS_REPO_SLUG&number=$TRAVIS_PREVIOUS_BUILD_NUMBER" | \ python3 -c 'import sys,json; k=json.load(sys.stdin); print(k); sys.exit(int(k["builds"][0]["state"] == "passed"))' } if [ -n "$TRAVIS_TAG" ]; then git show "$TRAVIS_TAG" 2>&1 | sed -n '/^diff --git/q;p' > /tmp/commit-message else git log --format=%B --no-merges -n 1 &> /tmp/commit-message fi ! grep -qF '[ci fast]' /tmp/commit-message || \ [ -n "$TRAVIS_TAG" ] || \ previous_build_failed || { echo Travis not needed for this update. exit } acmetool-0.2.2/.travis/boulder.patch000066400000000000000000000064241435652113300173720ustar00rootroot00000000000000diff --git a/cmd/shell.go b/cmd/shell.go index 353d5e1f..8c2a432b 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -24,7 +24,6 @@ import ( "fmt" "io/ioutil" "log" - "log/syslog" "net/http" "net/http/pprof" "os" @@ -130,19 +129,7 @@ func StatsAndLogging(logConf SyslogConfig, addr string) (metrics.Scope, blog.Log } func NewLogger(logConf SyslogConfig) blog.Logger { - tag := path.Base(os.Args[0]) - syslogger, err := syslog.Dial( - "", - "", - syslog.LOG_INFO, // default, not actually used - tag) - FailOnError(err, "Could not connect to Syslog") - syslogLevel := int(syslog.LOG_INFO) - if logConf.SyslogLevel != 0 { - syslogLevel = logConf.SyslogLevel - } - logger, err := blog.New(syslogger, logConf.StdoutLevel, syslogLevel) - FailOnError(err, "Could not connect to Syslog") + logger := blog.NewMock() _ = blog.Set(logger) cfsslLog.SetLogger(cfsslLogger{logger}) diff --git a/start.py b/start.py index 1c1a90bb..b5a0955c 100755 --- a/start.py +++ b/start.py @@ -19,6 +19,7 @@ import startservers if not startservers.start(race_detection=False): sys.exit(1) try: + open('/tmp/boulder-has-started','wb').write('x') os.wait() # If we reach here, a child died early. Log what died: diff --git a/test/config-next/va.json b/test/config-next/va.json index f90e1856..8721b69f 100644 --- a/test/config-next/va.json +++ b/test/config-next/va.json @@ -3,7 +3,7 @@ "userAgent": "boulder", "debugAddr": ":8004", "portConfig": { - "httpPort": 5002, + "httpPort": 80, "httpsPort": 5001, "tlsPort": 5001 }, diff --git a/test/config/ca.json b/test/config/ca.json index d5f78c2c..66cfa251 100644 --- a/test/config/ca.json +++ b/test/config/ca.json @@ -27,11 +27,11 @@ ] }, "Issuers": [{ - "ConfigFile": "test/test-ca.key-pkcs11.json", + "File": "test/test-ca.key", "CertFile": "test/test-ca2.pem", "NumSessions": 2 }, { - "ConfigFile": "test/test-ca.key-pkcs11.json", + "File": "test/test-ca.key", "CertFile": "test/test-ca.pem", "NumSessions": 2 }], diff --git a/test/config/va.json b/test/config/va.json index 91a6727c..2921c453 100644 --- a/test/config/va.json +++ b/test/config/va.json @@ -3,7 +3,7 @@ "userAgent": "boulder", "debugAddr": ":8004", "portConfig": { - "httpPort": 5002, + "httpPort": 80, "httpsPort": 5001, "tlsPort": 5001 }, diff --git a/test/hostname-policy.json b/test/hostname-policy.json index 5eaad17e..503badc1 100644 --- a/test/hostname-policy.json +++ b/test/hostname-policy.json @@ -5,12 +5,6 @@ ], "Blacklist": [ "in-addr.arpa", - "example", - "example.net", - "example.org", - "invalid", - "local", - "localhost", - "test" + "invalid" ] } diff --git a/test/rate-limit-policies.yml b/test/rate-limit-policies.yml index 157d9d2b..6e8070b6 100644 --- a/test/rate-limit-policies.yml +++ b/test/rate-limit-policies.yml @@ -4,7 +4,7 @@ totalCertificates: threshold: 100000 certificatesPerName: window: 2160h - threshold: 2 + threshold: 10000 overrides: ratelimit.me: 1 lim.it: 0 @@ -41,7 +41,7 @@ pendingOrdersPerAccount: threshold: 3 certificatesPerFQDNSet: window: 24h - threshold: 5 + threshold: 5000 overrides: le.wtf: 10000 le1.wtf: 10000 acmetool-0.2.2/.travis/check-copr-token000077500000000000000000000006511435652113300177730ustar00rootroot00000000000000#!/bin/sh set -e TRAVIS_FILE="$(dirname "$0")/../.travis.yml" [ -e "$TRAVIS_FILE" ] || exit 1 EXPIRY="$(grep 'COPR_LOGIN_TOKEN expires=' "$TRAVIS_FILE" | sed 's/^.*COPR_LOGIN_TOKEN expires=\([0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}\)/\1/g')" EXPIRY_S="$(date -d "$EXPIRY" +%s)" NOW_S="$(date +%s)" if [ "$NOW_S" -ge "$EXPIRY_S" ]; then echo >&2 "Outdated copr token. Renew it and update expiry date in .travis.yml." exit 1 fi acmetool-0.2.2/.travis/crosscompile000066400000000000000000000043371435652113300173430ustar00rootroot00000000000000#!bash # Test cross-compilation. The binaries produced are also used for release # upload in after_success if this is a release tag. [ -e "/tmp/crosscompiled" ] && return touch /tmp/crosscompiled echo travis_fold:start:crosscompile echo Cross-compiling releases... mkdir -p "$GOPATH/releasing/idist" "$GOPATH/releasing/dist" # Assume that x86 machines don't necessarily have SSE2. Whereas for amd64, # require SSE2. REPO=github.com/$TRAVIS_REPO_SLUG BINARIES=$REPO/cmd/acmetool export BUILDNAME="by travis" BUILDINFO="$($GOPATH/src/github.com/hlandau/buildinfo/gen $BINARIES)" # cgo crosscompile export GOARM=5 gox -ldflags "$BUILDINFO" -cgo -osarch 'linux/amd64' -output "$GOPATH/releasing/idist/acmetool-$TRAVIS_TAG-{{.OS}}_{{.Arch}}_cgo/bin/{{.Dir}}" $BINARIES RESULT1=$? GO386=387 gox -ldflags "$BUILDINFO" -cgo -osarch 'linux/386' -output "$GOPATH/releasing/idist/acmetool-$TRAVIS_TAG-{{.OS}}_{{.Arch}}_cgo/bin/{{.Dir}}" $BINARIES RESULT2=$? # non-cgo crosscompile gox -ldflags "$BUILDINFO" -osarch 'darwin/amd64 linux/amd64 linux/arm linux/arm64 freebsd/amd64 freebsd/arm openbsd/amd64 netbsd/amd64 netbsd/arm dragonfly/amd64 linux/ppc64 linux/ppc64le' -output "$GOPATH/releasing/idist/acmetool-$TRAVIS_TAG-{{.OS}}_{{.Arch}}/bin/{{.Dir}}" $BINARIES RESULT3=$? GO386=387 gox -ldflags "$BUILDINFO" -osarch 'linux/386 darwin/386 freebsd/386 openbsd/386 netbsd/386' -output "$GOPATH/releasing/idist/acmetool-$TRAVIS_TAG-{{.OS}}_{{.Arch}}/bin/{{.Dir}}" $BINARIES RESULT4=$? echo travis_fold:end:crosscompile # Defer exiting to get as much error output as possible upfront. echo "cgo crosscompile (amd64) exited with code $RESULT1" echo "cgo crosscompile (386) exited with code $RESULT2" echo "non-cgo crosscompile (amd64) exited with code $RESULT3" echo "non-cgo crosscompile (386) exited with code $RESULT4" if [ "$RESULT1" != "0" ]; then exit $RESULT1 fi if [ "$RESULT2" != "0" ]; then exit $RESULT2 fi if [ "$RESULT3" != "0" ]; then exit $RESULT3 fi if [ "$RESULT4" != "0" ]; then exit $RESULT4 fi # Generate man page. "$GOPATH/releasing/idist/acmetool-$TRAVIS_TAG-linux_amd64_cgo/bin/acmetool" --help-man > acmetool.8 || echo Failed to generate man page for x in $GOPATH/releasing/idist/*; do mkdir -p "$x/doc" cp -a acmetool.8 "$x/doc/" done acmetool-0.2.2/.travis/dist-readme.md000066400000000000000000000013401435652113300174250ustar00rootroot00000000000000# ACME Client Utilities More information: ## Installation and Usage You have downloaded a binary release of acmetool. Here are some simple installation instructions: $ sudo cp -a bin/acmetool /usr/local/bin/ # Run the quickstart wizard. Sets up account, cronjob, etc. $ sudo acmetool quickstart # Request the hostnames you want: $ sudo acmetool want example.com www.example.com # Now you have certificates: $ ls /var/lib/acme/live/example.com/ For more information on using acmetool, please see the full README at https://github.com/hlandau/acme ## Licence © 2015 Hugo Landau MIT License File issues at . acmetool-0.2.2/.travis/make_debian_env000077500000000000000000000211401435652113300177220ustar00rootroot00000000000000#!/bin/bash # Script to generate files for debbuild causing it to generate a 'source package' # to be uploaded to launchpad to generate PPA 'binary packages'. These source packages # are just binaries with a build script that outputs them as a 'binary package'. # So sue me. if [ -z "$1" -o -z "$2" -o -z "$3" -o -z "$4" ]; then echo Usage: "$0" "" "" "" "" echo "Create a debian build environment suitable for running debuild [-S] in." exit 1 fi OUTDIR="$1" ARCHIVEDIR="$2" VERSION="$(echo "$3" | sed 's/^v//g')" ORIGDIR="$(dirname "$0")" DISTRO="$4" mkdir -p "$OUTDIR" "$ARCHIVEDIR" OUTDIR="$(realpath "$OUTDIR")" ARCHIVEDIR="$(realpath "$ARCHIVEDIR")" ORIGDIR="$(realpath "$ORIGDIR")" REVISION="1${DISTRO}1" DATE_R="$(date -R)" # The non-cgo builds here are used by the RPM builds, which also reply on the # .orig.tar.gz file built here. ARCHS="386_cgo amd64_cgo 386 amd64 arm arm64 ppc64 ppc64le" mkdir -p "$ARCHIVEDIR" for ARCH in $ARCHS; do wget -nc -O "$ARCHIVEDIR/acmetool-v$VERSION-linux_$ARCH.tar.gz" "https://github.com/hlandau/acme/releases/download/v$VERSION/acmetool-v$VERSION-linux_$ARCH.tar.gz" done mkdir -p "$OUTDIR/acmetool_$VERSION" cd "$OUTDIR/acmetool_$VERSION" for ARCH in $ARCHS; do tar xvf "$ARCHIVEDIR/acmetool-v$VERSION-linux_$ARCH.tar.gz" done cd .. if [ ! -e "$ARCHIVEDIR/acmetool_$VERSION.orig.tar.gz" ]; then tar zcvf "$ARCHIVEDIR/acmetool_$VERSION.orig.tar.gz" "acmetool_$VERSION" fi [ -e "./acmetool_$VERSION.orig.tar.gz" ] || ln "$ARCHIVEDIR/acmetool_$VERSION.orig.tar.gz" cd "$ORIGDIR" UNPACKDIR="$OUTDIR/acmetool_$VERSION" DEBIANDIR="$UNPACKDIR/debian" mkdir -p "$DEBIANDIR/source" ### debian cruft ############################################################# echo 9 > "$DEBIANDIR/compat" echo 1.0 > "$DEBIANDIR/source/format" cat < "$DEBIANDIR/changelog" acmetool ($VERSION-$REVISION) $DISTRO; urgency=medium * Changelog information not maintained in Debian packaging. See -- Hugo Landau $DATE_R END cat <<'END' > "$DEBIANDIR/control" Source: acmetool Maintainer: Hugo Landau Section: utils Priority: optional Standards-Version: 3.9.6 Build-Depends: debhelper (>= 9), wget, ca-certificates, curl, libcap-dev Package: acmetool Architecture: amd64 i386 armhf arm64 ppc64el Depends: ${misc:Depends}, ${shlibs:Depends} Description: command line tool for automatically acquiring certificates acmetool is an easy-to-use command line tool for automatically acquiring certificates from ACME servers (such as Let's Encrypt). Designed to flexibly integrate into your webserver setup to enable automatic verification. Unlike the official Let's Encrypt client, this doesn't modify your web server configuration. . You can perform verifications using port 80 or 443 (if you don't yet have a server running on one of them); via webroot; by configuring your webserver to proxy requests for /.well-known/acme-challenge/ to a special port (402) which acmetool can listen on. . acmetool is intended to be "magic-free". All of acmetool's state is stored in a simple, comprehensible directory of flat files. . acmetool is intended to work like "make". The state directory expresses target domain names, and whenever acmetool is invoked, it ensures that valid certificates are available to meet those names. Certificates which will expire soon are renewed. acmetool is thus idempotent and minimises the use of state. END cat <<'END' > "$DEBIANDIR/copyright" Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: acmetool Upstream-Contact: Hugo Landau Source: https://github.com/hlandau/acme Files: * Copyright: 2015, Hugo Landau License: MIT Copyright © 2015 Hugo Landau . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: . The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. . THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Files: debian/* Copyright: 2015, Christian Pointner License: GPL-3+ 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. . This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. . You should have received a copy of the GNU General Public License along with this program. If not, see '/usr/share/common-licenses/GPL-3'. END ### debian build script ############################################################# cat <<'END' > "$DEBIANDIR/rules" #!/usr/bin/make -f %: dh $@ END ### debian miscellaneous files ############################################################# cat <<'END' > "$DEBIANDIR/acmetool.lintian-overrides" acmetool: embedded-library usr/bin/acmetool: libyaml acmetool: new-package-should-close-itp-bug END cat <<'END' > "$DEBIANDIR/acmetool.dirs" /usr/lib/acme /var/lib/acme END cat <<'END' > "$DEBIANDIR/acmetool.postrm" #!/bin/sh # postrm script for acmetool set -e if [ "$1" = "purge" ]; then # Remove cron job. if [ -f "/etc/cron.d/acmetool" ]; then rm -f /etc/cron.d/acmetool if [ -x "$(which invoke-rc.d 2>/dev/null)" ]; then invoke-rc.d cron reload || true else /etc/init.d/cron reload || true fi fi # Remove managed hooks. if [ -d "/usr/lib/acme/hooks" ]; then for hook in /usr/lib/acme/hooks/*; do grep -q '#!acmetool-managed!#' "$hook" && rm -f "$hook" || true done rmdir --ignore-fail-on-non-empty /usr/lib/acme/hooks || true fi # Warn about non-removed data. rmdir --ignore-fail-on-non-empty "/var/lib/acme" || true if [ -d "/var/lib/acme" ]; then echo '╔══════ acmetool purge ══════════════════════════════════════════════╗' echo '║ The acmetool state directory will be kept. If you really want to ║' echo '║ delete all your certificates and configurations you need to remove ║' echo '║ it manually using the following command: ║' echo '║ rm -rf /var/lib/acme ║' echo '╚════════════════════════════════════════════════════════════════════╝' fi fi #DEBHELPER# exit 0 END ### "source" build file ############################################################# cat <<'END' > "$UNPACKDIR/Makefile" # Invoked with DESTDIR. ARCH := $(shell sh ./getarch) VERSION := $(shell cat ./version) SRCDIR := acmetool-v$(VERSION)-linux_$(ARCH) build: install: install -Dm 755 "$(SRCDIR)/bin/acmetool" "$(DESTDIR)/usr/bin/acmetool" install -Dm 644 ./acme-reload.default "$(DESTDIR)/etc/default/acme-reload" if [ -e "$(SRCDIR)/doc/acmetool.8" ]; then \ install -Dm 644 "$(SRCDIR)/doc/acmetool.8" "$(DESTDIR)/usr/share/man/man8/acmetool.8"; \ fi clean: END cat <<'END' > "$UNPACKDIR/acme-reload.default" # Space separated list of services to restart after certificates are changed. # By default, this is a list of common webservers like apache2, nginx, haproxy, # etc. You can append to this list or replace it entirely. SERVICES="$SERVICES" END cat <<'END' > "$UNPACKDIR/getarch" #!/bin/sh set -e case "$DEB_BUILD_ARCH" in i386) echo 386_cgo;; amd64) echo amd64_cgo;; armhf|armel) echo arm;; ppc64el) echo ppc64le;; *) echo "$DEB_BUILD_ARCH";; esac END echo "$VERSION" > "$UNPACKDIR/version" acmetool-0.2.2/.travis/make_rpm_spec000077500000000000000000000025141435652113300174440ustar00rootroot00000000000000#!/bin/bash VERSION="$1" if [ -z "$VERSION" ]; then echo usage: $0 1.2.34 exit 1 fi VERSION="$(echo "$VERSION" | sed s/^v//g)" CGO_SUFFIX="_cgo" NOCGO_SUFFIX= NOTNOCGO_SUFFIX="-nocgo" if [ "$2" == "acmetool-nocgo" ]; then CGO_SUFFIX= NOCGO_SUFFIX="-nocgo" NOTNOCGO_SUFFIX= fi cat < boulder.log || cat boulder.log ; } & START_PID=$$ # Wait for boulder to come up. echo Waiting for boulder to come up... while ((1)); do kill -0 "$START_PID" || break [ -e /tmp/boulder-has-started ] && break sleep 1 done echo Boulder up. echo ---------------------------------------------------------------- # Run tests. cd "$ACME_DIR" echo travis_fold:start:go-tests time go test -v -tags=integration ./... RESULT=$? echo travis_fold:end:go-tests echo travis_fold:start:boulder-log echo Dumping boulder log cat $GOPATH/src/github.com/letsencrypt/boulder/boulder.log echo travis_fold:end:boulder-log echo Done with exit code $RESULT if [ "$RESULT" != "0" ]; then exit $RESULT fi # Crosscompilation failures are rare now and crosscompiling takes a long time # so only do it for tags. if [ -n "$TRAVIS_TAG" ]; then time source ./.travis/crosscompile fi # No point stopping boulder, travis will do it. # Don't exit here, we need after_success to run and this script is sourced. acmetool-0.2.2/Makefile000066400000000000000000000054331435652113300147660ustar00rootroot00000000000000PROJNAME=git.devever.net/hlandau/acmetool BINARIES=$(PROJNAME) ############################################################################### # v1.12 NNSC:github.com/hlandau/degoutils/_stdenv/Makefile.ref # This is a standard Makefile for building Go code designed to be copied into # other projects. Code below this line is not intended to be modified. # # NOTE: Use of this Makefile is not mandatory. People familiar with the use # of the "go" command who have a GOPATH setup can use go get/go install. # XXX: prebuild-checks needs bash, fix this at some point SHELL := $(shell which bash) -include Makefile.extra -include Makefile.assets ## Paths ifeq ($(GOPATH),) # for some reason export is necessary for FreeBSD's gmake export GOPATH := $(shell pwd) endif ifeq ($(GOBIN),) export GOBIN := $(GOPATH)/bin endif ifeq ($(PREFIX),) export PREFIX := /usr/local endif DIRS=src bin public ## Quieting Q=@ QI=@echo -e "\t[$(1)]\t $(2)"; ifeq ($(V),1) Q= QI= endif ## Buildinfo ifeq ($(USE_BUILDINFO),1) BUILDINFO_FLAG=-ldflags "$$($$GOPATH/src/github.com/hlandau/buildinfo/gen $(1))" endif ## Standard Rules all: prebuild-checks $(DIRS) $(call QI,GO-INSTALL,$(BINARIES))go install $(BUILDFLAGS) $(call BUILDINFO_FLAG,$(BINARIES)) $(BINARIES) prebuild-checks: $(call QI,RELOCATE)if [ `find . -iname '*.go' | grep -v ./src/ | wc -l` != 0 ]; then \ if [ -e "$(GOPATH)/src/$(PROJNAME)/" ]; then \ echo "$$GOPATH/src/$(PROJNAME)/ already exists, can't auto-relocate. Since you appear to have a GOPATH configured, just use go get -u '$(PROJNAME)/...; go install $(BINARIES)'. Alternatively, move this Makefile to either GOPATH or an empty directory outside GOPATH (preferred) and run it. Or delete '$$GOPATH/src/$(PROJNAME)/'."; \ exit 1; \ fi; \ mkdir -p "$(GOPATH)/src/$(PROJNAME)/"; \ for x in ./* ./.*; do \ [ "$$x" == "./src" ] && continue; \ mv -n "$$x" "$(GOPATH)/src/$(PROJNAME)/"; \ done; \ ln -s "$(GOPATH)/src/$(PROJNAME)/Makefile"; \ [ -e "$(GOPATH)/src/$(PROJNAME)/_doc" ] && ln -s "$(GOPATH)/src/$(PROJNAME)/_doc" doc; \ [ -e "$(GOPATH)/src/$(PROJNAME)/_tpl" ] && ln -s "$(GOPATH)/src/$(PROJNAME)/_tpl" tpl; \ fi; \ exit 0 $(DIRS): | .gotten $(call QI,DIRS)mkdir -p $(GOPATH)/src $(GOBIN); \ if [ ! -e "src" ]; then \ ln -s $(GOPATH)/src src; \ fi; \ if [ ! -e "bin" ]; then \ ln -s $(GOBIN) bin; \ fi .gotten: $(call QI,GO-GET,$(PROJNAME))go get $(PROJNAME)/... $(Q)touch .gotten .NOTPARALLEL: prebuild-checks $(DIRS) .PHONY: all test install prebuild-checks test: $(call QI,GO-TEST,$(PROJNAME))for x in $(PROJNAME); do go test -cover -v $$x/...; done install: all $(call QI,INSTALL,$(BINARIES))for x in $(BINARIES); do \ install -Dp $(GOBIN)/`basename "$$x"` $(DESTDIR)$(PREFIX)/bin; \ done update: | .gotten $(call QI,GO-GET,$(PROJNAME))go get -u $(PROJNAME)/... acmetool-0.2.2/README.md000066400000000000000000000407651435652113300146140ustar00rootroot00000000000000#
acmetool

[webchat: freenode #acmetool]
[download count] [version]
[ppa
debian/ubuntu] Build Status

acmetool is an easy-to-use command line tool for automatically acquiring certificates from ACME servers (such as Let's Encrypt). Designed to flexibly integrate into your webserver setup to enable automatic verification. Unlike the official Let's Encrypt client, this doesn't modify your web server configuration.

:white_check_mark: Zero-downtime autorenewal
:white_check_mark: Supports any webserver
:white_check_mark: Fully automatable
:white_check_mark: Single-file dependency-free binary
:white_check_mark: Idempotent
:white_check_mark: Fast setup

You can perform verifications using port 80 or 443 (if you don't yet have a server running on one of them); via webroot; by configuring your webserver to proxy requests for `/.well-known/acme-challenge/` to a special port (402) which acmetool can listen on; or by configuring your webserver not to listen on port 80, and instead running acmetool's built in HTTPS redirector (and challenge responder) on port 80. This is useful if all you want to do with port 80 is redirect people to port 443. You can run acmetool on a cron job to renew certificates automatically (`acmetool --batch`). The preferred certificate for a given hostname is always at `/var/lib/acme/live/HOSTNAME/{cert,chain,fullchain,privkey}`. You can configure acmetool to reload your webserver automatically when it renews a certificate. acmetool is intended to be "magic-free". All of acmetool's state is stored in a simple, comprehensible directory of flat files. [The schema for this directory is documented.](https://github.com/hlandau/acmetool/blob/master/_doc/SCHEMA.md) acmetool is intended to work like "make". The state directory expresses target domain names, and whenever acmetool is invoked, it ensures that valid certificates are available to meet those names. Certificates which will expire soon are renewed. acmetool is thus idempotent and minimises the use of state. acmetool can optionally be used [without running it as root.](https://hlandau.github.io/acmetool/userguide#annex-root-configured-non-root-operation) If you have existing certificates issued using the official client, acmetool can import those certificates, keys and account keys (`acmetool import-le`). acmetool supports both RSA and ECDSA keys and certificates. acmetool's notification hooks system allows you to write arbitrary shell scripts to be executed when new certificates are obtained. By default, this is used to reload webservers automatically, but it can also be used to distribute certificates to other servers or for other purposes. ## Getting Started **Binary releases:** [Binary releases are available.](https://github.com/hlandau/acmetool/releases) Download the release appropriate for your platform and simply copy the `acmetool` binary to `/usr/bin`. `_cgo` releases are preferred over non-`_cgo` releases where available, but non-`_cgo` releases may be more compatible with older OSes. **Ubuntu users:** A binary release PPA, `ppa:hlandau/rhea` (package `acmetool`) is available. ```bash $ sudo add-apt-repository ppa:hlandau/rhea $ sudo apt-get update $ sudo apt-get install acmetool ``` You can also [download .deb files manually.](https://launchpad.net/~hlandau/+archive/ubuntu/rhea/+packages) (Note: There is no difference between the .deb files for different Ubuntu release codenames; they are interchangeable and completely equivalent.) **Debian users:** The Ubuntu binary release PPA also works with Debian: ``` # echo 'deb http://ppa.launchpad.net/hlandau/rhea/ubuntu xenial main' > /etc/apt/sources.list.d/rhea.list # apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 9862409EF124EC763B84972FF5AC9651EDB58DFA # apt-get update # apt-get install acmetool ``` You can also [download .deb files manually.](https://launchpad.net/~hlandau/+archive/ubuntu/rhea/+packages) (Note: There is no difference between the .deb files for different Ubuntu release codenames; they are interchangeable and completely equivalent.) **RPM-based distros:** [A copr RPM repository is available.](https://copr.fedorainfracloud.org/coprs/hlandau/acmetool/) If you have `dnf` installed: ```bash $ sudo dnf copr enable hlandau/acmetool $ sudo dnf install acmetool ``` Otherwise use the `.repo` files on the [repository page](https://copr.fedorainfracloud.org/coprs/hlandau/acmetool/) and use `yum`, or download RPMs and use `rpm` directly. **Void Linux users:** `acmetool` is in the repositories: ```bash $ sudo xbps-install acmetool ``` **Arch Linux users:** [An AUR PKGBUILD for building from source is available.](https://aur.archlinux.org/packages/acmetool-git/) ```bash $ wget https://aur.archlinux.org/cgit/aur.git/snapshot/acmetool-git.tar.gz $ tar xvf acmetool-git.tar.gz $ cd acmetool-git $ makepkg -s $ sudo pacman -U ./acmetool*.pkg.tar.xz ``` **Alpine Linux users:** [An APKBUILD for building from source is available.](_doc/APKBUILD) **FreeBSD users:** [FreeBSD port is available.](http://www.freshports.org/security/acmetool/) **Building from source:** You will need Go installed to build from source. If you are on Linux, you will need to make sure the development files for `libcap` are installed. This is probably a package for your distro called `libcap-dev` or `libcap-devel` or similar. ```bash # This is necessary to work around a change in Git's default configuration # which hasn't yet been accounted for in some places. $ git config --global http.followRedirects true $ git clone https://github.com/hlandau/acme $ cd acme $ make && sudo make install # (People familiar with Go with a GOPATH setup can alternatively use go get/go install:) $ git config --global http.followRedirects true $ go get github.com/hlandau/acmetool ``` ### After installation ```bash # Run the quickstart wizard. Sets up account, cronjob, etc. # (If you want to use ECDSA keys or set RSA key size, pass "--expert".) $ sudo acmetool quickstart # Configure your webserver to serve challenges if necessary. # See https://hlandau.github.io/acmetool/userguide#web-server-configuration $ ... # Request the hostnames you want: $ sudo acmetool want example.com www.example.com # Now you have certificates: $ ls -l /var/lib/acme/live/example.com/ ``` The `quickstart` subcommand is a recommended wizard which guides you through the setup of ACME on your system. The `want` subcommand states that you want a certificate for the given hostnames. (If you want separate certificates for each of the hostnames, run the want subcommand separately for each hostname.) The default subcommand, `reconcile`, is like "make" and makes sure all desired hostnames are satisfied by valid certificates which aren't soon to expire. `want` calls `reconcile` automatically. If you run `acmetool reconcile` on a cronjob to facilitate automatic renewal, pass `--batch` to ensure it doesn't attempt to interact with a terminal. You can increase logging severity for debugging purposes by passing `--xlog.severity=debug`. ## Validation Options [screenshot] **Webroot:** acmetool can place challenge files in a given directory, allowing your normal web server to serve them. The files must be served from the path you specify at `/.well-known/acme-challenge/`. [Information on configuring your web server.](https://hlandau.github.io/acmetool/userguide#web-server-configuration-challenges) **Proxy:** acmetool can respond to validation challenges by serving them on port 402. In order for this to be useful, you must configure your webserver to proxy requests under `/.well-known/acme-challenge/` to `http://127.0.0.1:402/.well-known/acme-challenge`. [Information on configuring your web server.](https://hlandau.github.io/acmetool/userguide#web-server-configuration-challenges) **Stateless:** You configure your webserver to respond statelessly to challenges for a given account key without consulting acmetool. This requires nothing more than a one-time web server configuration change and no "moving parts". [Information on configuring stateless challenges.](https://hlandau.github.io/acmetool/userguide#web-server-configuration-challenges) **Redirector:** `acmetool redirector` starts an HTTP server on port 80 which redirects all requests to HTTPS, as well as serving any necessary validation responses. The `acmetool quickstart` wizard can set it up for you if you use systemd. Otherwise, you'll need to configure your system to run `acmetool redirector --service.uid=USERNAME --service.daemon=1` as a service, where `USERNAME` is the username you want the daemon to drop to. Make sure your web server is not listening on port 80. **Listen:** If you are for some reason not running anything on port 80 or 443, acmetool will use those ports. Either port being available is sufficient. This is only really useful for development purposes. **Hook:** You can write custom shell scripts (or binary executables) which acmetool invokes to provision challenge files at the desired location. For example, you could rsync challenge files to a directory on a remote server. [More information.](https://hlandau.github.io/acmetool/userguide#challenge-hooks) ## Renewal acmetool will try to renew certificates automatically once they are 30 days from expiry, or 66% through their validity period, whichever is lower. Note that Let's Encrypt currently issues 90 day certificates. acmetool will exit with an error message with nonzero exit status if it cannot renew a certificate, so it is suitable for use in a cronjob. Ensure your system is configured so that you get notifications of failing cronjobs. If a cronjob fails, you should intervene manually to see what went wrong by running `acmetool` (possibly with `--xlog.severity=debug` for verbose logging). ## Library The [client library which these utilities use](https://github.com/hlandau/acmeapi) can be used independently by any Go code. [README and source code.](https://github.com/hlandau/acmeapi) [Godoc.](https://godocs.io/gopkg.in/hlandau/acmeapi.v2) ## Comparison with... **certbot:** A heavyweight Python implementation which is a bit too “magic” for my tastes. Tries to mutate your webserver configuration automatically. acmetool is a single-file binary which only depends on basic system libraries (on Linux, these are libc, libpthread, libcap, libattr). It doesn't do anything to your webserver; it just places certificates at a standard location and can also reload your webserver (whichever webserver it is) by executing hook shell scripts. acmetool isn't based around individual transactions for obtaining certificates; it's about satisfying expressed requirements by any means necessary. Its comprehensible, magic-free state directory makes it as stateless and idempotent as possible. **lego:** Like acmetool, [xenolf/lego](https://github.com/xenolf/lego) provides a library and client utility. The utility provides commands for creating certificates, but doesn't provide a compelling system for managing the lifetime of the short-lived certificates offered by Let's Encrypt. The user is expected to generate and install all certificates manually. **gethttpsforfree:** [diafygi/gethttpsforfree](https://github.com/diafygi/gethttpsforfree) provides an HTML file which uses JavaScript to make requests to an ACME server and obtain certificates. It's a functional user interface, but like lego it provides no answer for the automation issue, and is thus impractical given the short lifetime of certificates issued by Let's Encrypt. ### Comparison, list of client implementations
acmetoolcertbotlegogethttpsforfree
Automatic renewalYesNot yetNoNo
SAN supportYesYesYesYes
ECC supportYesNoNoNo
OCSP Must Staple supportYesNoNoNo
Revocation supportYesYesYesNo
State managementYes†Yes
Single-file binaryYesNoYesYes
Quickstart wizardYesYesNoNo
Modifies webserver configNoBy defaultNoNo
Non-root supportOptionalOptionalOptional
Supports ApacheYesYes
Supports nginxYesExperimental
Supports HAProxyYesNo
Supports HitchYesNo
Supports any web serverYesWebroot‡
Authorization via webrootYesYesManual
Authorization via port 80 redirectorYesNoNoNo
Authorization via proxyYesNoNoNo
Authorization via listener§YesYesYesNo
Authorization via DNSHook onlyNoYesNo
Authorization via custom hookYesNoNoNo
Import state from official clientYes
Windows (basic) supportNoNoYes
Windows integration supportNoNoNo
† acmetool has a different philosophy to state management and configuration to the Let's Encrypt client; see the beginning of this README. ‡ The webroot method does not appear to provide any means of reloading the webserver once the certificate has been changed, which means auto-renewal requires manual intervention. § Requires downtime. This table is maintained in good faith; I believe the above comparison to be accurate. If notified of any inaccuracies, I will rectify the table and publish a notice of correction here: - This table previously stated that the official Let's Encrypt client doesn't support non-root operation. This was incorrect; it can be installed at user level and be configured to use user-writable directories. ## Documentation & Support For more documentation see: - [User Guide](https://hlandau.github.io/acmetool/userguide) - [Troubleshooting](https://hlandau.github.io/acmetool/userguide#troubleshooting) - [FAQ](https://hlandau.github.io/acmetool/userguide#faq) - [manpage](https://hlandau.github.io/acmetool/acmetool.8) If your question or issue isn't resolved by any of the above, file an issue. IRC: [#acmetool](irc://chat.freenode.net/#acmetool) on [Freenode](http://freenode.net/) ([webchat](https://webchat.freenode.net/?channels=%23acmetool)). ## Licence © 2015—2019 Hugo Landau MIT License [Licenced under the licence with SHA256 hash `fd80a26fbb3f644af1fa994134446702932968519797227e07a1368dea80f0bc`, a copy of which can be found here.](https://raw.githubusercontent.com/hlandau/rilts/master/licences/COPYING.MIT) acmetool-0.2.2/_doc/000077500000000000000000000000001435652113300142255ustar00rootroot00000000000000acmetool-0.2.2/_doc/FAQ.md000066400000000000000000000001201435652113300151470ustar00rootroot00000000000000# [This document has moved.](https://hlandau.github.com/acmetool/userguide#faq) acmetool-0.2.2/_doc/NOROOT.md000066400000000000000000000001521435652113300155650ustar00rootroot00000000000000# [This document has moved.](https://hlandau.github.io/acme/userguide#root-configured-non-root-operation) acmetool-0.2.2/_doc/PACKAGING-PATHS.md000066400000000000000000000022351435652113300167120ustar00rootroot00000000000000# On packaging acmetool for distribution: changing default paths acmetool uses paths such as "/var/lib/acme" and "/usr/lib(exec)/acme/hooks" by default. It may be desired to change these paths for the purposes of a specific distribution. It is thus possible to override these paths when building acmetool. The following arguments to `go build` demonstrate which paths may be customized and how. This example also includes version information, which ensures that `--version` output will be informative. If you set the BUILDNAME environment variable, you can specify a short, one-line string providing your build information. This defaults, if not set, to the date and hostname. (You could set it to a constant value if you are pursing reproducible builds.) ```sh $ go build -ldflags " -X git.devever.net/hlandau/acmetool/storage.RecommendedPath=\"/var/lib/acme\" -X git.devever.net/hlandau/acmetool/hooks.DefaultPath=\"/usr/lib/acme/hooks\" -X git.devever.net/hlandau/acmetool/responder.StandardWebrootPath=\"/var/run/acme/acme-challenge\" $($GOPATH/src/github.com/hlandau/buildinfo/gen git.devever.net/hlandau/acmetool) " git.devever.net/hlandau/acmetool ``` acmetool-0.2.2/_doc/PROGRAMMATIC-DOWNLOADING.md000066400000000000000000000014131435652113300201760ustar00rootroot00000000000000# How to download binary releases programmatically ## With curl ```sh VER="$(curl -s -H 'Accept: application/vnd.github.v3+json' 'https://api.github.com/repos/hlandau/acmetool/releases/latest' | python -c 'import sys,json;k=json.load(sys.stdin);print(k["tag_name"])')" curl -Ls -o acmetool-bin.tar.gz "https://github.com/hlandau/acmetool/releases/download/$VER/acmetool-$VER-linux_amd64_cgo.tar.gz" ``` ## With wget ```sh VER="$(wget --quiet -O - --header='Accept: application/vnd.github.v3+json' 'https://api.github.com/repos/hlandau/acmetool/releases/latest' | python -c 'import sys,json;k=json.load(sys.stdin);print(k["tag_name"])')" wget --quiet -O acmetool-bin.tar.gz "https://github.com/hlandau/acmetool/releases/download/$VER/acmetool-$VER-linux_amd64_cgo.tar.gz" ``` acmetool-0.2.2/_doc/SCHEMA.md000066400000000000000000001105401435652113300155100ustar00rootroot00000000000000ACME State Storage Specification ================================ The ACME State Storage Specification (ACME-SSS) specifies how an ACME client can store state information in a directory on the local system in a way that facilitates its own access to and mutation of its state and the exposure of certificates and keys to other system services as required. This specification relates to the use of ACME but has no official endorsement. This specification is intended for use on POSIX-like systems. Synopsis -------- The following example shows a state directory configured to obtain a certificate with hostnames example.com and www.example.com. No state other than that shown below is used. /var/lib/acme desired/ example.com ; Target expression file ;; By default a target expression file expresses a desire for the ;; hostname which is its filename. The following YAML-format ;; configuration directives are all optional. names: - example.com - www.example.com provider: URL of ACME server live/ example.com ; Symlink to appropriate cert directory www.example.com ; certs/ (certificate/order ID)/ cert ; Contains the certificate chain ; Contains the necessary chaining certificates fullchain ; Contains the certificate and the necessary chaining certificates privkey ; Symlink to a key privkey file account ; Symlink to an account directory (required for ACMEv2) url ; URL of the finalised order resource revoke ; Empty file indicating certificate should be revoked revoked ; Empty file indicating certificate has been revoked keys/ (key ID)/ privkey ; PEM-encoded certificate private key accounts/ (account ID)/ privkey ; PEM-encoded account private key conf/ ; Configuration data target ; This has the same format as a target expression file ; and is used to specify defaults. It is used to specify ; a default provider URL. Not all values which are valid ; in a target expression file may be used. webroot-path ; DEPRECATED. rsa-key-size ; DEPRECATED. ; Other, implementation-specific files may be placed in conf. tmp/ ; (used for writing files only) Preferred Location ------------------ All ACME state is stored within a directory, the State Directory. On UNIX-like systems, this directory SHOULD be "/var/lib/acme". Directory Tree -------------- ### desired An ACME State Directory expresses targets in the form of desired hostnames for which certificates are required. This normatively expresses the target set of domains. The desired hostnames list is a directory named "desired" inside the State Directory, containing zero or more files. Each file represents a target. The function of an implementation of this specification is simply this: to ensure that currently valid certificates are always provisioned which provide for all hostnames expressed across all targets. Each target is a YAML file. The file schema looks like this: satisfy: names: - example.com - www.example.com - foo.example.com request: names: ... provider: (URL of ACME server) priority: 0 The target file is principally divided into "satisfy" and "request" sections. The satisfy section controls the conditions which must be satisfied by a certificate in order to satisfy this target. The request section controls the parameters of a new certificate request made to satisfy the target. **Conditions to be satisfied.** The "satisfy" section can contain the following values: - `names`: A list of hostname strings. If not set, and the filename is a valid hostname, defaults to a list containing only that hostname. Hostnames SHOULD be in lowercase with no trailing dot but hostnames not in this form MUST be accepted and so canonicalized. IDN domain names in Unicode form MUST be converted to their equivalent ASCII form. (All text files in a State Directory must be UTF-8 encoded.) - `key`: `type`: Optional string containing "", "rsa" or "ecdsa". If set to a non-empty value, only satisfied by keys with a public key of that type. - `margin`: Optional positive integer. If set, expresses the number of days before expiry at which a certificate should be replaced. The default value is implementation-dependent. (The lumping of hostnames into different target files controls when separate certificates are issued, and when single certificates with multiple SANs are issued. For example, creating two empty files, `example.com` and `www.example.com`, would result in two certificates. Creating a single file `example.com` which specifies names to be satisfied of `example.com` and `www.example.com` would result in one certificate with both names.) **Certificate request parameters.** The "request" section can contain the following values: - `names`: A list of hostname strings. Defaults to the names set under "satisfy". This setting is specified for design parity, but it is not envisioned that a user will ever need to set it explicitly. - `provider`: A string which is the URL of an ACME server from which to request certificates. Optional; if not specified, an implementation-specific default ACME server is used. **Backwards compatibility.** For compatibility with previous versions, implementations SHOULD check for keys "names" and "provider" at the root level and if present, move them to the "satisfy" and "request" sections respectively. **Target set disjunction priority.** The "priority" value is special. It is an integer defaulting to 0. **Target set label.** The "label" value is an optional string value and defaults to "". **Target set disjunction procedure.** In order to ensure consistent and deterministic behaviour, and to minimise the number of certificate requests which need to be made in regard of overlapping name sets, the sets of names to be satisfied by each target are modified to ensure that the sets are fully disjoint. That is, any given hostname must appear in at most one target's list of names to be satisfied. The procedure operates as follows for all targets with a given label: - Take the list of targets and sort it in descending order of priority value. For targets with equal priority, tiebreak using the number of hostnames to be satisfied in descending order. Where the number of hostnames is equal, the tiebreaker is implementation-specified, but SHOULD be deterministic. - Now iterate through the targets in that order. Create an empty dictionary mapping hostnames to targets. This dictionary shall be called the Hostname-Target Mapping and should be retained in memory even after the disjunction procedure is completed. - For each hostname to be satisfied for a target, if that hostname is not already in the dictionary, add it, pointing to the given target. - Copy the list of hostnames to be satisfied by the given target, and remove any hostnames from it which were already in the dictionary (i.e., where the map does not point to this target). This modified hostname list is called the reduced set of hostnames to be satisfied. Keep both the full set of hostnames to be satisfied and the reduced set of hostnames to be satisfied in memory for each target. The on-disk target files are not modified. Wildcard certificates may be requested just as the wildcard name would be encoded in a certificate. For example, an empty file named `*.example.com` could be created in the "desired" directory. **Disjunction example.** This section is non-normative. Suppose that the following targets were created: Target 01: a.example.com b.example.com c.example.com Target 02: a.example.com b.example.com Target 03: b.example.com c.example.com Target 04: a.example.com c.example.com Target 05: a.example.com Target 06: b.example.com Target 07: c.example.com Target 08: c.example.com d.example.com e.example.com f.example.com Target 09: c.example.com d.example.com Target 10: c.example.com d.example.com e.example.com Suppose that all targets have the default priority zero and have filenames "Target 01", etc. The targets would be sorted as follows. The hostnames in brackets are not in the reduced set. Target 08: c.example.com d.example.com e.example.com f.example.com Target 01: a.example.com b.example.com [c.example.com] Target 10:[c.example.com][d.example.com][e.example.com] Target 02:[a.example.com][b.example.com] Target 03: [b.example.com][c.example.com] Target 04:[a.example.com] [c.example.com] Target 09:[c.example.com][d.example.com] Target 05:[a.example.com] Target 06: [b.example.com] Target 07: [c.example.com] Suppose that Target 01 was changed to have a priority of 10. The sorted, reduced targets would now look like this: Target 01: a.example.com b.example.com c.example.com Target 08:[c.example.com] d.example.com e.example.com f.example.com Target 10:[c.example.com][d.example.com][e.example.com] Target 02:[a.example.com][b.example.com] Target 03: [b.example.com][c.example.com] Target 04:[a.example.com] [c.example.com] Target 09:[c.example.com][d.example.com] Target 05:[a.example.com] Target 06: [b.example.com] Target 07: [c.example.com] **Extensions for specific implementations: acmetool.** This section is non-normative, added as a practicality since this document serves as both specification and documentation. acmetool supports the following extensions: request: # Determines whether RSA or ECDSA keys are used. ECDSA keys must be # supported by the server. Let's Encrypt does not yet support ECDSA # keys, though support is imminent. Default RSA. key: type: rsa (must be "rsa" or "ecdsa") # RSA modulus size when using an RSA key. Default 2048 bits. # # Legacy compatibility: if not present, the number of bits may be # contained in a file "rsa-key-size" inside the conf directory. rsa-size: 2048 # ECDSA curve when using an ecdsa key. Default "nistp256". # # It is strongly recommended that you use nistp256. Let's Encrypt # will not support nistp521. ecdsa-curve: nistp256 (must be "nistp256", "nistp384" or "nistp521") # If specified, specifies a key ID which should be used as the private # key for all generated requests. If not set or the key ID is not found, # generate a new key for every request. id: string # Request OCSP Must Staple in certificates. Defaults to false. ocsp-must-staple: true challenge: # Webroot paths to use when requesting certificates. Defaults to none. # This is usually used in the default target file. While you _can_ override # this in a specific target, you should think very carefully by doing so. # In almost all cases, it is better to use symlinks or aliases to ensure # that the same directory is used for all vhosts. # # Legacy compatibility: the file "webroot-path" in the conf directory # contains a list of webroot paths, one per line. webroot-paths: - /some/webroot/path/.well-known/acme-challenge # A list of additional ports to listen on. Each item can be a port # number, or an explicit bind address (e.g. "[::1]:402"). Specifying a # port number x is equivalent to specifying "[::1]:x" and "127.0.0.1:x". http-ports: - 80 - 402 - 4402 # Defaults to true. If false, will not perform self-test but will assume # challenge can be completed. Rarely needed. http-self-test: true # Optionally set environment variables to be passed to hooks. env: FOO: BAR ### accounts An ACME State Directory MUST contain a subdirectory "accounts" which contains account-specific information. It contains zero or more subdirectories, each of which relates to a specific account. Each subdirectory MUST be named after the Account ID. Each account subdirectory MUST contain a file "privkey" which MUST contain the account private key in PEM form. An ACME client which needs to request a certificate from a given provider (as expressed by a target or used as a default) which finds that no account corresponding to that provider URL exists should generate a new account key and store it for that provider URL. ### keys An ACME State Directory MUST contain a subdirectory "keys" which contains private keys used for certificates. It contains zero or more subdirectories, each of which relates to a specific key. Each subdirectory MUST be named after the Key ID. Each key subdirectory MUST contain a file "privkey" which MUST contain the private key in PEM form. An ACME client creates keys as necessary to correspond to certificates it requests. An ACME client SHOULD create a new key for every certificate request. ### certs An ACME State Directory MUST contain a subdirectory "certs" which contains information about issued or requested certificates. It contains zero or more subdirectories, each of which relates to a specific certificate. Each subdirectory MUST be named after the Certificate ID. Each certificate subdirectory MUST contain a file "url" which contains the URL for the finalised order encoded in UTF-8. Clients MUST NOT include trailing newlines or whitespace but SHOULD accept such whitespace and strip it. NOTE: In previous versions of this specification (which targeted the draft ACME protocol prior to the addition of orders), the URL contained in the "url" file was the URL to the certificate. Such certificates may still exist in a state directory; it is recommended that implementations be able to detect whether an URL leads to a certificate or order via the Content-Type of the response yielded when dereferencing the URL. These old certificate directories (and some older new certificate directories) will also lack an "account" symlink. Each certificate subdirectory MUST contain a relative symlink "account" to an account directory used to request the certificate. (Old certificate directories may lack this symlink.) A client SHOULD automatically delete any certificate directory if the certificate it contains is expired AND is not referenced by the "live" directory. Certificates which have expired but are still referenced by the "live" directory MUST NOT be deleted to avoid breaking reliant applications. A certificate subdirectory MAY also contain information obtained from the "url" file. If an ACME client finds only an "url" file, it MUST retrieve the certificate information to ensure that local system services can make use of the certificate: - If retrieval of the certificate fails with a permament error (e.g. 404), the certificate directory SHOULD be deleted. - If retrieval of the certificate fails with a temporary error (e.g. 202), the client tries again later. If provided, the Retry-After HTTP header should be consulted. - If retrieval of the certificate yields an `application/json` resource suggesting an order (rather than the certificate itself), it is parsed as an order to find the certificate URL. If the order is still in status "processing", handle it like a temporary error as above; if the order has somehow transitioned to "invalid", handle it like a permanent error as above. - If retrieval of the certificate succeeds, but the private key required to use it cannot be found, the certificate directory SHOULD be deleted. After having successfully retrieved the certificate, the following files MUST be written in the certificate subdirectory: - "cert": A file which MUST contain the PEM-encoded certificate. - "chain": A file which MUST contain the PEM-encoded certificate chain, i.e. the concatenation of the PEM encodings of all certificates but for the issued certificate itself and the root certificate which are necessary to validate the certificate. In other words, this contains any necessary intermediate certificates. - "fullchain": A file which MUST contain the concatenation of the "cert" and "chain" files. - "privkey": This MUST be a relative symlink to the privkey file of the private key used to create the certificate (i.e. a symlink pointing to `../../keys/(key ID)/privkey`). ### live An ACME State Directory MUST contain a subdirectory "live". It contains zero or more relative symlinks, each of which MUST link to a subdirectory of the "certs" directory. The name of each symlink MUST be a hostname which is expressed, or was previously expressed by one or more targets, followed by a colon and the label of the target. If the label of the target is "", the colon is omitted. The "live" directory MUST point to the Most Preferred Certificate for each target, as specified below. Thus an application requiring a certificate for a given hostname can unconditionally specify `/var/lib/acme/live/example.com/{cert,privkey}` for the certificate, private key, etc. ### tmp, Rules for State Directory Mutation An ACME State Directory MUST contain a subdirectory "tmp" which is used for storing temporary files. This directory is used instead of some system-scope temporary directory to ensure that new files are created on the same filesystem and thus can be atomically renamed to their desired final locations in the ACME State Directory. For temporary files which do not require this, other temporary directories may be more suitable. **Any change to any object in the ACME State Directory MUST be one of the following operations:** - An idempotent recursive directory creation ("mkdir -p"). - Writing to a temporary file securely created with a high-entropy filename in "tmp" and appropriately locked, then either atomically moving it to its desired location in the ACME State Directory (potentially overwriting an existing file) or deleting it (e.g. in the event of an error before the file is completely written). - Securely creating a new symlink with a high-entropy filename in "tmp", then either atomically moving it to its desired location in the ACME State Directory (potentially overwriting an existing symlink) or deleting it. - Atomically deleting a file or recursively deleting a directory. - Idempotently changing file or directory permissions or ownership to conform with security requirements. When an ACME client finds files in the "tmp" directory which it did not itself open (in its current invocation), it SHOULD delete them. It SHOULD perform this check whenever invoked. Files MUST be created with the permissions they are to ultimately hold, not have their permissions modified afterwards. Where particular permissions are required of certain files, those permissions SHOULD be verified on every invocation of the client. Where particular permissions are required of a directory, those permissions MUST be verified before moving any file into that directory. Note that because all files begin in the "tmp" directory, their permissions MUST be strictly as strict or stricter than the permissions of any direct or indirect parent directory, at least until the move is completed. ### Permissions (POSIX) The following permissions on a State Directory MUST be enforced: - The "accounts", "keys" and "tmp" directories and all subdirectories within them MUST have mode 0770 or stricter. All files directly or ultimately within these directories MUST have mode 0660 or stricter, except for files in "tmp", which MUST have the permissions appropriate for their ultimate location before they are moved to that location. - For all other files and directories, appropriate permissions MUST be enforced as determined by the implementation. Generally this will mean directories having mode 0755 and files having mode 0644. Files and directories MUST NOT be world writable. The ownership of a State Directory and all files and directories directly or ultimately within it SHOULD be verified and enforced. ### Use of Symlinks All symlinks used within the State Directory MUST be unbroken, MUST point to locations within the State Directory and MUST be relatively expressed (i.e., they MUST NOT break if the State Directory were to be moved). Implementations SHOULD verify these properties for any symlinks they encounter in the State Directory. Hooks ----- It is desirable to provide extensibility in certain circumstances via the means of hooks. These hooks are implemented using executable shell scripts or binaries external to an implementation. Several types of hook are defined. All hooks are kept in a separate directory, the ACME Hooks Directory. The RECOMMENDED path is `/usr/lib/acme/hooks`, except for systems which use `/usr/libexec`, which SHOULD use `/usr/libexec/acme/hooks`. The hooks directory MUST contain only executable objects (i.e. executable scripts or binaries or symlinks to them). However, implementations SHOULD ignore non-executable objects. "Executable" here means executable in practical terms, and does not refer merely to the file having the executable bits set in its mode, which is a necessary but not sufficient condition. ### Calling Convention An ACME client MUST invoke hooks as follows: Take the list of objects in the hooks directory and sort them in ascending lexicographical order by filename. Execute each object in that order. If execution of an object fails, execution of subsequent objects MUST continue. The first argument when invoking a hook is always the event type causing invocation of the hook. When invoking a hook, the environment variable `ACME_STATE_DIR` MUST be set to the absolute path of the State Directory. A hook is invoked successfully if it exits with exit code 0. A hook which exits with exit code 42 indicates a lack of support for the event type. Any other exit code indicates an error. ### sudo Protocol It may be desirable for an implementation to run as an unprivileged user. In this case, it is necessary to have some way to elevate notification hooks so they can perform privileged operations such as restarting system services. Since most POSIX systems do not support the setuid bit on scripts, the use of "sudo" is suggested. When an implementation is not running as root, and executes a hook, and that hook is owned by root, and it has the setuid bit set, and the OS does not (as currently configured) support setuid on scripts, and the "sudo" command is available, and the file begins with the characters "#!", execute "sudo -n -- FILE EVENT-TYPE ARGS...", where FILE is the absolute path to the file and ARGS are dictated by hook event type. Success is not guaranteed as the system administrator must have configured the sudoers file to allow this operation. ### live-updated The "live-updated" hook is invoked when one or more symlinks in the "live" directory are created or updated. There are no arguments. Each object invoked MUST have passed to stdin a list of the names of the symlinks in the "live" directory which have changed target, i.e. the hostnames for which the preferred certificate has changed. The hostnames are separated by newlines, and the final hostname also ends with a newline. ### challenge-http-start, challenge-http-stop These hooks are invoked when an HTTP challenge attempt begins and ends. They can be used to install challenge files at arbitrary locations. The first argument is the hostname to which the challenge relates. The second argument is the filename of the target file causing the challenge to be completed. This may be the empty string in some circumstances; for example, when an authorization is being obtained for the purposes of performing revocation rather than for obtaining a certificate. The third argument is the filename which must be provisioned under `/.well-known/acme-challenge/`. The required contents of the file is passed as stdin. A hook should exit with exit code 0 only if it successfully installs or removes the challenge file. For `challenge-http-start`, an implementation may consider such an exit to authoritatively indicate that it is now feasible to complete the challenge. Example call: ```sh echo evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA.nP1qzpXGymHBrUEepNY9HCsQk7K8KhOypzEt62jcerQ | \ ACME_STATE_DIR=/var/lib/acme /usr/lib/acme/hooks/foo \ challenge-http-start example.com some-target-file \ evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA ``` ### challenge-tls-sni-start, challenge-tls-sni-stop These hooks are invoked when a TLS-SNI challenge begins and ends. They can be used to install the necessary validation certificate by arbitrary means. The hook MUST return 0 only if it succeeds at provisioning/deprovisioning the challenge. When returning 0 in the `challenge-tls-sni-start` case, it MUST return only once the certificate is globally visible. The first argument is the hostname to which the challenge relates. The second argument is the filename of the target file causing the challenge to be completed. This may be the empty string in some circumstances; for example, when an authorization is being obtained for the purposes of performing revocation rather than for obtaining a certificate. The third argument is the hostname which will be specified via SNI when the validation server checks for the certificate. The fourth argument is an additional hostname which must appear in the certificate. Both hostnames must appear as dNSName SubjectAlternateNames in the certificate returned. The third and fourth argument may be equal in some cases. A PEM-encoded certificate followed by a PEM-encoded private key is fed on stdin. A hook can choose to provision this certificate to satisfy the challenge. It can also construct its own certificate. ### challenge-dns-start, challenge-dns-stop These hooks are invoked when a DNS challenge begins and ends. They can be used to install the necessary validation DNS records, for example via DNS UPDATE. The hook MUST return 0 only if it succeeds at provisioning/deprovisioning the challenge. When returning 0 in the `challenge-dns-start` case, it MUST return only once the record to be provisioned is globally visible at all of the authoritative nameservers for the applicable zone. The hook is not required to consider the effects of caching resolvers as ACME servers will perform the lookup directly. The first argument is the hostname to which the challenge relates. The second argument is the filename of the target file causing the challenge to be completed. This may be the empty string in some circumstances; for example, when an authorization is being obtained for the purposes of performing revocation rather than for obtaining a certificate. The third argument is the value of the DNS TXT record to be provisioned. Note that as per the ACME specification, the TXT record must be provisioned at `_acme-challenge.HOSTNAME`, where HOSTNAME is the hostname given. Example call: ```sh ACME_STATE_DIR=/var/lib/acme /usr/lib/acme/hooks/foo \ challenge-dns-start example.com some-target-file \ evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA ``` SRV-ID ------ The desire for a certificate containing a SRV-ID subjectAltName is expressed by placing a file in the "desired" directory named after the SRV-ID, e.g. "\_xmpp-client.example.com". This is recognised as a SRV-ID automatically by virtue of it starting with an underscore. Since hostnames may not contain underscores, this is not ambiguous. Support for SRV-ID in ACME implementations remains to be seen. Operations ---------- ### Conform To conform a State Directory means to examine everything in the directory for consistency and validity. Permissions are changed as necessary to ensure they match the implementation's policy. The implementation verifies that all symlinks are unbroken, relative and point to locations within the State Directory. Remnant temporary files are deleted. Errors are indicated for any malformed directory (e.g. account directory with no private key, etc.) This operation is idempotent. ### Reconcile A certificate can be described as "satisfying" a target, or as being the Most Preferred Certificate for a target. These are distinct classifications, and neither implies the other. A certificate might be the Most Preferred Certificate for a target even though it does not satisfy it, because it is the "least worst option". A certificate might satisfy a target but not be the Most Preferred Certificate for it. The reconcile operation is the actual act of “building” the State Directory. - Begin by performing the Conform operation. - If there are any uncached certificates (certificate directories containing only an "url" file), cache them, waiting for them to become available (orders to finish processing, etc.) if necessary. - If there are any certificates marked for revocation (meaning that a "revoke" file exists in the certificate directory), but which are not marked as being revoked (meaning that a "revoked" file exists in the certificate directory), request revocation of the certificate and, having obtained confirmation of that revocation, create an empty file "revoked" in the certificate directory. - For each target, satisfy that target. To satisfy a target: - If there exists a certificate satisfying the target, the target is satisfied. Done. - Otherwise, request a certificate with the hostnames listed under the "request" section of the target. If a certificate cannot be obtained, fail. Satisfy the target again. When making certificate requests, use the provider/account information specified in the "request" section. To request a certificate: - Create an order with the necessary identifiers and satisfy the authorizations specified within the newly created order. If the order becomes invalid due to a failed authorization, create another order and start again, until an order's authorization requirements are successfully fulfilled or it is determined that no further forward progress can be made regarding one or more authorizations. - Having obtained an order with status "ready", form an appropriate CSR containing the SANs specified in the "request" section of the applicable target and finalise the order. Write the order URL to the State Directory; there is no need to wait for it to exit the "processing" state. - Update the "live" directory as follows: - For each (hostname, target) pair in the Hostname-Target Mapping, create a symlink for the hostname pointing to the Most Preferred Certificate for the target, if one exists. - If any certificates were requested while satisfying targets, perform the Reconcile operation again; stop. - Optionally perform cleanup operations: - Delete the certificate directories for any cullable certificates. - Delete (optionally, securely erase) the key directories for any cullable private keys. This operation is idempotent. **Satisfying targets.** A certifigate satisfies a target if: - the private key for the certificate is available in the State Directory, and - the certificate is not known to be revoked, and - all stipulations listed in the "satisfy" section of the target are met: - the "names" stipulation is met if the dNSName SANs in a given certificate are a superset of the names specified. and - the certificate is not self-signed, and - the current time lies between the Not Before and Not After times, and - the certificate is not near expiry. **Near expiry.** A certificate is near expiry if the difference between the current time and the "Not After" time is less than some implementation-specific threshold. The RECOMMENDED threshold is 30 days or 33% of the validity period, whichever is lower. **Most Preferred Certificate.** The Most Preferred Certificate for a given target is determined as follows: - Certificates which satisfy the target are preferred over certificates that do not satisfy the target. - For two certficates neither of which satisfies the target, one is preferred over the other if the first criterion in the list of the criteria for satisfying a target which it does not satisfy is later in the list of criteria than for the other. For example, a revoked certificate for which a private key is available is preferred over a certificate for which no private key is available. A self-signed certificate with the right names is preferred over a self or CA-signed certificate with the wrong names. A self-signed certificate is preferred over a revoked certificate. (A revoked certificate may not be exemptible by a user; thus even a self-signed certificate is preferable to a certificate known to be revoked.) - Certificates with later "Not After" times are preferred. **Cullability.** A certificate is cullable if: - it is expired, and - after reconcilation, it is unreferenced by any "live" symlink. A private key is cullable if: - it does not relate to any known certificate, and - it was not recently created or imported. The definition of "recently" is implementation-specific. ### Revocation A certificate is revoked by creating an empty file "revoke" in the certificate directory and reconciling. Identifiers ----------- Accounts, keys and certificates are stored in directories named by their identifiers. Their identifiers are calculated as follows: **Key ID:** Lowercase base32 encoding with padding stripped of the SHA256 hash of the subjectPublicKeyInfo constructed from the private key. **Account ID:** Take the Directory URL for the ACME server. Take the hostname, port (if applicable) and path, stripping the scheme (e.g. "example.com/directory"). If the path is "/", strip it ("example.com/" becomes "example.com"). URL-encode this string so that any slashes are percent-encoded using lowercase hexadecimal. Take this string and append "/" followed by the string formed by calculating a Key ID using the account's private key. e.g. `example.com%2fdirectory/irq7564p5siu3zngnc2caqygp3v53dfmh6idwtpyfkxojssqglta` Each account directory is thus an account key-specific subdirectory of the string formed from the directory URL. For production use the scheme MUST be "https". In some cases, it may be desirable to test using HTTP. Where an HTTP URL is specified, it is prefixed with `http:`. For example: `http:example.com%2fdirectory/irq7564p5siu3zngnc2caqygp3v53dfmh6idwtpyfkxojssqglta` **Certificate ID:** A certificate ID must be assignable before a certificate has been issued, when only the public key and order URL are known. Thus, the Certificate ID shall be the lowercase base32 encoding with padding stripped of the SHA256 hash of the order URL (or, for legacy certificates, the certificate URL). A certificate directory is invalid if the "url" file does not match the Certificate ID. Such a directory should be deleted. Temporary Use of Self-Signed Certificates ----------------------------------------- Some daemons may fail terminally when a certificate file referenced by their configuration is not present. Thus, where a client is unable to procure a certificate immediately, it MAY choose to provision a self-signed certificate referenced by symlinks under 'live' instead. This will allow a daemon to continue operating (perhaps serving non-TLS requests or requests for other hostnames) with reduced functionality. If a client uses such interim self-signed certificates, it MUST create an empty 'selfsigned' file in the certificate directory to indicate that the certificate is a self-signed certificate. The 'url' file MUST NOT exist. The 'cert' and 'fullchain' files MUST be identical, and the 'chain' file MUST exist and MUST be an empty file. The self-signed certificate MAY contain information in it which points out the configuration issue the certificate poses, e.g. by placing a short description of the problem in the O and OU fields, e.g.: OU=ACME Cannot Acquire Certificate O=ACME Failure Please Check Server Logs The Certificate ID of a self-signed certificate is the string "selfsigned-" followed by the lowercase base32 encoding with padding stripped of the SHA256 hash of the DER encoded certificate. acmetool-0.2.2/_doc/WSCONFIG.md000066400000000000000000000001441435652113300157650ustar00rootroot00000000000000# [This document has moved.](https://hlandau.github.io/acmetool/userguide#web-server-configuration) acmetool-0.2.2/_doc/contrib/000077500000000000000000000000001435652113300156655ustar00rootroot00000000000000acmetool-0.2.2/_doc/contrib/APKBUILD000066400000000000000000000030261435652113300170040ustar00rootroot00000000000000# Contributor: Hugo Landau # Maintainer: Hugo Landau # # This is a build script for the Alpine Linux build system. # Please do not submit it to Alpine Linux at this time. # # To build, put this file in an empty directory and run abuild. # You may need to setup abuild signing keys first; see Alpine documentation. # if [ "$(find version -mmin -30 2>/dev/null | wc -l)" == "0" ]; then curl -s -H 'Accept: application/vnd.github.v3+json' \ 'https://api.github.com/repos/hlandau/acmetool/releases/latest' | \ sed 's/^.*"tag_name": *"v\([^"]*\)".*$/\1/;tx;d;:x' > version.tmp || exit 1 mv version.tmp version fi pkgname=acmetool pkgver="$(cat version)" pkgrel=0 pkgdesc="ACME/Let's Encrypt client" url="https://github.com/hlandau/acmetool" arch="all" license="MIT" depends="libcap" makedepends="libcap-dev go bash git curl" install="" subpackages="$pkgname-doc" options="!strip" source="" prepare() { cd "$srcdir" git clone -b "v$pkgver" https://github.com/hlandau/acmetool || return 1 } build() { cd "$srcdir/acmetool" || return 1 make USE_BUILDINFO=1 || return 1 # For some reason this is necessary in order for the buildinfo to get # included properly. rm "$srcdir/acmetool/bin/$pkgname" || return 1 make USE_BUILDINFO=1 || return 1 } package() { install -Dm0755 "$srcdir/acmetool/bin/$pkgname" "$pkgdir"/usr/bin/$pkgname || return 1 mkdir -p "$pkgdir"/usr/share/man/man8 "$pkgdir"/usr/bin/$pkgname --help-man | gzip > \ "$pkgdir"/usr/share/man/man8/acmetool.man.gz || return 1 } acmetool-0.2.2/_doc/contrib/dns.hook000077500000000000000000000060701435652113300173410ustar00rootroot00000000000000#!/bin/bash # This is an example DNS hook script which uses the nsupdate utility to update # nameservers. The script waits until updates have propagated to all # nameservers listed for a zone. The script fails if this takes more than 60 # seconds by default; this timeout can be adjusted. # # The script is ready to use, but to use it you must create # /etc/default/acme-dns or /etc/conf.d/acme-dns and set the following options: # # # Needed if using TSIG for updates. If authenticating updates by source IP, # # not necessary. # TSIG_KEY_NAME="hmac-sha256:tk1" # TSIG_KEY="a base64-encoded TSIG key" # # # DNS synchronization timeout in seconds. Default is 60. # DNS_SYNC_TIMEOUT=60 # # # Optional: inject extra arguments and commands to nsupdate. # NSUPDATE_ARGS="-v" # nsupdate_cmds() { # # Usually not necessary: # echo zone example.com. # } # # Having done this, rename it to /usr/lib[exec]/acme/hooks/dns. # # How to test this script: # ./dns.hook challenge-dns-start example.com "" "foobar" # ./dns.hook challenge-dns-stop example.com "" "foobar" # set -e get_apex() { local name="$1" if [ -z "$name" ]; then echo "$0: couldn't get apex for $name" >&2 return 1 fi local ans="`dig +noall +answer SOA "${name}."`" if [ "`echo "$ans" | grep SOA | wc -l`" == "1" -a "`echo "$ans" | grep CNAME | wc -l`" == "0" ]; then APEX="$name" return fi local sname="$(echo $name | sed 's/^[^.]\+\.//')" get_apex "$sname" } waitns() { local ns="$1" for ctr in $(seq 1 "$DNS_SYNC_TIMEOUT"); do [ "$(dig +short "@${ns}" TXT "_acme-challenge.${CH_HOSTNAME}." | grep -- "$CH_TXT_VALUE" | wc -l)" == "1" ] && return 0 sleep 1 done # Best effort cleanup. echo $0: timed out waiting ${DNS_SYNC_TIMEOUT}s for nameserver $ns >&2 updns delete || echo $0: failed to clean up records after timing out >&2 return 1 } updns() { local op="$1" ( declare -f nsupdate_cmds >/dev/null && nsupdate_cmds "$APEX" [ -n "$TSIG_KEY" ] && echo key "$TSIG_KEY_NAME" "$TSIG_KEY" echo update $op "_acme-challenge.${CH_HOSTNAME}." 60 IN TXT "\"${CH_TXT_VALUE}\"" echo send ) | nsupdate $NSUPDATE_ARGS } [ -e "/etc/default/acme-dns" ] && . /etc/default/acme-dns [ -e "/etc/conf.d/acme-dns" ] && . /etc/conf.d/acme-dns # e.g. # TSIG_KEY_NAME="hmac-sha256:tk1" # TSIG_KEY="base64-key-value" EVENT_NAME="$1" CH_HOSTNAME="$2" CH_TARGET_FILENAME="$3" CH_TXT_VALUE="$4" [ -z "$DNS_SYNC_TIMEOUT" ] && DNS_SYNC_TIMEOUT=60 # Older versions of this script used TKIP_KEY{,_NAME} instead of # TSIG_KEY{,_NAME}. Brainfart — TKIP is part of Wi-Fi's WPA2 and has nothing to # do with DNS's TSIG. Support the old naming. if [ -n "$TKIP_KEY" ]; then TSIG_KEY="$TKIP_KEY" fi if [ -n "$TKIP_KEY_NAME" ]; then TSIG_KEY_NAME="$TKIP_KEY_NAME" fi case "$EVENT_NAME" in challenge-dns-start) get_apex "$CH_HOSTNAME" updns add # Wait for all nameservers to update. for ns in $(dig +short NS "${APEX}."); do waitns "$ns" done ;; challenge-dns-stop) get_apex "$CH_HOSTNAME" updns delete ;; *) exit 42 ;; esac acmetool-0.2.2/_doc/contrib/ovh.hook000077500000000000000000000130451435652113300173510ustar00rootroot00000000000000#!/bin/bash # # This is a hook to complete DNS challenge using OVH API. # # For this script to work you need the following prereqs: # # 1) Install ovh-cli (https://github.com/toorop/ovh-cli) # 2) Install dig (Centos/RH: bind-utils; Ubuntu/Debian: dnsutils) # 3) Register OVH API (execute ovh-cli without parameters and set environment variables: OVH_CONSUMER_KEY/OVH_APP_SECRET/OVH_APP_KEY) # 4) Place this script to /usr/lib[exec]/acme/hooks/ovh # # FAQ: # # Q: I have my domains under several OVH accounts, how do I manage them all? # A: Setup wrapper scripts and pass them to OVH_API_SCRIPTS environment variable, ie. # # echo -en "OVH_CONSUMER_KEY=key1 OVH_APP_SECRET=secret1 OVH_APP_KEY=app1 ovh-cli $*" >/usr/local/bin/ovh-ab1234 # echo -en "OVH_CONSUMER_KEY=key2 OVH_APP_SECRET=secret2 OVH_APP_KEY=app2 ovh-cli $*" >/usr/local/bin/ovh-ab1235 # chmod a+rx /usr/local/bin/ovh-ab1234 /usr/local/bin/ovh-ab1235 # export OVH_API_SCRIPTS="/usr/local/bin/ovh-ab1234 /usr/local/bin/ovh-ab1235" # # Q: Why it takes so long? # A: Dunno, ask OVH :) # You can also increase timeout by setting environment variable DNS_SYNC_TIMEOUT. # # Q: Do I need to pass my environment variables all the times? # A: No, you can place them in /etc/default/acme-ovh or /etc/conf.d/acme-ovh # # Q: How to test this script? # A: Just call "test" action and pass it a domain name (./ovh.hook is hook name here, not ovh-cli): # # ./ovh.hook test example.com # set -e configure() { if [ -z "$OVH_API_SCRIPTS" ] ; then if which ovh-cli >/dev/null 2>&1 ; then OVH_API_SCRIPTS=ovh-cli elif which ovh >/dev/null 2>&1 ; then OVH_API_SCRIPTS=ovh else echo "OVH_API_SCRIPTS were not provided and ovh-cli has not been found" >&2 exit 1 fi if [ -z "OVH_CONSUMER_KEY" ] || [ -z "$OVH_APP_SECRET" ] || [ -z "$OVH_APP_KEY" ] ; then echo "ovh-cli has been found, but one of configuration variables OVH_CONSUMER_KEY/OVH_APP_SECRET/OVH_APP_KEY is empty" >&2 exit 1 fi fi } get_apex() { local name="$1" if [ -z "$name" ]; then echo "$0: couldn't get apex for $name" >&2 return 1 fi if ! echo "$name"|grep -q "\." ; then echo "$0: \"$name\" does not have a dot, which most likely mean that \"$CH_HOSTNAME\" is not delegated (expired?)" >&2 return 1 fi if dig +noall +answer SOA "${name}." |grep -q SOA ; then APEX="$name" return fi local sname="$(echo $name | sed 's/^[^.]\+\.//')" get_apex "$sname" } get_api_script() { APEX_SCRIPT=$(for script in $OVH_API_SCRIPTS ; do if $script domain list --json 2>&1 | grep -q "\"${APEX}\"" ; then echo $script fi done) if [ -z "$APEX_SCRIPT" ] ; then echo "$0: it's not possible to manage $APEX with $OVH_API_SCRIPTS" >&2 for script in $OVH_API_SCRIPTS ; do echo "$script domains list:" >&2 $script domain list 2>&1 | sed -e 's/^/ /' >&2 echo >&2 done return 1 fi } waitns() { local ns="$1" local tick="$2" for ctr in $(seq $DNS_SYNC_TIMEOUT); do seq="$ctr/$DNS_SYNC_TIMEOUT" dig +short "@${ns}" TXT "_acme-challenge.${CH_HOSTNAME}." | grep -q "$CH_TXT_VALUE" && return 0 [ ! -z "$tick" ] && echo -n "$tick" >&2 sleep 1 done # Best effort cleanup. echo $0: timed out waiting ${DNS_SYNC_TIMEOUT}s for nameserver $ns >&2 remove_challenge || echo $0: failed to clean up records after timing out >&2 return 1 } add_challenge() { [ -z "$CH_TXT_VALUE" ] && return 1 $APEX_SCRIPT domain zone newrecord $APEX --field TXT --target "$CH_TXT_VALUE" --sub _acme-challenge --ttl 60 --json >&2 $APEX_SCRIPT domain zone reload $APEX } remove_challenge() { [ -z "$CH_TXT_VALUE" ] && return 1 recid=$($APEX_SCRIPT domain zone getrecords $APEX 2>&1 | while read id rec data ; do if [ "$rec" == "_acme-challenge.${APEX}" ] ; then echo $id ; fi ; done) for id in $recid ; do $APEX_SCRIPT domain zone delrecord $APEX $id >&2 done $APEX_SCRIPT domain zone reload $APEX } test() { echo "Managing \"$APEX\" with script: $APEX_SCRIPT" >&2 [ -z "$CH_TXT_VALUE" ] && CH_TXT_VALUE="foobarszcz" echo -n "Trying to add record _acme-challenge.${APEX} record: " >&2 add_challenge # will return json, so we don't print anything here echo -n "Checking DNSes: " >&2 for ns in $(dig +short NS "${APEX}."); do echo -n "$ns" >&2 waitns "$ns" "." echo -n " " >&2 done echo "done." >&2 echo -n "Trying to remove _acme-challenge.${APEX} record: " >&2 remove_challenge && echo "done." >&2 echo -n "Testing if there is any garbage left: " >&2 if $APEX_SCRIPT domain zone getrecords $APEX --json 2>&1 | grep -q "\"_acme-challenge.${APEX}\"" ; then echo "Oops, it seems there's _acme-challenge.${APEX} listed, please examine domain manually!" >&2 return 1 fi echo "done." >&2 echo -en "All good!\n\nDomain contents:$($APEX_SCRIPT domain zone import $APEX 2>&1)\n" >&2 } [ -e "/etc/default/acme-ovh" ] && . /etc/default/acme-ovh [ -e "/etc/conf.d/acme-ovh" ] && . /etc/conf.d/acme-ovh EVENT_NAME="$1" CH_HOSTNAME="$2" CH_TARGET_FILENAME="$3" CH_TXT_VALUE="$4" [ -z "$DNS_SYNC_TIMEOUT" ] && DNS_SYNC_TIMEOUT=180 case "$EVENT_NAME" in challenge-dns-start) configure get_apex "$CH_HOSTNAME" get_api_script remove_challenge add_challenge # Wait for all nameservers to update. for ns in $(dig +short NS "${APEX}."); do waitns "$ns" done ;; challenge-dns-stop) configure get_apex "$CH_HOSTNAME" get_api_script remove_challenge ;; test) configure get_apex "$CH_HOSTNAME" get_api_script test ;; *) exit 42 ;; esac acmetool-0.2.2/_doc/contrib/perm.example000066400000000000000000000047371435652113300202200ustar00rootroot00000000000000# In some rare cases it can be necessary to override the permissions that # acmetool sets on files. You can override those permissions using the # permissions configuration file, which should be placed at # $ACME_STATE_DIR/conf/perm. This is an example of such a file. You should be # very careful when using this file, and only include the minimum changes that # you need to make. # # Each line has the following syntax: # path-pattern file-mode dir-mode [uid gid] # # For example: # keys 0640 0750 # or # keys 0640 0750 root exim # # If you specify a UID, you must also specify a GID and vice versa. # UIDs and GIDs can be specified numerically, and on some platforms # they may also be specifiable as names. # # The special UID/GID value "$r" means the current UID/GID of the running # acmetool process; you can use this to ensure that the file UID/GID is # enforced to the user which acmetool runs as. # # Not specifying UID/GID values, or specifying both as "-", means that acmetool # will not pay attention to file ownership. Files will be created with their # "natural" owner (i.e., the UID/GID under which acmetool is running). # # Mode enforcement cannot be disabled. # # Nothing acmetool does should affect POSIX ACLs, if you wish to use them. # # A path-pattern is a glob pattern. Specifying the same path-pattern as a built # in permissions rule overrides that rule. You cannot place two entries for # the same path-pattern in this file. acmetool uses the longest matching pattern # when deciding what rule to use when enforcing permissions. # # The default rules are shown below: # # . 0644 0750 # Default for anything without a longer match # accounts 0600 0700 # desired 0644 0755 # live 0644 0755 # certs 0644 0755 # certs/*/haproxy 0600 0700 # Support for the HAProxy extension; contains private keys # keys 0600 0700 # conf 0644 0755 # tmp 0600 0700 # Do NOT change this # # If you wish to disable a path-pattern rule allowing policy to be inherited # from a shorter match, you can do this using the special keyword 'inherit': # # path-pattern inherit # # For example, maybe you want to make the whole directory restricted: # . 0600 0700 # accounts inherit # certs inherit # conf inherit # desired inherit # keys inherit # # Again, you should rarely ever need to use this file. When you use this file, # add only the entries that you absolutely need. acmetool-0.2.2/_doc/contrib/response-file.yaml000066400000000000000000000024111435652113300213220ustar00rootroot00000000000000# This is a example of a response file, used with --response-file. # It automatically answers prompts for unattended operation. # grep for UniqueID in the source code for prompt names. # Pass --response-file to all invocations, not just quickstart. # If you don't pass --response-file, it will be looked for at "(state-dir)/conf/responses". # You will typically want to use --response-file with --stdio or --batch. # For dialogs not requiring a response, but merely acknowledgement, specify true. # This file is YAML. Note that JSON is a subset of YAML. "acme-enter-email": "hostmaster@example.com" "acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf": true "acmetool-quickstart-choose-server": https://acme-staging.api.letsencrypt.org/directory "acmetool-quickstart-choose-method": redirector # This is only used if "acmetool-quickstart-choose-method" is "webroot". "acmetool-quickstart-webroot-path": "/var/www/foo/bar/.well-known/acme-challenge" "acmetool-quickstart-complete": true "acmetool-quickstart-install-cronjob": true "acmetool-quickstart-install-haproxy-script": true "acmetool-quickstart-install-redirector-systemd": true "acmetool-quickstart-key-type": ecdsa "acmetool-quickstart-rsa-key-size": 4096 "acmetool-quickstart-ecdsa-curve": nistp256 acmetool-0.2.2/_doc/contrib/tinydns.hook000077500000000000000000000151461435652113300202510ustar00rootroot00000000000000#!/bin/sh set -e # This is a DNS hook that updated the tinydns (djbdns/dbndns) database. For a # small period (default 90 secs), waits for dns propagation. On fail, reverts. # Uses dig for resolution. # # Tries to figure out your tinydns root directory (overwrite if necessary). # When the root directory contains a Makefile, invokes make(1), else # tinydns-data(8). That way, you can notify downstream DNS server, eg with # http://tindyns.org/dnsnotify # # Copy, move, or link this script to $ACME_HOOKS_DIR/tinydns # # You can test this script with # ./tinydns.hook challenge-dns-start example.com "" "deadbeef" # ./tinydns.hook challenge-dns-stop example.com "" "deadbeef" # # This script reads /etc/default/acme-tinydns and /etc/conf.d/acme-tinydns # You can override the following variables there: # # DNS_SYNC_MAX Maximum time in seconds to wait for DNS propagation # (default 90) # SERVICE_ROOT Directory that contains daemontools(8) services # (default one of /service /etc/service /etc/sv) # SERVICE Directory with the tinydns(8) service for # daemontools(8) (default ${SERVICE_ROOT}/tinydns) # SERVICE_ENV Directory with the envdir(8) environment for the # tinydns(8) service, if used. (default ${SERVICE}/en) # ROOT Directory containing tinydns(8)'s data, especially # the `data` file. (default: when ${SERVICE_ENV}/ROOT # is a file, its contents, otherwise ${SERVICE}/root) # EXIT_UNKNOWN_EVENT="42" DATA_MARKER_START='# -- ACMETOOL TINYDNS HOOK START --' DATA_MARKER_STOP='# -- ACMETOOL TINYDNS HOOK STOP --' # return 1 or 0 whether the given command exists have_command() { command -v "${1}" 2>&1 >/dev/null; } # strips everything before second-level-domain. TDLs not supported. get_domain() { echo "${1}" | sed -e 's/^\([^.]\{1,\}\.\)\{0,\}\([^.]\{1,\}\.[^.]\{1,\}\.\{0,1\}\)$/\2/' } # get primary dns server, prefer the one we are provisioning get_ns() { if [ -e "${SERVICE_ENV}/IP" ]; then cat "${SERVICE_ENV}/IP" else DOMAIN="$(get_domain "${1}")" dig +short SOA "${DOMAIN}" | cut -d' ' -f1 fi } get_all_ns() { DOMAIN="$(get_domain "${1}")" dig +short NS "${DOMAIN}" } # parse dnsq/dnsqr/tinydns-get output (we care for 1st field of data) #answer: example.com ttl RECORD data parse_dnsq() { grep '^answer: ' | cut -d' ' -f5; } # parse DNS TXT record that still contains length prepended (as dnsq) parse_dnstxt() { sed -e 's/^\(\\[[:digit:]]\{3\}\)\|.//'; } # parse DNS TXT record still with quotes (as dig) parse_digtxt() { TXT="${1#\"}"; echo "${TXT%\"}"; } # Get content of given TXT record via DNS (opt from SERVER) get_txt() { TXT_HOST="${1}" SERVER="${2}" if [ -z "${SERVER}" ]; then parse_digtxt "$(dig +short TXT "${TXT_HOST}")" else parse_digtxt "$(dig +short "@${SERVER}" TXT "${TXT_HOST}")" fi } controls_domain() ( cd "${ROOT}" tinydns-get soa "${1}" | grep -q '^answer:' # if no answer, then no control ) # set all variable we need and such prepare() { # set reliable path PATH="$(command -p getconf PATH):${PATH}" # add /command if available [ -d /command ] && PATH="/command:${PATH}" # make sure we all commands we need for cmd in tinydns-get dig sleep sed grep cut mv wait echo; do have_command "${cmd}" done # find tinydns root for CANDIDATE in "${SERVICE_ROOT}" /service /etc/service /etc/sv; do if [ -d "${CANDIDATE}" ]; then SERVICE_ROOT="${CANDIDATE}"; break fi done SERVICE="${SERVICE:-${SERVICE_ROOT}/tinydns}" SERVICE_ENV="${SERVICE_ENV:-${SERVICE}/env}" if [ -z "${ROOT}" ]; then if [ -f "${SERVICE_ENV}/ROOT" ]; then ROOT="$(cat "${SERVICE_ENV}/ROOT")" else ROOT="${SERVICE}/root" fi fi # no tinydns root, no operation [ -d "${ROOT}" ] || exit 1 } # Get content of given TXT record via database get_txt_record() ( cd "${ROOT}" tinydns-get txt "${1}" | parse_dnsq | parse_dnstxt ) # write txt record to database set_txt_record() ( cd "${ROOT}" if grep -q "${DATA_MARKER_START}" data; then :; else echo "${DATA_MARKER_START}" >> data echo "${DATA_MARKER_STOP}" >> data fi sed -e "/${DATA_MARKER_STOP}/i\'${1}:${2}:300" data > data.acmetmp \ && mv data.acmetmp data ) # remove txt record from database del_txt_record() ( cd "${ROOT}" sed -e "/^'${1}:${2}/d" data > data.acmetmp \ && mv data.acmetmp data ) # update tinydns database (aka commit) update() ( cd "${ROOT}" if have_command make && [ -f Makefile ]; then make else tinydns-data fi ) # reload database and check this worked via DNS reload() ( TXT_HOST="${1}" CHALLENGE="${2}" update index="${DNS_SYNC_MAX:-90}" export NS_STATUS=1 get_all_ns "${TXT_HOST}" | while read NAMESERVER; do while [ "${index}" -gt 0 ]; do sleep 5 & if [ -z "${CHALLENGE}" ]; then if [ -z "$(get_txt "${TXT_HOST}" "${NAMESERVER}")" ]; then export NS_STATUS=0; break; fi else if [ "$(get_txt "${TXT_HOST}" "${NAMESERVER}")" = "${CHALLENGE}" ]; then NS_STATUS=0; break; fi fi index="$((${index} - 5))" wait done [ "${NS_STATUS}" -eq 0 ] || return 1 # reached here because of timeout done return 0 ) # CALLBACK: insert acme challange start() { HOST="${1}"; DOMAIN="${2}"; CHALLENGE="${3}" TXT_HOST="_acme-challenge.${HOST}" TXT_RECORD="$(get_txt_record "${TXT_HOST}" )" [ "${TXT_RECORD}" = "${CHALLENGE}" ] && return 0 # challenge already there [ -z "${TXT_RECORD}" ] # challenge not empty, doesn't match ours set_txt_record "${TXT_HOST}" "${CHALLENGE}" reload "${TXT_HOST}" "${CHALLENGE}" \ || (del_txt_record "${TXT_HOST}"; update; return 1) } # CALLBACK: remove acme challange stop() { HOST="${1}"; DOMAIN="${2}"; CHALLENGE="${3}" TXT_HOST="_acme-challenge.${HOST}" [ "$(get_txt_record "${TXT_HOST}" )" = "${CHALLENGE}" ] del_txt_record "${TXT_HOST}" reload "${TXT_HOST}" "" \ || (set_txt_record "${TXT_HOST}" "${CHALLENGE}" ; update; return 1) } # include configuration from known locations [ -e "/etc/default/acme-tinydns" ] && . /etc/default/acme-tinydns [ -e "/etc/conf.d/acme-tinydns" ] && . /etc/conf.d/acme-tinydns # Contract is: # ACME_STATE_DIR=/var/lib/acme /usr/lib/acme/hooks/tinydns \ # challenge-dns-start hostname.example.com target_file challenge EVENT="${1}" HOST="${2}" TARGET_FILE="${3}" CHALLENGE="${4}" case "${EVENT}" in challenge-dns-*) prepare DOMAIN="$(get_domain ${HOST})" controls_domain "${DOMAIN}" "${EVENT##challenge-dns-}" "${HOST}" "${DOMAIN}" "${CHALLENGE}" ;; *) exit "${EXIT_UNKNOWN_EVENT}" ;; esac acmetool-0.2.2/_doc/guide/000077500000000000000000000000001435652113300153225ustar00rootroot00000000000000acmetool-0.2.2/_doc/guide/ENTER000077500000000000000000000001531435652113300161240ustar00rootroot00000000000000#!/bin/sh set -euo pipefail exec nix-shell -p ruby libxslt libxml2 docbook5_xsl plantuml "$@" #asciidoctor acmetool-0.2.2/_doc/guide/Makefile000066400000000000000000000017501435652113300167650ustar00rootroot00000000000000ifneq ($(NIX_PATH),) # NixOS DOCBOOK_XSL := $(shell echo "$(buildInputs)" | tr ' ' '\n' | grep docbook)/xml/xsl/docbook else # Alpine DOCBOOK_XSL := $(shell echo /usr/share/xml/docbook/xsl-stylesheets-*) endif all: out/index.xhtml out/img out/style.css clean: rm -rf tmp out deploy: rsync -rltv --perms=D755,F644 out/ drone@rem.devever.net::doc/acmetool/ out/img: img rsync -a "$<"/ "$@"/ out/style.css: style.css out/index.xhtml cat out/docbook.css style.css > "$@" out/index.xhtml: out/index.docbook tmp/acmetool.8.docbook include-man.xsl to-xhtml.xsl rm -f docbook-xsl ln -s "$(DOCBOOK_XSL)" docbook-xsl xsltproc \ --stringparam html.stylesheet style.css \ --nonet --output "$@" \ to-xhtml.xsl "$<" out/index.docbook: tmp/index.docbook tmp/acmetool.8.docbook include-man.xsl to-xhtml.xsl xsltproc --xinclude --xincludestyle --nonet --output "$@" include-man.xsl "$<" tmp/%.docbook: %.adoc mkdir -p tmp asciidoctor -r asciidoctor-diagram -b docbook5 -o "$@" "$<" acmetool-0.2.2/_doc/guide/acmetool.8.adoc000066400000000000000000000156341435652113300201340ustar00rootroot00000000000000ACMETOOL(8) =========== Hugo Landau :doctype: manpage :manmanual: ACMETOOL :mansource: ACMETOOL NAME ---- acmetool - request certificates from ACME servers automatically SYNOPSIS -------- *acmetool* ['flags'] 'command' ['args'] [[description]] DESCRIPTION ----------- acmetool is a utility for the automated retrieval, management and renewal of PKI certificates from ACME servers such as those provided by Let's Encrypt. The tool emphasises automation, idempotency and the minimisation of state. You use acmetool by configuring targets (typically using the "want" command). acmetool then requests certificates as necessary to satisfy the configured targets. New certificates are requested where existing ones are soon to expire. acmetool stores its state in a state directory. It can be specified on invocation via the *--state* option; otherwise, the path in environment variable *ACME_STATE_DIR* is used, or, failing that, the path '/var/lib/acme' (recommended). The '--xlog' options control the logging. The '--service' options control privilege dropping and daemonization and are applicable only to the 'redirector' subcommand. [[global-options]] GLOBAL OPTIONS -------------- ### FUNDAMENTAL OPTIONS *--state=/var/lib/acme*:: Path to the state directory (defaults to environment variable *ACME_STATE_DIR*, or, failing that, '/var/lib/acme'.) *--hooks=/usr/lib/acme/hooks*:: Path to the notification hooks directory (defaults to environment variable *ACME_HOOKS_DIR* or, failing that, '/usr/lib/acme/hooks' or '/usr/libexec/acme/hooks', depending on your system.) You may disable hooks by setting this to '/var/empty'. ### INFORMATION OPTIONS *--help*:: Show context-sensitive help (also try --help-long). *--version*:: Print version information. ### INTERACTION OPTIONS *--batch*:: Never attempt interaction; useful for cron jobs. If it is impossible to continue without interaction, exits unsuccessfully. (acmetool can still obtain responses from a response file, if one was provided.) *--stdio*:: Don't attempt to use console dialogs for interaction; fall back to stdio prompts. *--response-file=RESPONSE-FILE*:: Read dialog responses from the given YAML file. (Defaults to '$ACME_STATE_DIR/conf/responses', if it exists.) ### LOGGING OPTIONS *--xlog.facility=daemon*:: Syslog facility to use. *--xlog.syslog*:: Log to syslog? Defaults to false. *--xlog.syslogseverity=DEBUG*:: Syslog severity limit. *--xlog.journal*:: Log to systemd journal? Defaults to false. *--xlog.journalseverity=DEBUG*:: Systemd journal severity limit. *--xlog.severity=NOTICE*:: Log severity (any syslog severity name or number). *--xlog.file=""*:: Log to filename. Disabled by default. *--xlog.fileseverity=TRACE*:: File logging severity limit. *--xlog.stderr*:: Log to stderr? *--xlog.stderrseverity=TRACE*:: Stderr logging severity limit. ### REDIRECTOR OPTIONS *--service.cpuprofile=""*:: Redirector mode: Write CPU profile to file. *--service.debugserveraddr=""*:: Redirector mode: Address for debug server to listen on (insecure, do not specify a public address). Disabled by default. *--service.uid=""*:: Redirector mode: UID to run as. If not specified, doesn't drop privileges. Note: On Linux, this option is only available if acmetool was built with cgo. You can find out whether this is the case by running 'acmetool --version'. Note: Regardless of platform, this value can only be specified by name rather than numerically if acmetool was built with cgo. *--service.gid=""*:: Redirector mode: GID to run as. If not specified, doesn't drop privileges. See --service.uid for caveats. *--service.daemon*:: Redirector mode: Run as a daemon? (Doesn't fork.) *--service.stderr*:: Redirector mode: Keep stderr open when daemonizing. *--service.chroot=""*:: Redirector mode: Chroot to a directory. If you set this, you must set a UID and GID. Set to '/' to disable. *--service.pidfile=""*:: Redirector mode: Write PID to file with given filename and hold a write lock. *--service.fork*:: Redirector mode: Fork? Implies --service.daemon. Not recommended. [[subcommands]] SUBCOMMANDS ----------- [[fbquickstart_ltflagsgtfr]] *quickstart []* ~~~~~~~~~~~~~~~~~~~~~~ Interactively ask some getting started questions and install default hooks (recommended). *--expert*:: Ask more questions in quickstart wizard [[fbreconcilefr]] *reconcile* ~~~~~~~~~~~ Reconcile ACME state, idempotently requesting and renewing certificates to satisfy configured targets. This is the default command. [[fbwant_ltflagsgt_lthostnamegtfr]] *want [] ...* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Add a target with one or more hostnames *--no-reconcile*:: Do not reconcile automatically after adding the target. [[fbunwant_lthostnamegtfr]] *unwant ...* ~~~~~~~~~~~~~~~~~~~~~~ Modify targets to remove any mentions of the given hostnames [[fbcull_ltflagsgtfr]] *cull []* ~~~~~~~~~~~~~~~~ Delete expired, unused certificates *-n, --simulate*:: Show which certificates would be deleted without deleting any. [[fbstatusfr]] *status* ~~~~~~~~ Show active configuration [[fbaccountthumbprintfr]] *account-thumbprint* ~~~~~~~~~~~~~~~~~~~~ Prints account thumbprints. [[fbrevoke_ltcertificateidorpathgtfr]] *revoke []* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Revoke a certificate. [[fbredirector_ltflagsgtfr]] *redirector []* ~~~~~~~~~~~~~~~~~~~~~~ HTTP to HTTPS redirector with challenge response support. *--path=PATH*:: Path to serve challenge files from. Defaults to '/var/run/acme/acme-challenge'. *--challenge-gid=CHALLENGE-GID*:: GID to chgrp the challenge path to. Optional. *--read-timeout=10s*:: Maximum duration before timing out read of the request. Defaults to '10s'. *--write-timeout=20s*:: Maximum duration before timing out write of the request. Defaults to '20s'. *--status-code=308*:: HTTP status code to use when redirecting. Defaults to '308'. *--bind=":80"*:: Bind address for redirector. Defaults to ':80'. [[fbtestnotify_lthostnamegtfr]] *test-notify [...]* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Test-execute notification hooks as though given hostnames were updated. [[fbimportjwkaccount_ltproviderurlgt_ltpri]] *import-jwk-account * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Import a JWK account key. [[fbimportpemaccount_ltproviderurlgt_ltpri]] *import-pem-account * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Import a PEM account key. [[fbimportkey_ltprivatekeyfilegtfr]] *import-key * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Import a certificate private key. [[fbhelp_ltcommandgtfr]] *help [...]* ~~~~~~~~~~~~~~~~~~~~~ Show help. [[author]] AUTHOR ------ © 2015—2018 Hugo Landau MIT License [[see_also]] SEE ALSO -------- Documentation: Report bugs at: acmetool-0.2.2/_doc/guide/img/000077500000000000000000000000001435652113300160765ustar00rootroot00000000000000acmetool-0.2.2/_doc/guide/img/acmetool-logo-black.png000077500000000000000000000030631435652113300224240ustar00rootroot00000000000000PNG  IHDRFŢtEXtSoftwareAdobe ImageReadyqe<3PLTE???___///OOOooow8IDATxZ0 ,Ӷ$e9W F#GOdn۾;;;~ ; `'xrCkTX-koǐ'aA䁽c[ѳ= wzSI /0e]Jrϳ{m&ɳwJV;clK1FGq\K+Q^y9ōu<9M{YU;ϋG3Mr;o^/LLe!lu,Lt!Ԡ6 dY }>8 cK`*o( .!6 FtizQyU 8>nS i*J: '_!/ű:KLv"?0;-2v+\yXMndâ9Rtn& ԁv\r@mQIX݁oi]TiL>Gw<-/5w o &5CnNP[ ubpTpk8izgȗf9>TvԭCAɎ%h@ut!!d%DH"C0:ψ.\Eע*$ AgJSJι@´~BZjN"Q<‡'m\!w9I#as/ȩM w;]b b1ITޤfʎ<4OzPH8c;r .v P#[ʬ9i$峸(4}GM r"o+Uh̴́u04~X-=@Qtk'O+W?*np`Ra1k,QX<))vK_mustYZpuHb)}B5{Rl#+iNlSt~:l'dg!퐏3z<Cs.O\qy߅ouR>-;i`s^Xl|mm{~YY+ic6Ό)F#< c#' E<:NyNd،=#"(p?IENDB`acmetool-0.2.2/_doc/guide/img/acmetool-logo.png000077500000000000000000000030561435652113300213540ustar00rootroot00000000000000PNG  IHDRFŢtEXtSoftwareAdobe ImageReadyqe<3PLTE@@@ 000ppp```PPPIDATxZَ* VۄT߫My`z۾;;;~ ; `'xrCkTX-cN1IY|y` 2p<Kr9}#Tv/q { 6 Ns-6=ulf5x&(y**]]I ZyvM]$yN~g0|m%?ٸۢɓ@AniTi̐>[w<-/5w o &ÄnNP_ vb`T'NZ$egBտx3w+Pвcu P\<%D$ĕ(P|Pq|FחTrABxI** rB"ȕs}M+D?!- r5׀̨y/Õ5\$/iX!+a5ҋpQySFG(;g`~/sB^JBIlQ5r9_y@nc[TkG߮ &KPhٚкH5H8c;s . Т#[l9j䳸),}Gքm[9W"o'-"60Ӟhd C yk.E8z@Vzb~oD@lGu̪X3ɍo9~bzn f7gG=Nni7/TOMw#DVEy;HG#FʖyBvA 8ꭕVh.ӥ 2.OuAx<n`OkZjLcm2O5 \⯳!WD[p%lSkiGe^m_G>Or'emʜyɰw'v,G? "".4n{IENDB`acmetool-0.2.2/_doc/guide/include-man.xsl000066400000000000000000000013041435652113300202440ustar00rootroot00000000000000 Manual Pages acmetool-0.2.2/_doc/guide/index.adoc000066400000000000000000000774151435652113300172770ustar00rootroot00000000000000[[acmetool-manual]] = The Acmetool Manual :doctype: book [[users-guide]] = User's Guide [[introduction-design-philosophy]] == Introduction & Design Philosophy acmetool is an easy-to-use command line tool for automatically acquiring TLS certificates from ACME servers such as Let’s Encrypt, designed to flexibly integrate into your webserver setup to enable automatic verification. *Non-interference.* Unlike the official Let’s Encrypt client, this doesn’t modify your web server configuration. *Target-oriented and idempotent.* acmetool is designed to work like ``make'': you specify what certificates you want, and acmetool obtains certificates as necessary to satisfy those requirements. If the requirements are already satisfied, acmetool doesn’t do anything when invoked. Thus, acmetool is ideally suited for use on a cron job; it will do nothing until certificates are near expiry, and then obtain new ones. *Clear and minimal state.* acmetool is designed to minimise the use of state and be transparent in the state that it does use. All state, including certificates, is stored in a single directory, by default `/var/lib/acme`. The schema for this directory is simple, comprehensible and https://github.com/hlandau/acme/blob/master/_doc/SCHEMA.md[documented.] *Stable filenames.* The certificate for a given hostname `example.com` always lives at `/var/lib/acme/live/example.com/{cert,chain,privkey}`. This is a symlink to the real certificate directory and gets changed as certificates are renewed. *Fully automatic renewal.* acmetool can automatically reload your webserver when it changes the target of a `live` symlink. In conjunction with acmetool’s use of stable filenames and idempotent design, this means that renewal can be fully automatic. *Flexible validation methods.* acmetool supports six different validation methods: * *Webroot:* acmetool places challenge files in a given directory, allowing your normal web server to serve them. You must ensure the directory is served at `/.well-known/acme-challenge/`. * *Proxy:* When acmetool needs to validate for a domain, it temporarily spins up a built-in web server on port 402 or 4402 (if being used under the non-root validation mode). You configure your web server to proxy requests for `/.well-known/acme-challenge/` to this server at the same path. * *Stateless:* You configure your webserver to respond statelessly to challenges for a given account key without consulting acmetool. This requires nothing more than a one-time web server configuration change and no ``moving parts''. * *Redirector:* If the only thing you want to do with port 80 is redirect people to port 443, you can use acmetool’s built in redirector HTTP server. You must ensure that your existing web server does not listen on port 80. acmetool redirects requests to HTTPS, but its control of port 80 ensures it can complete validation. * *Listen:* Listen on port 80 or 443. This is only really useful for development purposes. * *Hook:* You can write custom shell scripts or binary executables which acmetool invokes to provision challenges at the desired location. *Non-root operation.* If you don’t want to trust acmetool, you can setup acmetool to operate without running as root. If you don’t have root access to a system, you may still be able to use acmetool by configuring it to use a local directory and webroot mode. *Designed for automation.* acmetool is designed to be fully automatable. Response files allow you to run the quickstart wizard automatically. [[installation]] == Installation You can install acmetool by building from source or by using a binary release. Both are easy. *Installing: using binary releases.* https://github.com/hlandau/acme/releases[Binary releases are found here.] Unpack, copy the binary to `/usr/local/bin/acmetool` (or wherever you like), and you’re done. `_cgo` releases are preferred over non-`_cgo` releases where available, but non-`_cgo` releases may be more compatible with older OSes. (The main drawback of non-`_cgo` releases is that they exhibit reduced functionality in relation to privilege dropping and daemonization functionality for the redirector daemon.) *Installing: Ubuntu users.* A binary release PPA, `ppa:hlandau/rhea` (package `acmetool`) is available. [source,sh] ---- $ sudo add-apt-repository ppa:hlandau/rhea $ sudo apt-get update $ sudo apt-get install acmetool ---- You can also https://launchpad.net/~hlandau/+archive/ubuntu/rhea/+packages[download .deb files manually.] (Note: There is no difference between the .deb files for different Ubuntu release codenames; they are interchangeable and completely equivalent.) *Installing: Debian users.* The Ubuntu binary release PPA also works with Debian: .... # echo 'deb http://ppa.launchpad.net/hlandau/rhea/ubuntu xenial main' > /etc/apt/sources.list.d/rhea.list # apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 9862409EF124EC763B84972FF5AC9651EDB58DFA # apt-get update # apt-get install acmetool .... You can also https://launchpad.net/~hlandau/+archive/ubuntu/rhea/+packages[download .deb files manually.] (Note: There is no difference between the .deb files for different Ubuntu release codenames; they are interchangeable and completely equivalent.) *Installing: RPM-based distros:* https://copr.fedorainfracloud.org/coprs/hlandau/acmetool/[A copr RPM repository is available.] If you have `dnf` installed: [source,bash] ---- $ sudo dnf copr enable hlandau/acmetool $ sudo dnf install acmetool ---- Otherwise use the `.repo` files on the https://copr.fedorainfracloud.org/coprs/hlandau/acmetool/[repository page] and use `yum`, or download RPMs and use `rpm` directly. *Installing: Arch Linux users.* https://aur.archlinux.org/packages/acmetool-git/[An AUR PKGBUILD for building from source is available.] [source,sh] ---- $ wget https://aur.archlinux.org/cgit/aur.git/snapshot/acmetool-git.tar.gz $ tar xvf acmetool-git.tar.gz $ cd acmetool-git $ makepkg -s $ sudo pacman -U ./acmetool*.pkg.tar.xz ---- *Installing: Alpine Linux users.* https://github.com/hlandau/acme/blob/master/_doc/APKBUILD[An APKBUILD for building from source is available.] *Installing: from source.* Clone, make, make install. You will need Go 1.5.2 or later installed to build from source. If you are using Linux, you will need to make sure the development files for `libcap` are installed. This is probably a package for your distro called `libcap-dev` or `libcap-devel` or similar. [source,sh] ---- # This is necesary to work around a change in Git's default configuration # which hasn't yet been accounted for in some places. $ git config --global http.followRedirects true $ git clone https://github.com/hlandau/acme $ cd acme $ make && sudo make install ---- *Installing: from source (existing GOPATH).* The Makefile is intended to make things easy for users unfamiliar with Go packaging conventions. If you know what a GOPATH is and have one set up, you can and should instead simply do: [source,sh] ---- $ git config --global http.followRedirects true $ go get -u github.com/hlandau/acme/cmd/acmetool $ sudo cp $GOPATH/bin/acmetool /usr/local/bin/acmetool ---- (Note: Although use of cgo is recommended, building without cgo is supported.) [[after-installation]] == After installation *Initial configuration.* Having installed acmetool, run the quickstart wizard for a guided setup. You may wish to ensure you have `dialog` in your PATH, but acmetool will fallback to basic stdio prompts if it’s not available. [source,sh] ---- $ sudo acmetool quickstart ---- If you don’t want to run acmetool as root, see the link:#annex-root-configured-non-root-operation[non-root setup guide]. Pass `--expert` to quickstart if you want to choose what key parameters to use (RSA or ECDSA, RSA key size, ECDSA curve). By default 2048-bit RSA is used. If you want to automate the quickstart process, see the section on response files below. It is safe to rerun quickstart at any time. *Configuring your web server.* Once you’ve completed the quickstart, you should configure your web server as necessary to enable validation. See the _Web server configuration_ section below. *Obtaining certificates.* Once everything’s ready, simply run: [source,sh] ---- $ sudo acmetool want example.com www.example.com ---- This adds a target desiring a certificate for hostnames `example.com` and `www.example.com`. You can specify as many hostnames (SANs) as you like. Whenever you run acmetool in the future, it’ll make sure that a certificate for these hostnames is available and not soon to expire. acmetool lumps hostnames together in the same certificate. If you want `example.com` and `www.example.com` to be separate certificates, use separate `want` commands to configure them as separate targets: [source,sh] ---- $ sudo acmetool want example.com $ sudo acmetool want www.example.com ---- If all went well, your certificate should be available at `/var/lib/acme/live/example.com`. This is a directory containing PEM files `cert`, `chain`, `fullchain` and `privkey`. The use of these files varies by application; typically you will use only a subset of these files. *Troubleshooting.* If all didn’t go well, you might find it helpful to run with debug logging: [source,sh] ---- $ sudo acmetool --xlog.severity=debug ---- (There’s no need to run ``want'' again; the targets are recorded even if reconciliation is not successful.) *Auto-renewal.* acmetool offers to install a cronjob during the quickstart process. This simply runs `acmetool --batch`, which will idempotently ensure that all configured targets are satisfied by certificates not soon to expire. (`--batch` here ensures that acmetool doesn’t try to ask any questions.) *Auto-renewal: reloading your webserver.* When acmetool refreshes a certificate, it changes the symlink in `live` and executes hook scripts to reload your web server or do whatever you want. Specifically, it executes any executable files in `/usr/lib/acme/hooks` (or `/usr/libexec/acme/hooks` if on a distro that uses libexec). You can drop your own executable files here, and acmetool will invoke them when it changes certificates. (For information on the calling convention, see https://github.com/hlandau/acme/blob/master/_doc/SCHEMA.md#notification-hooks[SCHEMA.md].) `acmetool quickstart` installs some default hooks applicable to common webservers. These hooks contain the string `#!acmetool-managed!#`. acmetool reserves the right to overwrite any file containing this string with a newer version of the script, in the event that the default scripts are updated in subsequent versions of acmetool. If you make changes to a default script and do not wish them to be overwritten, you should remove this line to ensure that your changes are not overwritten. However, note that the default hook scripts are designed to be configurable and it will be rare that you need to modify the scripts themselves. If you encounter a situation where you need to change the script itself, you may consider whether it would be appropriate to file an enhancement request. The string `#!acmetool-managed!#` must be present near the start of the file in order to be detected. If you want to disable a default hook entirely, you should replace it with an empty file rather than deleting it, as `acmetool quickstart` will automatically install absent default hooks. *Default hook scripts: the `reload` hook.* The reload hook is a default hook installed by `acmetool quickstart`. It reloads a list of services using commands specific to the distro. The correct command is detected automatically; `service $SERVICE reload`, `systemctl reload $SERVICE`, and `/etc/init.d/$SERVICE reload` are supported. A default list of services is provided which includes the most common webserver service names. This list can be customised using the `reload` hook configuration file. The `reload` hook configuration file is located at `/etc/conf.d/acme-reload` or `/etc/default/acme-reload`; the correct path depends on the conventions of your distro. It is a sourced shell file which can modify the default configuration variables of the `reload` script. Currently, the only variable is the `SERVICES` variable, a space-separated list of service names. You can overwrite the services list outright, or append to it like so: [source,sh] ---- # Example reload hook configuration file adding a service to the list of # services to be restarted. SERVICES="$SERVICES cherokee" ---- *Default hook scripts: the `haproxy` hook.* The haproxy hook is a default hook which `acmetool quickstart` can optionally install. It only offers to install this hook if HAProxy is detected as being installed on the system. HAProxy is rather bizarre in its TLS configuration requirements; it requires certificates and private key to be appended together in the same file. `acmetool` does not support this natively and is unlikely ever to as a default configuration for security reasons. Instead, the `haproxy` hook creates the necessary files for HAProxy from the certificate and private key files whenever they are updated. Thus, additional copies of the private key are only made when necessary to support HAProxy. *Inside the state directory.* acmetool aims to minimise the use of state and be transparent about the state it does keep. When you run `acmetool want`, acmetool does these things: * It configures a new target by writing a YAML file to `/var/lib/acme/desired/` describing the desired hostnames. * It runs the default command, `reconcile`, to ensure that all targets are met. To demonstrate, you can replicate the function of `acmetool want`: [source,sh] ---- $ sudo touch /var/lib/acme/desired/example.com $ sudo acmetool ---- Target files live in the `desired` directory. An empty target file defaults to its filename as the target hostname. https://github.com/hlandau/acme/blob/master/_doc/SCHEMA.md[More information on the format of the acmetool state directory and target files.] [[web-server-configuration-challenges]] == Web server configuration: challenges What web server configuration you need to do depends on the validation method you have selected. *Redirector mode.* No configuration required, but ensure that your web server is not listening on port 80 and that the redirector service (`acmetool redirector --service.daemon --service.uid=`__uid-to-drop-privileges-to__) is started. *Proxy mode: nginx/tengine.* You can configure nginx/tengine for use with acmetool in proxy mode as follows: [source,nginx] ---- http { server { ... your configuration ... location /.well-known/acme-challenge/ { proxy_pass http://acmetool; } } upstream acmetool { # (Change to port 4402 if using non-root mode.) server 127.0.0.1:402; } } ---- This configuration will need to be repeated for each vhost. You may wish to avoid duplication by placing the applicable configuration in a separate file and including it in each vhost. *Proxy mode: Apache httpd.* [source,apache] ---- # (Change to port 4402 if using non-root mode.) ProxyPass "/.well-known/acme-challenge" "http://127.0.0.1:402/.well-known/acme-challenge" ---- Ensure you load the modules `mod_proxy` and `mod_proxy_http`. *Proxy mode: Changing port.* If you need to change the ports on which acmetool listens, see the `request: challenge: http-ports` directive. See link:#the-state-storage-schema[State storage schema]. *Webroot mode.* If you don’t have a particular webroot path in mind, consider using `/var/run/acme/acme-challenge` as a recommended standard. `acmetool` defaults to this as a webroot path if you don’t explicitly configure one. (See ``Challenge Completion Philosophy'' below.) *Webroot mode: nginx/tengine.* [source,nginx] ---- http { server { location /.well-known/acme-challenge/ { alias /var/run/acme/acme-challenge/; } } } ---- Note that the configuration will need to be repeated for each vhost. You may wish to avoid duplication by placing the applicable configuration in a separate file and including it in each vhost. *Webroot mode: Apache httpd.* [source,apache] ---- Alias "/.well-known/acme-challenge/" "/var/run/acme/acme-challenge/" AllowOverride None Options None # If using Apache 2.4+ Require all granted # If using Apache 2.2 and lower Order allow, deny Allow from all ---- *Hook mode.* See link:#challenge-hooks[Challenge Hooks]. *Stateless mode.* In stateless mode, you configure your webserver to respond to challenge requests without consulting acmetool. A single account key is nominated. This is one of the most reliable and least error-prone methods for simple cases. *Stateless mode: nginx/Tengine.* Replace `ACCOUNT_THUMBPRINT` in the example below with your account thumbprint. You can retrieve your account thumbprint by running `acmetool account-thumbprint`. The first part of each line output is the account thumbprint. [source,nginx] ---- http { server { location ~ "^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)$" { default_type text/plain; return 200 "$1.ACCOUNT_THUMBPRINT"; } } } ---- *Stateless mode: Apache.* It does not appear that the configuration system of Apache can currently express the needed behaviour. [[web-server-configuration-tls]] == Web server configuration: TLS Mozilla has a https://mozilla.github.io/server-side-tls/ssl-config-generator/[TLS configuration generator] that you can use to generate configurations for common web servers. [[challenge-completion-philosophy]] == Challenge completion philosophy acmetool’s philosophy to completing challenges is to try absolutely anything that might work. So long as _something_ works, acmetool doesn’t care what it was that worked. When `acmetool quickstart` asks you what method to use, this is asked purely to determine a) whether to ask you for a webroot path (if you selected webroot mode) and b) whether to ask you if you want to install the redirector service (if you selected redirector mode and are using systemd, for which automatic service installation is supported). It doesn’t determine what strategies acmetool does or doesn’t use, so it’s normal to see log output relating to a failure to use methods other than the one you chose. acmetool always tries to listen on port 402 and 4402 when completing challenges, in case something proxies to it. It always tries to listen on ports 80 and 443, in case you’re not running a webserver yet. And it always tries to place challenge files in any webroot paths you have configured. Finally, it always tries to place challenge files in `/var/run/acme/acme-challenge`; this serves as a standard location for challenge files, and the redirector daemon works by looking here. Failure to complete any of these efforts is non-fatal. Ultimately, all acmetool cares about is that a challenge completes successfully after having attempted all possible preparations. It doesn’t know or care _why_ a challenge succeeds, only that it succeeded. (For HTTP-based challenges, acmetool self-tests its ability to complete the challenge by issuing a request for the same URL which will be requested by the ACME server, and does not proceed if this does not validate. Thus, HTTP-based challenges will never work if you are running some sort of weird split-horizon configuration where challenge files are retrievable only from the internet but not the local machine.) [[the-state-storage-schema]] == The state storage schema https://github.com/hlandau/acme/blob/master/_doc/SCHEMA.md[The format of acmetool’s state directory is authoritatively documented here.] What follows is a summary of the more important parts. *`live` directory:* Contains symlinks from hostnames to certificate directories. Each certificate directory contains `cert`, `chain`, `fullchain` and `privkey` files. (If you are using HAProxy and have chosen to install the HAProxy hook script, a `haproxy` file will also be available containing key, certificate and chain all in one.) You should configure your web server in terms of paths like `/var/lib/acme/live/example.com/{cert,chain,fullchain,privkey}`. *`desired` directory:* Contains targetfiles. These determine the certificates which will be requested. Each target file is a YAML file, split into two principal sections: the `satisfy` section and the `request` section. The `satisfy` section dictates what conditions must be met in order for a certificate to meet a target (and thus be selected for symlinking under the `live` directory). The `request` section dictates the parameters for requesting new certificates, but nothing under it determines _whether_ a certificate is requested. Finally, the `priority` value determines which target is used for a hostname when there are multiple targets for the same hostname. Higher priorities take precedence. The default priority is 0. In most cases, you will set only `satisfy.names` in a target file, and will set all other settings in the _default target file_, which is located at `conf/target`. The quickstart wizard sets this file up for you. All settings in the default target file are inherited by targets, but can be overridden individually. [source,yaml] ---- satisfy: names: - example.com # The names you want on the certificate. - www.example.com request: provider: # ACME Directory URL. Normally set in conf/target only. ocsp-must-staple: true # Request OCSP Must Staple. Use with care. challenge: webroot-paths: # You can specify custom webroot paths. - /var/www http-ports: # You can specify different ports for proxying. - 123 # Defaults to listening on localhost. - 456 - 0.0.0.0:789 # Global listen. http-self-test: false # Defaults to true. If false, will not perform self-test # but will assume challenge can be completed. Rarely needed. env: # Optionally set environment variables to be passed to hooks. FOO: BAR key: # What sort of key will be used for this certificate? type: rsa|ecdsa rsa-size: 2048 ecdsa-curve: nistp256 id: krzh2akn... # If specified, the key ID to use to generate new certificates. # If not specified, a new private key will always be generated. # Useful for key pinning. priority: 0 ---- *HAProxy support:* If you have chosen to install the HAProxy hook script, each certificate directory will also have a coalesced `haproxy` file containing certificate chain and private key. There will also be a `haproxy` directory mapping from hostnames directly to these files. *`accounts` directory:* ACME account keys and state information. You don’t need to worry about this. *`certs` and `keys`*: Contains certificates and keys used to satisfy targets. However, you should never need to reference these directories directly. Please note that it is a requirement that the state directory not straddle filesystem boundaries. That is, all files under `/var/lib/acme` must lie on the same filesystem. [[response-files]] == Response files It is possible to automatically provide responses to any question acmetool can ask. To do this, you provide the `--response-file` flag, with the path to a YAML file containing response information. https://github.com/hlandau/acme/blob/master/_doc/response-file.yaml[An example of such a file is here.] If you don’t provide a `--response-file` flag, acmetool will try to look for one at `/var/lib/acme/conf/responses`. If using a response file, it’s recommended that you place it at this location. The file specifies key-value pairs. Each key is a prompt ID. (You can find these by grepping the source code for `UniqueID`.) (For messages which simply require acknowledgement, specify `true` to bypass them. Yes/no prompts should have a boolean value specified. The example response file is demonstrative.) You should specify `--batch` when using a response file to prevent acmetool from trying to prompt the user and fail instead, in case it tries to ask anything which you don’t have a response for in your response file. [[hooks]] == Hooks Hooks provide a means to extend acmetool with arbitrary behaviour. Hooks are executable files installed by default at `/usr/lib/acme/hooks` (or, on systems which use `/usr/libexec`, `/usr/libexec/acme/hooks`). The event type is always passed as the first argument. A hook must exit with exit code 42 for event types it doesn’t handle. There are currently two types of hook: notification hooks and challenge hooks. [[notification-hooks]] == Notification hooks The quickstart wizard installs default notification hooks to reload common webservers and other services after acmetool changes the preferred certificate for a hostname. These hooks are executable shell scripts and you can, if you wish, substitute your own. The default hooks are good bases from which to make your own customisations. You can use notification hooks to reload webservers, distribute certificates and private keys to other servers, or convert certificates and private keys into another format which is required by some daemon. For example, HAProxy support is implemented entirely via hook. The event type is ``live-updated''. [[challenge-hooks]] == Challenge hooks In some complex use cases, it may be necessary to install HTTP challenge files via some arbitrary programmatic means, rather than via one of the standard methods of webroot, proxy, redirector or listener. Challenge hooks are executed when challenge files need to be added or removed. Your hook must be synchronous; it must exit only when the challenge file is definitely in place and is globally accessible. https://github.com/hlandau/acme/blob/master/_doc/SCHEMA.md#hooks[See the specification for more information.] Challenge hooks are supported for HTTP, TLS-SNI and DNS challenges. https://hlandau.github.io/acme/userguide#annex-external-resources-and-third-party-extentions[A list of third party challenge hook scripts can be found here.] [[command-line-options]] == Command line options link:acmetool.8[See the acmetool(8) manual page.] [[troubleshooting]] == Troubleshooting Passing `--xlog.severity=debug` increases the logging verbosity of acmetool and should be your first troubleshooting strategy. [[faq]] == FAQ [[ive-selected-the-webrootproxyredirectorlistener-challenge-method-but-im-seeing-log-entries-for-other-methods-or-for-webroots-other-than-the-one-i-configured.]] === I’ve selected the (webroot/proxy/redirector/listener) challenge method, but I’m seeing log entries for other methods, or for webroots other than the one I configured. This is normal. By design, acmetool always tries anything which might work, and these errors are nonfatal as long as _something_ works. The challenge method you select in the quickstart wizard determines only whether to ask you for a webroot path, and whether to install the redirector (if you are using system). The webroot path `/var/run/acme/acme-challenge`, as a standard location, will always be tried in addition to any webroot you specify, as will proxy and listener mode ports. Fore more information, see https://hlandau.github.io/acme/userguide#challenge-completion-philosophy[challenge completion philosophy]. [[annex-root-configured-non-root-operation]] == Annex: Root-configured non-root operation The following steps describe how you can, as root, take a series of steps that allows you to invoke acmetool as a non-root user, thereby limiting your attack surface and the degree to which you trust acmetool. It is also possible to use acmetool without you having access to root at all. In this case, place acmetool in a location of your choice and pass the `--state` and `--hooks` flags with appropriate paths of your choice to all invocations of acmetool. [[rootless-setup-as-root]] == Rootless setup as root acmetool has experimental support for root-free operation. In order to run root-free, after installing acmetool in `/usr/local/bin` (or wherever you want it), before running acmetool, do the following: * Create a new user `acme` (or whatever you want). * Create the directory `/var/lib/acme` and change the owning user and group to `acme`. (You can use a different directory, but you must then make sure you pass `--state PATH` to all invocations of acmetool.) * Create the directory `/usr/lib/acme/hooks` (`/usr/libexec/acme/hooks` on distros which use libexec). Make it writable by `acme` for the time being by changing the group to `acme` and making the directory group-writable. (You can make this read-only after running the quickstart process, which places some shell scripts in here to reload servers. You can audit these scripts yourself or use your own if you wish.) * Change to the user `acme` and run `acmetool quickstart`. + .... $ sudo -u acme acmetool quickstart .... + A crontab will be installed automatically as the `acme` user; you may wish to examine it. ** As root, make the `hooks` directory root-owned/not group writable once more. Ensure that the scripts are root-owned: + [source,sh] ---- # chown -R root:root /usr/lib*/acme/hooks # chmod 755 /usr/lib*/acme/hooks ---- + Inspect the hook scripts if you wish. Mark the hook scripts setuid: + [source,sh] ---- # chmod u+s /usr/lib*/acme/hooks/* ---- + UNIX systems don’t support setuid shell scripts, so this bit is ignored. Rather, acmetool takes it as a flag to tell it to run these scripts via `sudo`. This is necessary so that web servers, etc. can be reloaded. + The conditions for running using `sudo` are that the files have the setuid bit set, that they be owned by root, that they be scripts and not binaries, and that acmetool is not being run as root. ** Setup sudo. You will need to edit the sudoers file so that the hook scripts (which you have inspected and trust) can be executed by acmetool. It is essential that these have the `NOPASSWD` flag as the scripts must be executable noninteractively. + `# visudo` + Add the line: + `acme ALL=(root) NOPASSWD: /usr/lib/acme/hooks/` ** Setup your challenge method: + *Webroot:* Make sure the `acme` user can write to the webroot directory you configured. + *Redirector:* Make sure the directory `/var/run/acme/acme-challenge` is writable by the `acme` user. `acmetool` puts challenges here because the redirector looks here (internally it’s a glorified webroot mode). + Note that `/var/run` will be a tmpfs on many modern OSes, so the directory ceases to exist on reboots. The redirector will try to create the directory (as user root, mode 0755) if it doesn’t exist. This happens before the redirector drops privileges from root. (It has to run as root initially to bind to port 80.) + A configuration option has been added to make the redirector ensure that the directory is writable by a certain group when starting up. When this option is used, mode 0775 is used instead and the group owner is changed to a given GID. + Pass `--challenge-gid=GID` to `acmetool redirector` (edit your service manager’s configuration, e.g. the systemd unit file), where GID is the numeric group ID of the group owner for the challenge directory (i.e. the GID of the `acme` group). (Group names rather than IDs may be supported on some platforms, but this is not guaranteed and will vary. Use of a GID is recommended.) + *Proxy:* If you are using the proxy method, you won’t be able to listen on port 402 as a non-root user. Use port 4402 instead, which acmetool will try also try to use. + *Listener:* This is not usable under non-root operation unless you can enable acmetool to bind to ports 80/443. On Linux you can do this by running `setcap 'cap_net_bind_service=+ep' /path/to/acmetool` as root. Other POSIX platforms may have sysctls to allow non-root processes to bind to low ports. However, this mode is not really useful for anything other than development anyway. + *Hook:* See link:#challenge-hooks[Challenge Hooks]. [[annex-external-resources-and-third-party-extentions]] == Annex: External resources and third party extentions The list of various tutorials, hook scripts and other integrations people have made for acmetool is now maintained https://github.com/hlandau/acme/wiki/ThirdPartyResources[in the wiki]. * *https://github.com/hlandau/acme/wiki/ThirdPartyResources[List of third party resources]* acmetool-0.2.2/_doc/guide/style.css000066400000000000000000000014101435652113300171700ustar00rootroot00000000000000/* RESET */ a img { border: none; } /* LOGO */ #logo { text-align: center; margin-top: 1em; } html#index #logo { margin-top: 10em; } /* TOP-LEVEL HEADING */ h1 { text-align: center; margin: 0 auto; } /* MID-LEVEL HEADING */ h2 { margin-top: 2em; } h2::before { content: "█ "; color: #000; margin-left: 0; } /* NAV */ nav#tnav > ul { list-style: none; text-align: center; } nav#tnav > ul > li { display: inline-block; padding: 0; margin: 0; } nav#tnav > ul > li:not(:first-child)::before { content: "·"; padding: 0.2em; } a:link { color: #00a; text-decoration: none; } /* TABLE OF CONTENTS */ .toc { background-color: #e0e0e0; padding: 0.5em; margin: 0.5em; margin-bottom: 2em; } .toc > ul { list-style: none; margin: 0; padding: 0; } .toc-title { font-weight: bold; } acmetool-0.2.2/_doc/guide/to-xhtml.xsl000066400000000000000000000021611435652113300176260ustar00rootroot00000000000000 book toc 0 0 acmetool-0.2.2/cli/000077500000000000000000000000001435652113300140705ustar00rootroot00000000000000acmetool-0.2.2/cli/doc.go000066400000000000000000000046311435652113300151700ustar00rootroot00000000000000package cli import ( "fmt" "github.com/hlandau/acmetool/storage" "gopkg.in/alecthomas/kingpin.v2" ) const manPageTemplate = `{{define "FormatFlags"}}\ {{range .Flags}}\ {{if not .Hidden}}\ .TP \fB{{if .Short}}-{{.Short|Char}}, {{end}}--{{.Name}}{{if not .IsBoolFlag}}={{.FormatPlaceHolder}}{{end}}\\fR {{.Help}} {{end}}\ {{end}}\ {{end}}\ {{define "FormatCommand"}}\ {{if .FlagSummary}} {{.FlagSummary}}{{end}}\ {{range .Args}} {{if not .Required}}[{{end}}<{{.Name}}{{if .Default}}*{{end}}>{{if .Value|IsCumulative}}...{{end}}{{if not .Required}}]{{end}}{{end}}\ {{end}}\ {{define "FormatCommands"}}\ {{range .FlattenedCommands}}\ {{if not .Hidden}}\ .SS \fB{{.FullCommand}}{{template "FormatCommand" .}}\\fR .PP {{.Help}} {{template "FormatFlags" .}}\ {{end}}\ {{end}}\ {{end}}\ {{define "FormatUsage"}}\ {{template "FormatCommand" .}}{{if .Commands}} [ ...]{{end}}\\fR {{end}}\ .TH {{.App.Name}} 8 {{.App.Version}} "acmetool" .SH "NAME" {{.App.Name}} - request certificates from ACME servers automatically .SH "SYNOPSIS" .TP \fB{{.App.Name}}{{template "FormatUsage" .App}} .SH "DESCRIPTION" {{.App.Help}} .SH "OPTIONS" {{template "FormatFlags" .App}}\ {{if .App.Commands}}\ .SH "SUBCOMMANDS" {{template "FormatCommands" .App}}\ {{end}}\ .SH "AUTHOR" © 2015 {{.App.Author}} MIT License .SH "SEE ALSO" Documentation: Report bugs at: ` var helpText = fmt.Sprintf(`acmetool is a utility for the automated retrieval, management and renewal of certificates from ACME server such as Let's Encrypt. It emphasises automation, idempotency and the minimisation of state. You use acmetool by configuring targets (typically using the "want") command. acmetool then requests certificates as necessary to satisfy the configured targets. New certificates are requested where existing ones are soon to expire. acmetool stores its state in a state directory. It can be specified on invocation via the --state option; otherwise, the path in ACME_STATE_DIR is used, or, failing that, the path "%s" (recommended). The --xlog options control the logging. The --service options control privilege dropping and daemonization and are applicable only to the redirector subcommand. `, storage.RecommendedPath) func init() { kingpin.CommandLine.Help = helpText kingpin.CommandLine.Author("Hugo Landau") kingpin.ManPageTemplate = manPageTemplate } acmetool-0.2.2/cli/main.go000066400000000000000000000360331435652113300153500ustar00rootroot00000000000000// Package cli is the command-line interface driver for acmetool. Everything begins here. package cli import ( "bytes" "fmt" "io/ioutil" "os" "path/filepath" "strings" "syscall" "github.com/hlandau/acmetool/hooks" "github.com/hlandau/acmetool/interaction" "github.com/hlandau/acmetool/redirector" "github.com/hlandau/acmetool/responder" "github.com/hlandau/acmetool/storage" "github.com/hlandau/acmetool/storageops" "github.com/hlandau/dexlogconfig" "github.com/hlandau/xlog" "gopkg.in/alecthomas/kingpin.v2" "gopkg.in/hlandau/acmeapi.v2" "gopkg.in/hlandau/acmeapi.v2/acmeutils" "gopkg.in/hlandau/easyconfig.v1/adaptflag" "gopkg.in/hlandau/service.v2" "gopkg.in/square/go-jose.v1" "gopkg.in/yaml.v2" ) var log, Log = xlog.New("acmetool") var ( stateFlag = kingpin.Flag("state", "Path to the state directory (env: ACME_STATE_DIR)"). Default(storage.RecommendedPath). Envar("ACME_STATE_DIR"). PlaceHolder(storage.RecommendedPath). String() hooksFlag = kingpin.Flag("hooks", "Path to the notification hooks directory (env: ACME_HOOKS_DIR)"). Default(hooks.RecommendedPaths...). PlaceHolder(hooks.RecommendedPaths[0]). Envar("ACME_HOOKS_DIR"). Strings() batchFlag = kingpin.Flag("batch", "Do not attempt interaction; useful for cron jobs. (acmetool can still obtain responses from a response file, if one was provided.)"). Bool() stdioFlag = kingpin.Flag("stdio", "Don't attempt to use console dialogs; fall back to stdio prompts").Bool() responseFileFlag = kingpin.Flag("response-file", "Read dialog responses from the given file (default: $ACME_STATE_DIR/conf/responses)").ExistingFile() reconcileCmd = kingpin.Command("reconcile", reconcileHelp).Default() reconcileSpecArg = reconcileCmd.Arg("target-filenames", "optionally, specify one or more target file paths or filenames to reconcile only those targets").Strings() cullCmd = kingpin.Command("cull", "Delete expired, unused certificates") cullSimulateFlag = cullCmd.Flag("simulate", "Show which certificates would be deleted without deleting any").Short('n').Bool() statusCmd = kingpin.Command("status", "Show active configuration") wantCmd = kingpin.Command("want", "Add a target with one or more hostnames") wantReconcile = wantCmd.Flag("reconcile", "Specify --no-reconcile to skip reconcile after adding target").Default("1").Bool() wantArg = wantCmd.Arg("hostname", "hostnames for which a certificate should be obtained").Required().Strings() unwantCmd = kingpin.Command("unwant", "Modify targets to remove any mentions of the given hostnames") unwantArg = unwantCmd.Arg("hostname", "hostnames which should be removed from all target files").Required().Strings() quickstartCmd = kingpin.Command("quickstart", "Interactively ask some getting started questions (recommended)") expertFlag = quickstartCmd.Flag("expert", "Ask more questions in quickstart wizard").Bool() redirectorCmd = kingpin.Command("redirector", "HTTP to HTTPS redirector with challenge response support") redirectorPathFlag = redirectorCmd.Flag("path", "Path to serve challenge files from").String() redirectorGIDFlag = redirectorCmd.Flag("challenge-gid", "GID to chgrp the challenge path to (optional)").String() redirectorReadTimeout = redirectorCmd.Flag("read-timeout", "Maximum duration before timing out read of the request (default: '10s')").Default("10s").Duration() redirectorWriteTimeout = redirectorCmd.Flag("write-timeout", "Maximum duration before timing out write of the request (default: '20s')").Default("20s").Duration() redirectorStatusCodeFlag = redirectorCmd.Flag("status-code", "HTTP status code to use when redirecting (default '308')").Default("308").Int() redirectorBindFlag = redirectorCmd.Flag("bind", "Bind address for redirectory (default ':80')").Default(":80").String() testNotifyCmd = kingpin.Command("test-notify", "Test-execute notification hooks as though given hostnames were updated") testNotifyArg = testNotifyCmd.Arg("hostname", "hostnames which have been updated").Strings() importJWKAccountCmd = kingpin.Command("import-jwk-account", "Import a JWK account key") importJWKURLArg = importJWKAccountCmd.Arg("provider-url", "Provider URL (e.g. https://acme-v02.api.letsencrypt.org/directory)").Required().String() importJWKPathArg = importJWKAccountCmd.Arg("private-key-file", "Path to private_key.json").Required().ExistingFile() importPEMAccountCmd = kingpin.Command("import-pem-account", "Import a PEM account key") importPEMURLArg = importPEMAccountCmd.Arg("provider-url", "Provider URL (e.g. https://acme-v02.api.letsencrypt.org/directory)").Required().String() importPEMPathArg = importPEMAccountCmd.Arg("private-key-file", "Path to private key PEM file").Required().ExistingFile() importKeyCmd = kingpin.Command("import-key", "Import a certificate private key") importKeyArg = importKeyCmd.Arg("private-key-file", "Path to PEM-encoded private key").Required().ExistingFile() importLECmd = kingpin.Command("import-le", "Import a Let's Encrypt client state directory") importLEArg = importLECmd.Arg("le-state-path", "Path to Let's Encrypt state directory").Default("/etc/letsencrypt").ExistingDir() // Arguments we should probably support for revocation: // A certificate ID // A key ID // A path to a PEM-encoded certificate - TODO // A path to a PEM-encoded private key (revoke all known certificates with that key) - TODO // A path to a certificate directory - TODO // A path to a key directory - TODO // A certificate URL - TODO revokeCmd = kingpin.Command("revoke", "Revoke a certificate") revokeArg = revokeCmd.Arg("certificate-id-or-path", "Certificate ID to revoke").String() accountThumbprintCmd = kingpin.Command("account-thumbprint", "Prints account thumbprints") accountURLCmd = kingpin.Command("account-url", "Show account URL") ) const reconcileHelp = `Reconcile ACME state, idempotently requesting and renewing certificates to satisfy configured targets. This is the default command.` // Main entrypoint for the command line tool. func Main() { syscall.Umask(0) // make sure webroot files can be world-readable adaptflag.Adapt() cmd := kingpin.Parse() var err error *stateFlag, err = filepath.Abs(*stateFlag) log.Fatale(err, "state directory path") hooksSlice := *hooksFlag for i := range hooksSlice { hooksSlice[i], err = filepath.Abs(hooksSlice[i]) log.Fatale(err, "hooks directory path") } hooks.DefaultPaths = hooksSlice acmeapi.UserAgent = "acmetool" dexlogconfig.Init() if *batchFlag { interaction.NonInteractive = true } if *stdioFlag { interaction.NoDialog = true } if *responseFileFlag == "" { p := filepath.Join(*stateFlag, "conf/responses") if _, err := os.Stat(p); err == nil { *responseFileFlag = p } } if *responseFileFlag != "" { err := loadResponseFile(*responseFileFlag) log.Errore(err, "cannot load response file, continuing anyway") } switch cmd { case "reconcile": cmdReconcile() case "cull": cmdCull() case "status": cmdStatus() case "account-thumbprint": cmdAccountThumbprint() case "want": cmdWant() if *wantReconcile { cmdReconcile() } case "unwant": cmdUnwant() case "quickstart": cmdQuickstart() case "redirector": cmdRunRedirector() case "test-notify": cmdRunTestNotify() case "import-key": cmdImportKey() case "import-jwk-account": cmdImportJWKAccount() case "import-pem-account": cmdImportPEMAccount() case "revoke": cmdRevoke() case "account-url": cmdAccountURL() } } func cmdImportJWKAccount() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") f, err := os.Open(*importJWKPathArg) log.Fatale(err, "cannot open private key file") defer f.Close() b, err := ioutil.ReadAll(f) log.Fatale(err, "cannot read file") k := jose.JsonWebKey{} err = k.UnmarshalJSON(b) log.Fatale(err, "cannot unmarshal key") _, err = s.ImportAccount(*importJWKURLArg, k.Key) log.Fatale(err, "cannot import account key") } func cmdImportPEMAccount() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") f, err := os.Open(*importPEMPathArg) log.Fatale(err, "cannot open private key file") defer f.Close() b, err := ioutil.ReadAll(f) log.Fatale(err, "cannot read file") pk, err := acmeutils.LoadPrivateKey(b) log.Fatale(err, "cannot parse private key") _, err = s.ImportAccount(*importPEMURLArg, pk) log.Fatale(err, "cannot import account key") } func cmdImportKey() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") err = importKey(s, *importKeyArg) log.Fatale(err, "import key") } func cmdReconcile() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") err = storageops.Reconcile(s, storageops.ReconcileConfig{ Targets: *reconcileSpecArg, }) log.Fatale(err, "reconcile") } func cmdCull() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") err = storageops.Cull(s, *cullSimulateFlag) log.Fatale(err, "cull") } func cmdStatus() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") info := StatusString(s) log.Fatale(err, "status") fmt.Print(info) } func cmdAccountURL() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") url, err := storageops.GetAccountURL(s) log.Fatale(err, "get account URL") fmt.Print(url) } func importKey(s storage.Store, filename string) error { b, err := ioutil.ReadFile(filename) if err != nil { return err } pk, err := acmeutils.LoadPrivateKey(b) if err != nil { return err } _, err = s.ImportKey(pk) return err } func StatusString(s storage.Store) string { var buf bytes.Buffer fmt.Fprintf(&buf, "Settings:\n") fmt.Fprintf(&buf, " ACME_STATE_DIR: %s\n", s.Path()) fmt.Fprintf(&buf, " ACME_HOOKS_DIR: %s\n", strings.Join(hooks.DefaultPaths, "; ")) fmt.Fprintf(&buf, " Default directory URL: %s\n", s.DefaultTarget().Request.Provider) fmt.Fprintf(&buf, " Preferred key type: %v\n", &s.DefaultTarget().Request.Key) fmt.Fprintf(&buf, " Additional webroots:\n") for _, wr := range s.DefaultTarget().Request.Challenge.WebrootPaths { fmt.Fprintf(&buf, " %s\n", wr) } fmt.Fprintf(&buf, "\nAvailable accounts:\n") s.VisitAccounts(func(a *storage.Account) error { fmt.Fprintf(&buf, " %v\n", a) thumbprint, _ := acmeutils.Base64Thumbprint(a.PrivateKey) fmt.Fprintf(&buf, " thumbprint: %s\n", thumbprint) return nil }) fmt.Fprintf(&buf, "\n") s.VisitTargets(func(t *storage.Target) error { fmt.Fprintf(&buf, "%v\n", t) c, err := storageops.FindBestCertificateSatisfying(s, t) if err != nil { fmt.Fprintf(&buf, " error: %v\n", err) return nil // continue } renewStr := "" if storageops.CertificateNeedsRenewing(c, t) { renewStr = " needs-renewing" } fmt.Fprintf(&buf, " best: %v%s\n", c, renewStr) return nil }) if storageops.HaveUncachedCertificates(s) { fmt.Fprintf(&buf, "\nThere are uncached certificates.\n") } return buf.String() } func cmdAccountThumbprint() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") s.VisitAccounts(func(a *storage.Account) error { thumbprint, _ := acmeutils.Base64Thumbprint(a.PrivateKey) fmt.Printf("%s\t%s\n", thumbprint, a.ID()) return nil }) } func cmdWant() { hostnames := *wantArg // Ensure all hostnames provided are valid. for idx := range hostnames { norm, err := acmeutils.NormalizeHostname(hostnames[idx]) if err != nil { log.Fatalf("invalid hostname: %#v: %v", hostnames[idx], err) return } hostnames[idx] = norm } // Determine whether there already exists a target satisfying all given // hostnames or a superset thereof. s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") alreadyExists := false s.VisitTargets(func(t *storage.Target) error { nm := map[string]struct{}{} for _, n := range t.Satisfy.Names { nm[n] = struct{}{} } for _, w := range hostnames { if _, ok := nm[w]; !ok { return nil } } alreadyExists = true return nil }) if alreadyExists { return } // Add the target. tgt := storage.Target{ Satisfy: storage.TargetSatisfy{ Names: hostnames, }, } err = s.SaveTarget(&tgt) log.Fatale(err, "add target") } func cmdUnwant() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") for _, hn := range *unwantArg { err = storageops.RemoveTargetHostname(s, hn) log.Fatale(err, "remove target hostname ", hn) } } func cmdRunRedirector() { // redirector process is internet-facing and must never touch private keys storage.Neuter() rpath := *redirectorPathFlag if rpath == "" { rpath = determineWebroot() } service.Main(&service.Info{ Name: "acmetool", Description: "acmetool HTTP redirector", DefaultChroot: rpath, NewFunc: func() (service.Runnable, error) { return redirector.New(redirector.Config{ Bind: *redirectorBindFlag, ChallengePath: rpath, ChallengeGID: *redirectorGIDFlag, ReadTimeout: *redirectorReadTimeout, WriteTimeout: *redirectorWriteTimeout, StatusCode: *redirectorStatusCodeFlag, }) }, }) } func determineWebroot() string { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") webrootPaths := s.DefaultTarget().Request.Challenge.WebrootPaths if len(webrootPaths) > 0 { return webrootPaths[0] } return responder.StandardWebrootPath } func cmdRunTestNotify() { ctx := &hooks.Context{ HookDirs: *hooksFlag, StateDir: *stateFlag, } err := hooks.NotifyLiveUpdated(ctx, *testNotifyArg) log.Errore(err, "notify") } // YAML response file loading. func loadResponseFile(path string) error { b, err := ioutil.ReadFile(path) if err != nil { return err } m := map[string]interface{}{} err = yaml.Unmarshal(b, &m) if err != nil { return err } for k, v := range m { r, err := parseResponse(v) if err != nil { log.Errore(err, "response for ", k, " invalid") continue } interaction.SetResponse(k, r) } return nil } func parseResponse(v interface{}) (*interaction.Response, error) { switch x := v.(type) { case string: return &interaction.Response{ Value: x, }, nil case int: return &interaction.Response{ Value: fmt.Sprintf("%d", x), }, nil case bool: return &interaction.Response{ Cancelled: !x, }, nil default: return nil, fmt.Errorf("unknown response value") } } func cmdRevoke() { certSpec := *revokeArg f, _ := os.Open(certSpec) //var fi os.FileInfo if f != nil { defer f.Close() //var err error //fi, err = f.Stat() //log.Panice(err) } //u, _ := url.Parse(certSpec) switch { //case f != nil && !fi.IsDir(): // is a file path //case f != nil && fi.IsDir(): // is a directory path // f, _ = os.Open(filepath.Join(certSpec, "cert")) //case u != nil && u.IsAbs() && acmeapi.ValidURL(certSpec): // is an URL case storage.IsWellFormattedCertificateOrKeyID(certSpec): // key or certificate ID revokeByCertificateID(certSpec) default: log.Fatalf("don't understand argument, must be a certificate or key ID: %q", certSpec) } } func revokeByCertificateID(certID string) { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") err = storageops.RevokeByCertificateOrKeyID(s, certID) log.Fatale(err, "revoke") err = storageops.Reconcile(s, storageops.ReconcileConfig{}) log.Fatale(err, "reconcile") } acmetool-0.2.2/cli/main_ig_test.go000066400000000000000000000046401435652113300170650ustar00rootroot00000000000000// +build integration package cli import ( "fmt" //"gopkg.in/hlandau/acmeapi.v2" "github.com/hlandau/acmetool/interaction" "github.com/hlandau/acmetool/responder" "github.com/hlandau/acmetool/storageops" "gopkg.in/hlandau/acmeapi.v2/pebbletest" "io/ioutil" "path/filepath" "strings" "testing" ) type interceptor struct { } func (i *interceptor) Prompt(c *interaction.Challenge) (*interaction.Response, error) { switch c.UniqueID { case "acmetool-quickstart-choose-server": return &interaction.Response{Value: "url"}, nil case "acmetool-quickstart-enter-directory-url": return &interaction.Response{Value: "https://127.0.0.1:14000/dir"}, nil case "acmetool-quickstart-choose-method": return &interaction.Response{Value: "redirector"}, nil case "acme-enter-email": return &interaction.Response{Value: "nobody@example.com"}, nil case "acmetool-quickstart-complete": return &interaction.Response{}, nil case "acmetool-quickstart-install-cronjob", "acmetool-quickstart-install-haproxy-script", "acmetool-quickstart-install-redirector-systemd": return &interaction.Response{Cancelled: true}, nil default: if strings.HasPrefix(c.UniqueID, "acme-agreement:") { return &interaction.Response{}, nil } return nil, fmt.Errorf("unsupported challenge for interceptor: %v", c) } } func (i *interceptor) Status(info *interaction.StatusInfo) (interaction.StatusSink, error) { return nil, fmt.Errorf("status not supported") } func TestCLI(t *testing.T) { log.Warnf("This test requires a configured Boulder instance listening at http://127.0.0.1:4000/ and the ability to successfully complete challenges. You must change the Boulder configuration to use ports 80 and 5001. Also change the rate limits per certificate name. Consider ensuring that the user you run these tests as can write to %s and that that directory is served on port 80 /.well-known/acme-challenge/", responder.StandardWebrootPath) //acmeapi.TestingAllowHTTP = true storageops.InternalHTTPClient = pebbletest.HTTPClient interaction.Interceptor = &interceptor{} tmpDir, err := ioutil.TempDir("", "acmetool-test") if err != nil { panic(err) } *stateFlag = filepath.Join(tmpDir, "state") *hooksFlag = []string{filepath.Join(tmpDir, "hooks")} responder.InternalHTTPPort = 5002 //responder.InternalTLSSNIPort = 5001 cmdQuickstart() *wantArg = []string{"dom1.acmetool-test.devever.net", "dom2.acmetool-test.devever.net"} cmdWant() cmdReconcile() } acmetool-0.2.2/cli/quickstart-linux.go000066400000000000000000000061541435652113300177540ustar00rootroot00000000000000// +build linux package cli import ( "fmt" "github.com/hlandau/acmetool/interaction" sddbus "github.com/hlandauf/go-systemd/dbus" sdunit "github.com/hlandauf/go-systemd/unit" "gopkg.in/hlandau/svcutils.v1/exepath" "gopkg.in/hlandau/svcutils.v1/systemd" // coreos/go-systemd/util requires cgo "io" "os" ) func promptSystemd() { if !systemd.IsRunningSystemd() { log.Debugf("not running systemd") return } log.Debug("connecting to systemd") conn, err := sddbus.New() if err != nil { log.Errore(err, "connect to systemd") return } defer conn.Close() log.Debug("connected") props, err := conn.GetUnitProperties("acmetool-redirector.service") if err != nil { log.Errore(err, "systemd GetUnitProperties") return } if props["LoadState"].(string) != "not-found" { log.Info("acmetool-redirector.service unit already installed, skipping") return } r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "Install Redirector as systemd Service?", Body: `Would you like acmetool to automatically install the redirector as a systemd service? The service name will be acmetool-redirector.`, ResponseType: interaction.RTYesNo, UniqueID: "acmetool-quickstart-install-redirector-systemd", }) log.Fatale(err, "interaction") if r.Cancelled { return } username, err := determineAppropriateUsername() if err != nil { log.Errore(err, "determine appropriate username") return } f, err := os.OpenFile("/etc/systemd/system/acmetool-redirector.service", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err != nil { log.Errore(err, "acmetool-redirector.service unit file already exists?") return } defer f.Close() rdr := sdunit.Serialize([]*sdunit.UnitOption{ sdunit.NewUnitOption("Unit", "Description", "acmetool HTTP redirector"), sdunit.NewUnitOption("Service", "Type", "notify"), sdunit.NewUnitOption("Service", "ExecStart", exepath.Abs+` redirector --service.uid=`+username), sdunit.NewUnitOption("Service", "Restart", "always"), sdunit.NewUnitOption("Service", "RestartSec", "30"), sdunit.NewUnitOption("Install", "WantedBy", "multi-user.target"), }) _, err = io.Copy(f, rdr) if err != nil { log.Errore(err, "cannot write unit file") return } f.Close() err = conn.Reload() // softfail log.Warne(err, "systemctl daemon-reload failed") _, _, err = conn.EnableUnitFiles([]string{"acmetool-redirector.service"}, false, false) log.Errore(err, "failed to enable unit acmetool-redirector.service") _, err = conn.StartUnit("acmetool-redirector.service", "replace", nil) log.Errore(err, "failed to start acmetool-redirector") resultStr := "The acmetool-redirector service was successfully started." if err != nil { resultStr = "The acmetool-redirector service WAS NOT successfully started. You may have a web server listening on port 80. You will need to troubleshoot this yourself." } _, err = interaction.Auto.Prompt(&interaction.Challenge{ Title: "systemd Service Installation Complete", Body: fmt.Sprintf(`acmetool-redirector has been installed as a systemd service. %s`, resultStr), UniqueID: "acmetool-quickstart-complete", }) log.Errore(err, "interaction") } acmetool-0.2.2/cli/quickstart-nlinux.go000066400000000000000000000000701435652113300201210ustar00rootroot00000000000000// +build !linux package cli func promptSystemd() { } acmetool-0.2.2/cli/quickstart.go000066400000000000000000000601531435652113300166160ustar00rootroot00000000000000package cli import ( "bytes" "crypto/rand" "fmt" "github.com/hlandau/acmetool/hooks" "github.com/hlandau/acmetool/interaction" "github.com/hlandau/acmetool/storage" "github.com/hlandau/acmetool/storageops" "gopkg.in/hlandau/acmeapi.v2" "gopkg.in/hlandau/acmeapi.v2/acmeendpoints" "gopkg.in/hlandau/svcutils.v1/exepath" "gopkg.in/hlandau/svcutils.v1/passwd" "io/ioutil" "os" "os/exec" "path/filepath" "reflect" "strconv" "strings" ) func cmdQuickstart() { s, err := storage.NewFDB(*stateFlag) log.Fatale(err, "storage") serverURL := promptServerURL() s.DefaultTarget().Request.Provider = serverURL err = s.SaveTarget(s.DefaultTarget()) log.Fatale(err, "set provider URL") // key type keyType := promptKeyType() switch keyType { case "rsa": s.DefaultTarget().Request.Key.Type = "rsa" rsaKeySize := promptRSAKeySize() if rsaKeySize != 0 { s.DefaultTarget().Request.Key.RSASize = rsaKeySize err = s.SaveTarget(s.DefaultTarget()) log.Fatale(err, "set preferred RSA Key size") } case "ecdsa": s.DefaultTarget().Request.Key.Type = "ecdsa" ecdsaCurve := promptECDSACurve() if ecdsaCurve != "" { s.DefaultTarget().Request.Key.ECDSACurve = ecdsaCurve err = s.SaveTarget(s.DefaultTarget()) log.Fatale(err, "set preferred ECDSA curve") } } // hook method method := promptHookMethod() var webroot []string switch method { case "webroot": webroot = []string{promptWebrootDir()} } if len(webroot) != 0 { err = os.MkdirAll(webroot[0], 0755) log.Fatale(err, "couldn't create webroot path") } s.DefaultTarget().Request.Challenge.WebrootPaths = webroot err = s.SaveTarget(s.DefaultTarget()) log.Fatale(err, "set webroot path") prog, err := interaction.Auto.Status(&interaction.StatusInfo{ Title: "Registering account...", }) log.Fatale(err, "status") prog.SetProgress(0, 1) err = storageops.EnsureRegistration(s) log.Fatale(err, "couldn't complete registration") prog.SetProgress(1, 1) prog.Close() if method == "redirector" { promptSystemd() } installDefaultHooks() if promptInstallCombinedHooks() { installCombinedHooks() } promptCron() promptGettingStarted() } const reloadHookFile = `#!/bin/sh ## This file was installed by acmetool. Any updates to this script will ## overwrite changes you make. If you don't want acmetool to manage ## this file, remove the following line. ##!acmetool-managed!## # This file reloads services when the preferred certificate for a hostname # changes. A list of commonly used daemons is preconfigured. You can override # this list by setting $SERVICES in /etc/{default,conf.d}/acme-reload. # # Configuration options: # /etc/{default,conf.d}/acme-reload # Sourced if they exist. Specify variables here. # Please note that most of the time, you don't need to specify anything. # # $SERVICES # Space-separated list of daemons to reload. # Append with SERVICES="$SERVICES mydaemon". ############################################################################### set -e EVENT_NAME="$1" [ "$EVENT_NAME" = "live-updated" ] || exit 42 SERVICES="httpd apache2 apache nginx tengine lighttpd postfix dovecot exim exim4 haproxy hitch quassel quasselcore opensmtpd freeswitch apache24" [ -e "/etc/default/acme-reload" ] && . /etc/default/acme-reload [ -e "/etc/conf.d/acme-reload" ] && . /etc/conf.d/acme-reload [ -z "$ACME_STATE_DIR" ] && ACME_STATE_DIR="@@ACME_STATE_DIR@@" # Restart services. if which service >/dev/null 2>/dev/null; then for x in $SERVICES; do service "$x" reload >/dev/null 2>/dev/null || true done exit 0 fi if which systemctl >/dev/null 2>/dev/null; then for x in $SERVICES; do [ -e "/lib/systemd/system/$x.service" -o -e "/etc/systemd/system/$x.service" ] && systemctl reload "$x.service" >/dev/null 2>/dev/null || true done exit 0 fi if [ -e "/etc/init.d" ]; then for x in $SERVICES; do /etc/init.d/$x reload >/dev/null 2>/dev/null || true done exit 0 fi` const combinedReloadHookFile = `#!/bin/sh ## This file was installed by acmetool. Any updates to this script will ## overwrite changes you make. If you don't want acmetool to manage ## this file, remove the following line. ##!acmetool-managed!## # This file generates combined certificate+private key files for daemons which # require them. It is called haproxy for legacy reasons, HAProxy being a common # example of such a daemon, but can also be used with other daemons taking the # same input format such as Hitch and Quasselcore. # # This is done outside of acmetool, because it is desired to avoid making # unnecessary copies of private keys except in environments where it is # necessary. This also demonstrates the power and flexibility of the hooks # system. # # The files consist of the private key, followed by the certificate and # certificate chain, followed by any data placed in conf/dhparams. # # This script is a no-op unless a daemon known to require combined files is # found. You can override this by setting $HAPROXY_ALWAYS_GENERATE or # $HAPROXY_DAEMONS in /etc/{default,conf.d}/acme-reload. # # (This file should be executed before 'reload'. So long as it is named # 'haproxy' and reload is named 'reload', that is assured.) # # DEBUGGING NOTE: If you make changes to the configuration this will not # be reflected simply by rerunning 'acmetool', because this script is only # called when a symlink in 'live' is updated. You can force this script to # be rerun by deleting all symlinks in 'live' and running 'acmetool'. # # Output: # $ACME_STATE_DIR/live/$HOSTNAME/haproxy # The combined certificate file for a hostname. # # $ACME_STATE_DIR/haproxy/$HOSTNAME # Symlinked to the combined certificate file. Daemons such as HAProxy # can prefer directories such as these, where each file is a hostname # containing combined data. # # Configuration options: # /etc/{default,conf.d}/acme-reload # Sourced if they exist. Specify variables here. # Please note that most of the time, you don't need to specify anything. # # $HAPROXY_ALWAYS_GENERATE # If non-empty, always generate combined files. # # $HAPROXY_DAEMONS # Space-separated list of binaries to search for in path. If any are found # (or $HAPROXY_ALWAYS_GENERATE is set), generate combined files. # Append with HAPROXY_DAEMONS="$HAPROXY_DAEMONS mydaemon". # Defaults: see below. # # $HAPROXY_DH_PATH # Defaults to "$ACME_STATE_DIR/conf/dhparams". If the file exists, it is # appended verbatim to combined certificate files. Commonly used to attach # custom Diffie-Hellman parameters. # # $HAPROXY_UMASK # Don't change this unless you know what you're doing. # If you change this, you must create a conf/perm file to reconfigure # acmetool's permissions enforcement. See _doc directory in repository. # Override path "certs/*/haproxy". ############################################################################### set -e EVENT_NAME="$1" [ "$EVENT_NAME" = "live-updated" ] || exit 42 # List of services. If any of these are in PATH (or HAPROXY_ALWAYS_GENERATE is # set), assume we need to generate combined files. HAPROXY_DAEMONS="haproxy hitch quasselcore quassel lighttpd freeswitch" HAPROXY_UMASK="0077" [ -e "/etc/default/acme-reload" ] && . /etc/default/acme-reload [ -e "/etc/conf.d/acme-reload" ] && . /etc/conf.d/acme-reload [ -z "$ACME_STATE_DIR" ] && ACME_STATE_DIR="@@ACME_STATE_DIR@@" [ -z "$HAPROXY_DH_PATH" ] && HAPROXY_DH_PATH="$ACME_STATE_DIR/conf/dhparams" # Don't do anything if no daemon requiring combined files is found. [ -n "$HAPROXY_ALWAYS_GENERATE" ] || { ok= for exe in $HAPROXY_DAEMONS; do which "$exe" >/dev/null 2>/dev/null && ok=1 && break done [ -z "$ok" ] && exit 0 } # Create coalesced files and a haproxy repository. umask 0022 mkdir -p "$ACME_STATE_DIR/haproxy" umask $HAPROXY_UMASK while read name; do certdir="$ACME_STATE_DIR/live/$name" if [ -z "$name" -o ! -e "$certdir" ]; then continue fi if [ -n "$HAPROXY_DH_PATH" -a -e "$HAPROXY_DH_PATH" ]; then cat "$certdir/privkey" "$certdir/fullchain" "$HAPROXY_DH_PATH" > "$certdir/haproxy" else cat "$certdir/privkey" "$certdir/fullchain" > "$certdir/haproxy" fi [ -h "$ACME_STATE_DIR/haproxy/$name" ] || ln -fs "../live/$name/haproxy" "$ACME_STATE_DIR/haproxy/$name" done` func installHook(name, value string) { hooks.Replace(*hooksFlag, name, strings.Replace(value, "@@ACME_STATE_DIR@@", *stateFlag, -1)) // fail silently, allow non-root, makes travis work. } func installDefaultHooks() { installHook("reload", reloadHookFile) } func installCombinedHooks() { installHook("haproxy", combinedReloadHookFile) } var errStop = fmt.Errorf("stop") func isCronjobInstalled() bool { ms, err := filepath.Glob("/etc/cron.*/*acmetool*") log.Fatale(err, "glob") if len(ms) > 0 { return true } installed := false filepath.Walk("/var/spool/cron", func(p string, fi os.FileInfo, err error) error { if err != nil { return nil } if (fi.Mode() & os.ModeType) != 0 { return nil } if strings.Index(fi.Name(), "acmetool") >= 0 { installed = true return errStop } f, err := os.Open(p) if err != nil { return nil } defer f.Close() b, err := ioutil.ReadAll(f) if err != nil { return nil } if bytes.Index(b, []byte("acmetool")) >= 0 { installed = true return errStop } return nil }) return installed } func formulateCron(root bool) string { // Randomise cron time to avoid hammering the ACME server. var b [2]byte _, err := rand.Read(b[:]) log.Panice(err) m := b[0] % 60 h := b[1] % 24 s := "" if root { s = "SHELL=/bin/sh\nPATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin\nMAILTO=root\n" } s += fmt.Sprintf("%d %d * * * ", m, h) if root { s += "root " } s += fmt.Sprintf("%s --batch ", exepath.Abs) if *stateFlag != storage.RecommendedPath { s += fmt.Sprintf(`--state="%s" `, *stateFlag) } if !reflect.DeepEqual(*hooksFlag, hooks.RecommendedPaths) { for _, hookDir := range *hooksFlag { s += fmt.Sprintf(`--hooks="%s" `, hookDir) } } s += "reconcile\n" return s } func runningAsRoot() bool { return os.Getuid() == 0 } func promptCron() { if isCronjobInstalled() { return } var err error cronString := formulateCron(runningAsRoot()) if runningAsRoot() { _, err = os.Stat("/etc/cron.d") } else { _, err = exec.LookPath("crontab") } if err != nil { log.Warnf("Don't know how to install a cron job on this system, please install the following job:\n%s\n", cronString) return } r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "Install auto-renewal cronjob?", Body: "Would you like to install a cronjob to renew certificates automatically? This is recommended.", ResponseType: interaction.RTYesNo, UniqueID: "acmetool-quickstart-install-cronjob", }) log.Fatale(err, "interaction") if r.Cancelled { return } if runningAsRoot() { f, err := os.OpenFile("/etc/cron.d/acmetool", os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644) if err != nil { log.Errore(err, "failed to install cron job at /etc/cron.d/acmetool (does the file already exist?), wanted to install: ", cronString) return } defer f.Close() f.Write([]byte(cronString)) } else { err := amendUserCron(cronString, "acmetool") if err != nil { log.Errore(err, "failed to amend user crontab to add: ", cronString) return } } } func amendUserCron(cronLine, filterString string) error { b, err := getUserCron() if err != nil { return err } if bytes.Index(b, []byte("acmetool")) >= 0 { return nil } b = append(b, '\n') b = append(b, []byte(cronLine)...) return setUserCron(b) } func getUserCron() ([]byte, error) { errBuf := bytes.Buffer{} listCmd := exec.Command("crontab", "-l") listCmd.Stderr = &errBuf b, err := listCmd.Output() if err == nil { return b, nil } // crontab -l returns 1 if no crontab is installed, grep stderr to identify this condition if bytes.Index(errBuf.Bytes(), []byte("no crontab for")) >= 0 { return nil, nil } return b, nil } func setUserCron(b []byte) error { setCmd := exec.Command("crontab", "-") setCmd.Stdin = bytes.NewReader(b) setCmd.Stdout = os.Stdout setCmd.Stderr = os.Stderr return setCmd.Run() } func promptInstallCombinedHooks() bool { // Always install if the hook is already installed. hooksPaths := *hooksFlag if len(hooksPaths) == 0 { hooksPaths = hooks.DefaultPaths } if hooks.Exists(hooksPaths, "haproxy") { return true } // Prompt. r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "Install combined certificate files?", Body: fmt.Sprintf(`By default, acmetool stores certificates and private keys separately. The vast majority of daemons prefer this format. However, some daemons, such as HAProxy, require combined files which contain both certificate and private key. acmetool doesn't create such files by default to avoid creating unnecessary copies of private keys. acmetool can install a notification hook that will automatically generate an additional file called "haproxy" in every certificate directory. (These files are accepted by more than just HAProxy; the name is used for legacy reasons.) This means that you can point such daemons at "%s/live/HOSTNAME/haproxy". These files will also be accessible in a directory of their own, "%s/haproxy/HOSTNAME", for daemons which like a directory of files named after hostnames. If you place a PEM-encoded DH parameter file at "%s/conf/dhparams", those will also be included in each "haproxy" file. This is optional. Examples of daemons requiring combined files include HAProxy, Hitch, Quassel, Freeswitch. The hook script will not generate the files unless one of these daemons is detected, or you configure it to always generate combined files. (See the hook script for configuration documentation.) Therefore, installing the script is a no-op on systems without these daemons installed, and it is always safe to say yes here. Do you want to install the combined file generation hook? If in doubt, say yes.`, *stateFlag, *stateFlag, *stateFlag), ResponseType: interaction.RTYesNo, Implicit: !*expertFlag, UniqueID: "acmetool-quickstart-install-haproxy-script", }) if err != nil { // Install by default, since the hook script does nothing unless a service requiring it is // installed. Can still be overriden by --expert or response file. return true } return !r.Cancelled } var usernamesToTry = []string{"daemon", "nobody"} func determineAppropriateUsername() (string, error) { for _, u := range usernamesToTry { _, err := passwd.ParseUID(u) if err == nil { return u, nil } } return "", fmt.Errorf("cannot find appropriate username") } func promptRSAKeySize() int { r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "RSA Key Size", Body: `Please enter the RSA key size to use for keys and account keys. The recommended key size is 2048. Unsupported key sizes will be clamped to the nearest supported value at generation time (the current minimum is 2048; the current maximum is 4096). Leave blank to use the recommended value, currently 2048.`, ResponseType: interaction.RTLineString, UniqueID: "acmetool-quickstart-rsa-key-size", Implicit: !*expertFlag, }) if err != nil { return 0 } if r.Cancelled { os.Exit(1) return 0 } v := strings.TrimSpace(r.Value) if v == "" { return 0 } n, err := strconv.ParseUint(v, 10, 31) if err != nil { interaction.Auto.Prompt(&interaction.Challenge{ Title: "Invalid RSA Key Size", Body: "The RSA key size must be an integer in decimal form.", UniqueID: "acmetool-quickstart-invalid-rsa-key-size", }) return promptRSAKeySize() } return int(n) } func promptKeyType() string { r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "Key Type Selection", Body: `Select the type of keys you want to use for account keys and certificates. If in doubt, select RSA.`, ResponseType: interaction.RTSelect, Options: []interaction.Option{ { Title: "RSA", Value: "rsa", }, { Title: "ECDSA", Value: "ecdsa", }, }, UniqueID: "acmetool-quickstart-key-type", Implicit: !*expertFlag, }) if err != nil { return "rsa" } if r.Cancelled { os.Exit(1) return "" } return r.Value } func promptECDSACurve() string { r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "ECDSA Curve Selection", Body: `Please select the ECDSA curve to use for keys and account keys. NOTE: nistp521 is not as well supported as the others and is not supported by Let's Encrypt.`, ResponseType: interaction.RTSelect, Options: []interaction.Option{ { Title: "NIST P-256 (recommended)", Value: "nistp256", }, { Title: "NIST P-384", Value: "nistp384", }, { Title: "NIST P-521 (limited support)", Value: "nistp521", }, }, UniqueID: "acmetool-quickstart-ecdsa-curve", Implicit: !*expertFlag, }) if err != nil { return "" } if r.Cancelled { os.Exit(1) return "" } return r.Value } func promptWebrootDir() string { r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "Enter Webroot Path", Body: `Please enter the path at which challenges should be stored. If your webroot path is /var/www, you would enter /var/www/.well-known/acme-challenge here. The directory will be created if it does not exist. Webroot paths vary by OS; please consult your web server configuration. `, ResponseType: interaction.RTLineString, UniqueID: "acmetool-quickstart-webroot-path", }) log.Fatale(err, "interaction") if r.Cancelled { os.Exit(1) return "" } path := r.Value path = strings.TrimRight(strings.TrimSpace(path), "/") if !filepath.IsAbs(path) { interaction.Auto.Prompt(&interaction.Challenge{ Title: "Invalid Webroot Path", Body: "The webroot path must be an absolute path.", UniqueID: "acmetool-quickstart-webroot-path-invalid", }) return promptWebrootDir() } if !strings.HasSuffix(path, "/.well-known/acme-challenge") { r1 := r r, err = interaction.Auto.Prompt(&interaction.Challenge{ Title: "Are you sure?", Body: `The webroot path you have entered does not end in "/.well-known/acme-challenge". This path will only work if you have specially configured your webserver to map requests for that path to the specified directory. Do you want to continue? To enter a different webroot path, select No.`, ResponseType: interaction.RTYesNo, Implicit: *batchFlag || r1.Noninteractive, UniqueID: "acmetool-quickstart-webroot-path-unlikely", }) if r != nil && r.Cancelled { return promptWebrootDir() } } err = os.MkdirAll(path, 0755) log.Fatale(err, "could not create directory: ", path) return path } func promptGettingStarted() { if *batchFlag { return } interaction.PrintStderrMessage( "Quickstart Complete", fmt.Sprintf(`The quickstart process is complete. Ensure your chosen challenge conveyance method is configured properly before attempting to request certificates. You can find more information about how to configure your system for each method in the acmetool documentation: https://github.com/hlandau/acmetool/blob/master/_doc/WSCONFIG.md To request a certificate, run: $ sudo acmetool want example.com www.example.com If the certificate is successfully obtained, it will be placed in %s/live/example.com/{cert,chain,fullchain,privkey}. `, *stateFlag)) } func promptHookMethod() string { r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "Select Challenge Conveyance Method", Body: `acmetool needs to be able to convey challenge responses to the ACME server in order to prove its control of the domains for which you issue certificates. These authorizations expire rapidly, as do ACME-issued certificates (Let's Encrypt certificates have a 90 day lifetime), thus it is essential that the completion of these challenges is a) automated and b) functioning properly. There are several options by which challenges can be facilitated: WEBROOT: The webroot option installs challenge files to a given directory. You must configure your web server so that the files will be available at . For example, if your webroot is "/var/www", specifying a webroot of "/var/www/.well-known/acme-challenge" is likely to work well. The directory will be created automatically if it does not already exist. PROXY: The proxy option requires you to configure your web server to proxy requests for paths under /.well-known/acme-challenge/ to a special web server running on port 402, which will serve challenges appropriately. REDIRECTOR: The redirector option runs a special web server daemon on port 80. This means that you cannot run your own web server on port 80. The redirector redirects all HTTP requests to the equivalent HTTPS URL, so this is useful if you want to enforce use of HTTPS. You will need to configure your web server to not listen on port 80, and you will need to configure your system to run "acmetool redirector" as a daemon. If your system uses systemd, an appropriate unit file can automatically be installed. LISTEN: Directly listen on port 80 or 443, whichever is available, in order to complete challenges. This is useful only for development purposes. STATELESS: Some web servers can be configured to respond to challenges themselves. This removes any need for interaction with acmetool. See documentation for web server support. HOOK: Programmatic challenge provisioning. Advanced users only. Please see documentation.`, ResponseType: interaction.RTSelect, Options: []interaction.Option{ { Title: "WEBROOT - Place challenges in a directory", Value: "webroot", }, {Title: "PROXY - I'll proxy challenge requests to an HTTP server", Value: "proxy", }, {Title: "REDIRECTOR - I want to use acmetool's redirect-to-HTTPS functionality", Value: "redirector", }, {Title: "LISTEN - Listen on port 80 or 443 (only useful for development purposes)", Value: "listen", }, {Title: "STATELESS - I will configure my web server with my account key", Value: "stateless", }, {Title: "HOOK - I will write scripts to provision challenges", Value: "hook", }, }, UniqueID: "acmetool-quickstart-choose-method", }) log.Fatale(err, "interaction") if r.Cancelled { os.Exit(1) return "" } return r.Value } func promptServerURL() string { var options []interaction.Option acmeendpoints.Visit(func(e *acmeendpoints.Endpoint) error { t := e.Title switch e.Code { case "LetsEncryptLive": t += " - I want live certificates" case "LetsEncryptStaging": t += " - I want test certificates" } options = append(options, interaction.Option{ Title: t, Value: e.DirectoryURL, }) return nil }) options = append(options, interaction.Option{ Title: "Enter an ACME server URL", Value: "url", }) r, err := interaction.Auto.Prompt(&interaction.Challenge{ Title: "Select ACME Server", Body: `Please choose an ACME server from which to request certificates. Your principal choices are the Let's Encrypt Live Server, and the Let's Encrypt Staging Server. You can use the Let's Encrypt Live Server to get real certificates. The Let's Encrypt Staging Server does not issue publically trusted certificates. It is useful for development purposes, as it has far higher rate limits than the live server.`, ResponseType: interaction.RTSelect, Options: options, UniqueID: "acmetool-quickstart-choose-server", }) log.Fatale(err, "interaction") if r.Cancelled { os.Exit(1) return "" } if r.Value == "url" { for { r, err = interaction.Auto.Prompt(&interaction.Challenge{ Title: "Select ACME Server", Body: `Please enter the "Directory URL" of an ACME server. This must be an HTTPS URL pointing to the ACME directory for the server.`, ResponseType: interaction.RTLineString, UniqueID: "acmetool-quickstart-enter-directory-url", }) log.Fatale(err, "interaction") if r.Cancelled { os.Exit(1) return "" } if acmeapi.ValidURL(r.Value) { break } interaction.Auto.Prompt(&interaction.Challenge{ Title: "Invalid ACME URL", Body: "That was not a valid ACME Directory URL. An ACME Directory URL must be a valid HTTPS URL.", ResponseType: interaction.RTAcknowledge, UniqueID: "acmetool-quickstart-invalid-directory-url", }) log.Fatale(err, "interaction") if r.Cancelled { os.Exit(1) return "" } } } return r.Value } acmetool-0.2.2/cmd/000077500000000000000000000000001435652113300140645ustar00rootroot00000000000000acmetool-0.2.2/cmd/acmetool/000077500000000000000000000000001435652113300156675ustar00rootroot00000000000000acmetool-0.2.2/cmd/acmetool/main.go000066400000000000000000000003111435652113300171350ustar00rootroot00000000000000// Legacy entrypoint for people using github.com/hlandau/acme/cmd/acmetool. // Moved to github.com/hlandau/acmetool. package main import "github.com/hlandau/acmetool/cli" func main() { cli.Main() } acmetool-0.2.2/fdb/000077500000000000000000000000001435652113300140545ustar00rootroot00000000000000acmetool-0.2.2/fdb/fdb.go000066400000000000000000000431231435652113300151410ustar00rootroot00000000000000// Package fdb allows for the use of a filesystem directory as a simple // database on UNIX-like systems. package fdb import ( "fmt" deos "github.com/hlandau/goutils/os" "github.com/hlandau/xlog" "gopkg.in/hlandau/svcutils.v1/passwd" "io/ioutil" "os" "path/filepath" "strings" ) var log, Log = xlog.New("fdb") // FDB instance. type DB struct { cfg Config path string extantDirs map[string]struct{} effectivePermissions []Permission } // FDB configuration. type Config struct { Path string Permissions []Permission PermissionsPath string // If not "", allow permissions to be overriden from this file. } // Expresses the permission policy for a given path. The first match is used. type Permission struct { // The path to which the permission applies. May contain wildcards and must // match a collection path, not an object path. e.g. // accounts/*/* // tmp // The directory will receive the DirMode and any objects inside will receive // the FileMode. Since all new files are initially created in tmp, it is // essential that tp have permissions specified which are strictly as strict // or stricter than the permissions of the strictest collection. // The root collection matches the path ".". Path string FileMode os.FileMode DirMode os.FileMode UID string // if not "", user/UID to enforce GID string // if not "", group/GID to enforce } // Merge b into a. func mergePermissions(a, b []Permission) ([]Permission, error) { var r []Permission r = append(r, a...) am := map[string]int{} for i := range a { am[a[i].Path] = i } for i := range b { ai, ok := am[b[i].Path] if ok { r[ai] = b[i] } else { r = append(r, b[i]) } } return r, nil } // Return a copy of a but without any permissions with paths in pathsToErase. func erasePermissionsByPath(a []Permission, pathsToErase map[string]struct{}) []Permission { var r []Permission for i := range a { _, erase := pathsToErase[a[i].Path] if !erase { r = append(r, a[i]) } } return r } // Open a fdb database or create a new database. func Open(cfg Config) (*DB, error) { path, err := filepath.Abs(cfg.Path) if err != nil { return nil, err } db := &DB{ cfg: cfg, path: path, extantDirs: map[string]struct{}{}, } err = db.clearTmp() if err != nil { return nil, err } err = db.Verify() if err != nil { return nil, err } return db, nil } // Closes the database. func (db *DB) Close() error { return nil } // Integrity checks // Verify the consistency and validity of the database (e.g. link targets). // This is called automatically when opening the database so you shouldn't need // to call it. func (db *DB) Verify() error { err := db.loadPermissions() if err != nil { return err } err = db.createDirs() if err != nil { return err } // don't do this until now as EvalSymlinks requires the directory to exist db.path, err = filepath.EvalSymlinks(db.path) if err != nil { return err } if len(db.cfg.Permissions) > 0 { err = db.conformPermissions() if err != nil { return err } } return nil } func (db *DB) createDirs() error { for _, p := range db.effectivePermissions { if strings.IndexByte(p.Path, '*') >= 0 { continue } uid, gid, err := resolveUIDGID(&p) if err != nil { return err } err = mkdirAllWithOwner(filepath.Join(db.path, p.Path), p.DirMode, uid, gid) if err != nil { return err } } return nil } func (db *DB) loadPermissions() error { db.effectivePermissions = db.cfg.Permissions if db.cfg.PermissionsPath == "" { return nil } r, err := db.Collection("").Open(db.cfg.PermissionsPath) if err != nil { if os.IsNotExist(err) { return nil } return err } defer r.Close() ps, erasePaths, err := parsePermissions(r) if err != nil { return fmt.Errorf("badly formatted permissions file: %v", err) } mergedPermissions, err := mergePermissions(db.cfg.Permissions, ps) if err != nil { return err } mergedPermissions = erasePermissionsByPath(mergedPermissions, erasePaths) db.effectivePermissions = mergedPermissions return nil } // Returns the UID and GID to enforce. If the UID or GID is -1, it is not // to be enforced. Neither or both or either of the UID or GID may be -1. func resolveUIDGID(p *Permission) (uid, gid int, err error) { if p.UID == "$r" { uid = os.Getuid() } else if p.UID != "" { uid, err = passwd.ParseUID(p.UID) if err != nil { return } } else { uid = -1 } if p.GID == "$r" { gid = os.Getgid() } else if p.GID != "" { gid, err = passwd.ParseGID(p.GID) if err != nil { return } } else { gid = -1 } return } func isHiddenRelPath(rp string) bool { return strings.HasPrefix(rp, ".") || strings.Index(rp, "/.") >= 0 } // Change all directory permissions to be correct. func (db *DB) conformPermissions() error { err := filepath.Walk(db.path, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rpath, err := filepath.Rel(db.path, path) if err != nil { return err } // Some people want to store hidden files/directories inside the ACME state // directory without permissions enforcement. Since it's reasonable to // assume I'll never want to amend the ACME-SSS specification to specify // top-level directories inside a state directory, this shouldn't have any // security implications. Symlinks inside the state directory (whose state // directory paths themselves don't contain "/." and are thus ignored) // cannot reference ignored paths, as their permissions are not managed and // this is not safe. This is enforced elsewhere. if isHiddenRelPath(rpath) { return nil } mode := info.Mode() switch mode & os.ModeType { case 0: case os.ModeDir: db.extantDirs[rpath] = struct{}{} case os.ModeSymlink: l, err := os.Readlink(path) if err != nil { return err } if filepath.IsAbs(l) { return fmt.Errorf("database symlinks must not have absolute targets: %v: %v", path, l) } ll := filepath.Join(filepath.Dir(path), l) ll, err = filepath.Abs(ll) if err != nil { return err } ok, err := pathIsWithin(ll, db.path) if err != nil { return err } if !ok { return fmt.Errorf("database symlinks must point to within the database directory: %v: %v", path, ll) } rll, err := filepath.Rel(db.path, ll) if err != nil { return err } if isHiddenRelPath(rll) { return fmt.Errorf("database symlinks cannot target hidden files within the database directory: %v: %v", path, ll) } _, err = os.Stat(ll) if os.IsNotExist(err) { log.Warnf("broken symlink, removing: %v -> %v", path, l) err := os.Remove(path) if err != nil { return err } } else if err != nil { log.Errore(err, "stat symlink") return err } default: return fmt.Errorf("unexpected file type in state directory: %s", mode) } perm := db.longestMatching(rpath) if perm == nil { log.Warnf("object without any permissions specified: %v", rpath) } else { correctPerm := perm.FileMode if (mode & os.ModeType) == os.ModeDir { correctPerm = perm.DirMode } if (mode & os.ModeType) != os.ModeSymlink { if fperm := mode.Perm(); fperm != correctPerm { log.Warnf("%#v has wrong mode %v, changing to %v", rpath, fperm, correctPerm) err := os.Chmod(path, correctPerm) if err != nil { return err } } } correctUID, correctGID, err := resolveUIDGID(perm) if err != nil { return err } if correctUID >= 0 || correctGID >= 0 { curUID, err := deos.GetFileUID(info) if err != nil { return err } curGID, err := deos.GetFileGID(info) if err != nil { return err } if correctUID < 0 { correctUID = curUID } if correctGID < 0 { correctGID = curGID } if curUID != correctUID || curGID != correctGID { log.Warnf("%#v has wrong UID/GID %v/%v, changing to %v/%v", rpath, curUID, curGID, correctUID, correctGID) err := os.Lchown(path, correctUID, correctGID) // Can't chown if not root so be a bit forgiving, but always moan log.Errore(err, "could not lchown file ", rpath) } } } return nil }) if err != nil { return err } return nil } func (db *DB) longestMatching(path string) *Permission { pattern := "" var perm *Permission if filepath.IsAbs(path) { panic("do not call longestMatching with an absolute path") } for { for _, p := range db.effectivePermissions { m, err := filepath.Match(p.Path, path) if err != nil { return nil } if m && len(p.Path) > len(pattern) { pattern = p.Path p2 := p perm = &p2 } } if perm != nil { return perm } if path == "." { break } path = filepath.Join(path, "..") } return nil } func pathIsWithin(subject, root string) (bool, error) { return strings.HasPrefix(subject, ensureSeparator(root)), nil } func ensureSeparator(p string) string { if !strings.HasSuffix(p, string(filepath.Separator)) { return p + string(filepath.Separator) } return p } func (db *DB) clearTmp() error { ms, err := filepath.Glob(filepath.Join(db.path, "tmp", "*")) if err != nil { return err } for _, m := range ms { err := os.RemoveAll(m) if err != nil { return err } } return nil } func (db *DB) ensurePath(path string) error { _, ok := db.extantDirs[path] if ok { return nil } mode := os.FileMode(0755) perm := db.longestMatching(path) if perm != nil { mode = perm.DirMode } uid, gid, err := resolveUIDGID(perm) if err != nil { return err } err = mkdirAllWithOwner(filepath.Join(db.path, path), mode, uid, gid) if err != nil { return err } db.extantDirs[path] = struct{}{} return nil } // Database Access // Collection represents a collection of objects in an fdb database. More // accurately, it is a contextual point in the database hierarchy which object // references are interpreted relative to. type Collection struct { db *DB name string ensuredPath bool } // Obtain a collection. The collection will be created automatically if it does // not already exist. Guaranteed to return a non-nil value. func (db *DB) Collection(collectionName string) *Collection { return &Collection{ db: db, name: collectionName, } } // Obtain a collection underneath the given collection. The collection will be // created automatically if it does not already exist. Guaranteed to return a // non-nil value. func (c *Collection) Collection(name string) *Collection { return &Collection{ db: c.db, name: filepath.Join(c.name, name), } } func (c *Collection) ensurePath() error { if c.ensuredPath { return nil } err := c.db.ensurePath(c.name) if err != nil { return err } c.ensuredPath = true return nil } // Stream for reading an object from the database. type ReadStream interface { Close() error Read([]byte) (int, error) Seek(int64, int) (int64, error) } // Stream for writing an object to the database. Changes do not take effect // until the stream is closed, at which case they are applied atomically. type WriteStream interface { ReadStream Write([]byte) (int, error) // Abort writing of the file. The file is not changed. Calling Close or // CloseAbort after calling this has no effect. CloseAbort() error } // A link points to a given name in a given collection. The database ensures // referential integrity. type Link struct { Target string // "collection1/subcollection/etc/objectName" } // Returns the database from which the collection was created. func (c *Collection) DB() *DB { return c.db } // Returns the collection path. func (c *Collection) Name() string { return c.name } // Returns the OS path to the file with the given name inside the collection. // If name is "", returns the OS path to the collection. func (c *Collection) OSPath(name string) string { c.ensurePath() // ignore error return filepath.Join(c.db.path, c.name, name) } // Atomically delete an existing object or link or subcollection in the given // collection with the given name. Returns nil if the object does not exist. func (c *Collection) Delete(name string) error { return os.RemoveAll(filepath.Join(c.db.path, c.name, name)) } // Returned when calling Open() on a symlink. (To open symlinks, use Openl.) var ErrIsLink = fmt.Errorf("cannot open symlink") // Open an existing object in the given collection with the given name. The // object is read-only. Returns an error if the object does not exist or // is a link. func (c *Collection) Open(name string) (ReadStream, error) { return c.open(name, false) } // Like Open(), but follows links automatically. func (c *Collection) Openl(name string) (ReadStream, error) { return c.open(name, true) } func (c *Collection) open(name string, allowSymlinks bool) (ReadStream, error) { fi, err := os.Lstat(filepath.Join(c.db.path, c.name, name)) again: if err != nil { return nil, err } m := fi.Mode() switch m & os.ModeType { case 0: case os.ModeSymlink: if !allowSymlinks { return nil, ErrIsLink } fi, err = os.Stat(filepath.Join(c.db.path, c.name, name)) goto again case os.ModeDir: return nil, fmt.Errorf("cannot open a collection") default: return nil, fmt.Errorf("unknown file type") } f, err := os.Open(filepath.Join(c.db.path, c.name, name)) if err != nil { return nil, err } return f, nil } // Create a new object in the given collection with the given name. If the // object already exists, it will be overwritten atomically. Changes only take // effect once the stream is closed. func (c *Collection) Create(name string) (WriteStream, error) { err := c.ensurePath() if err != nil { return nil, err } f, err := ioutil.TempFile(filepath.Join(c.db.path, "tmp"), "tmp.") if err != nil { return nil, err } return &closeWrapper{ db: c.db, f: f, finalName: filepath.Join(c.db.path, c.name, name), finalNameRel: filepath.Join(c.name, name), writing: true, }, nil } type closeWrapper struct { db *DB f *os.File finalName string finalNameRel string closed bool writing bool } func (cw *closeWrapper) Close() error { if cw.closed { return nil } n := cw.f.Name() err := cw.f.Close() if err != nil { return err } err = os.Rename(n, cw.finalName) if err != nil { return err } if cw.writing { err = cw.db.enforcePermissionsOnFile(cw.finalNameRel, cw.finalNameRel, false) if err != nil { return err } } cw.closed = true return nil } func (db *DB) enforcePermissionsOnFile(rpath, rpathFinal string, symlink bool) error { p := db.longestMatching(rpathFinal) if p == nil { return nil } fpath := filepath.Join(db.path, rpath) // TempFile creates files with mode 0600, so it's OK to chmod/chown it here, race-wise. correctUID, correctGID, err := resolveUIDGID(p) if err != nil { return err } curUID, curGID := os.Getuid(), os.Getgid() if correctUID < 0 { correctUID = curUID } if correctGID < 0 { correctGID = curGID } log.Debugf("enforce permissions: %s %d/%d %d/%d", rpath, curUID, curGID, correctUID, correctGID) if correctUID != curUID || correctGID != curGID { err := os.Lchown(fpath, correctUID, correctGID) // failure is nonfatal, may not be root log.Errore(err, "could not set correct owner for file", fpath) } if !symlink { err = os.Chmod(fpath, p.FileMode) if err != nil { return err } } return nil } func (cw *closeWrapper) CloseAbort() error { if cw.closed { return nil } err := cw.f.Close() if err != nil { return err } err = os.Remove(cw.f.Name()) if err != nil { return err } cw.closed = true return nil } func (cw *closeWrapper) Read(b []byte) (int, error) { return cw.f.Read(b) } func (cw *closeWrapper) Write(b []byte) (int, error) { return cw.f.Write(b) } func (cw *closeWrapper) Seek(p int64, w int) (int64, error) { return cw.f.Seek(p, w) } // Read a link in the given collection with the given name. Returns an error // if the object does not exist or is not a link. func (c *Collection) ReadLink(name string) (Link, error) { fpath := filepath.Join(c.db.path, c.name, name) l, err := os.Readlink(fpath) if err != nil { return Link{}, err } flink := filepath.Join(filepath.Dir(fpath), l) lr, err := filepath.Rel(c.db.path, flink) if err != nil { return Link{}, err } return Link{Target: lr}, nil } // Write a link in the given collection with the given name. Any existing // object or link is overwritten atomically. func (c *Collection) WriteLink(name string, target Link) error { err := c.ensurePath() if err != nil { return err } from := filepath.Join(c.db.path, c.name, name) to := filepath.Join(c.db.path, target.Target) toRel, err := filepath.Rel(filepath.Dir(from), to) if err != nil { return err } // if the link already exists, do nothing existingTo, err := os.Readlink(from) if err == nil && existingTo == toRel { return nil } tmpName, err := tempSymlink(toRel, filepath.Join(c.db.path, "tmp")) if err != nil { return err } err = c.db.enforcePermissionsOnFile(filepath.Join("tmp", filepath.Base(tmpName)), filepath.Join(c.name, name), true) if err != nil { return err } return os.Rename(tmpName, from) } func (c *Collection) ListAll() ([]string, error) { ms, err := filepath.Glob(filepath.Join(c.db.path, c.name, "*")) if err != nil { return nil, err } var objs []string for _, m := range ms { objs = append(objs, filepath.Base(m)) } return objs, nil } // List the objects and collections in the collection. Filenames beginning with // '.' are hidden. func (c *Collection) List() ([]string, error) { s, err := c.ListAll() if err != nil { return nil, err } s2 := make([]string, 0, len(s)) for _, x := range s { if strings.HasPrefix(x, ".") { continue } s2 = append(s2, x) } return s2, nil } acmetool-0.2.2/fdb/fdb_test.go000066400000000000000000000067711435652113300162100ustar00rootroot00000000000000package fdb import ( "io/ioutil" "os" "path/filepath" "reflect" "testing" ) func TestFDB(t *testing.T) { dir, err := ioutil.TempDir("", "acmefdbtest") if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) const permissionsfile = ` # This is an example permissions file alpha 0604 0705 alpha/foo 0640 0750 ` err = ioutil.WriteFile(filepath.Join(dir, "Permissionsfile"), []byte(permissionsfile), 0644) if err != nil { t.Fatal(err) } db, err := Open(Config{ Path: dir, Permissions: []Permission{ {Path: ".", FileMode: 0644, DirMode: 0755}, {Path: "alpha", FileMode: 0644, DirMode: 0755}, {Path: "beta", FileMode: 0600, DirMode: 0700}, {Path: "tmp", FileMode: 0600, DirMode: 0700}, }, PermissionsPath: "Permissionsfile", }) if err != nil { t.Fatal(err) } defer db.Close() err = db.Verify() if err != nil { t.Fatal(err) } c := db.Collection("alpha/foo/x") if c.DB() != db { panic("...") } if c.Name() != "alpha/foo/x" { panic(c.Name()) } if c.OSPath("") != filepath.Join(dir, "alpha/foo/x") { panic(c.OSPath("")) } if c.OSPath("xyz") != filepath.Join(dir, "alpha/foo/x/xyz") { panic(c.OSPath("xyz")) } cc := db.Collection("alpha").Collection("foo").Collection("x") if cc.OSPath("xyz") != filepath.Join(dir, "alpha/foo/x/xyz") { panic(c.OSPath("xyz")) } b := []byte("\r\n\t 42 \n\n") err = WriteBytes(c, "xyz", b) if err != nil { t.Fatal(err) } n, err := Uint(c, "xyz", 31) if err != nil { t.Fatal(err) } if n != 42 { t.Fatalf("expected 42, got %v", n) } if !Exists(c, "xyz") { t.Fatalf("expected xyz to exist") } if Exists(c, "xyz1") { t.Fatalf("did not expect xyz1 to exist") } fi, err := os.Stat(c.OSPath("xyz")) if err != nil { t.Fatal(err) } if fi.Mode() != 0640 { t.Fatal("unexpected mode") } err = CreateEmpty(db.Collection("alpha"), "nak") if err != nil { t.Fatal(err) } fi, err = os.Stat(db.Collection("alpha").OSPath("nak")) if err != nil { t.Fatal(err) } if fi.Mode() != 0604 { t.Fatal("unexpected mode") } err = CreateEmpty(c, "xyz1") if err != nil { t.Fatal(err) } if !Exists(c, "xyz1") { t.Fatalf("expected xyz1 to exist") } err = c.Delete("xyz1") if err != nil { t.Fatal(err) } f, err := c.Create("xyz") if err != nil { t.Fatal(err) } _, err = f.Write([]byte("blah blah blah.")) if err != nil { t.Fatal(err) } err = f.CloseAbort() if err != nil { t.Fatal(err) } b2, err := Bytes(c.Open("xyz")) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(b, b2) { t.Fatal("mismatch") } s2, err := String(c.Open("xyz")) if err != nil { t.Fatal(err) } if s2 != string(b2) { t.Fatal("mismatch") } err = c.WriteLink("lnk", Link{Target: "alpha/foo/x/xyz"}) if err != nil { t.Fatal(err) } lnk, err := c.ReadLink("lnk") if err != nil { t.Fatal(err) } if lnk.Target != "alpha/foo/x/xyz" { t.Fatal(lnk.Target) } b2, err = Bytes(c.Openl("lnk")) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(b, b2) { t.Fatal("mismatch") } err = db.Verify() if err != nil { t.Fatal(err) } names, err := c.List() if err != nil { t.Fatal(err) } correctNames := []string{"lnk", "xyz"} if !reflect.DeepEqual(names, correctNames) { t.Fatalf("wrong names: %v != %v", names, correctNames) } err = c.Delete("xyz") if err != nil { t.Fatal(err) } err = db.Verify() if err != nil { t.Fatal(err) } _, err = c.Open("lnk") if err == nil { t.Fatal("lnk should have been removed") } err = db.Verify() if err != nil { t.Fatal(err) } } acmetool-0.2.2/fdb/mkdir.go000066400000000000000000000026171435652113300155170ustar00rootroot00000000000000package fdb import ( "os" "syscall" ) // Like os.MkdirAll but new components created have the given UID and GID. func mkdirAllWithOwner(absPath string, perm os.FileMode, uid, gid int) error { // From os/path.go. // Fast path: if we can tell whether path is a directory or file, stop with success or error. dir, err := os.Stat(absPath) if err == nil { if dir.IsDir() { return nil } return &os.PathError{Op: "mkdir", Path: absPath, Err: syscall.ENOTDIR} } // Slow path: make sure parent exists and then call Mkdir for path. i := len(absPath) for i > 0 && os.IsPathSeparator(absPath[i-1]) { // Skip trailing path separator. i-- } j := i for j > 0 && !os.IsPathSeparator(absPath[j-1]) { // Scan backward over element. j-- } if j > 1 { // Create parent err = mkdirAllWithOwner(absPath[0:j-1], perm, uid, gid) if err != nil { return err } } // Parent now exists; invoke Mkdir and use its result. err = os.Mkdir(absPath, perm) if err != nil { // Handle arguments like "foo/." by double-checking that directory // doesn't exist. dir, err1 := os.Lstat(absPath) if err1 == nil && dir.IsDir() { return nil } return err } if uid >= 0 || gid >= 0 { if uid < 0 { uid = os.Getuid() } if gid < 0 { gid = os.Getgid() } err = os.Lchown(absPath, uid, gid) // ignore errors in case we aren't root log.Errore(err, "cannot chown ", absPath) } return nil } acmetool-0.2.2/fdb/parseperm.go000066400000000000000000000044471435652113300164120ustar00rootroot00000000000000package fdb import ( "bufio" "fmt" "gopkg.in/hlandau/svcutils.v1/passwd" "io" "os" "path/filepath" "regexp" "strconv" "strings" ) var rePermissionLine = regexp.MustCompile(`^(?P[^\s]+)\s+(?Pinherit|(?P[0-7]{3,4})\s+(?P[0-7]{3,4})(\s+(?P[^\s]+)\s+(?P[^\s]+))?)$`) func parsePermissions(r io.Reader) (ps []Permission, erasePaths map[string]struct{}, err error) { br := bufio.NewReader(r) Lnum := 0 erasePaths = map[string]struct{}{} seenPaths := map[string]struct{}{} for { Lnum++ L, err := br.ReadString('\n') if err == io.EOF { break } if err != nil { return nil, nil, err } L = strings.TrimSpace(L) if L == "" || strings.HasPrefix(L, "#") { continue } // keys/*/privkey 0640 0750 - - m := rePermissionLine.FindStringSubmatch(L) if m == nil { return nil, nil, fmt.Errorf("line %d: badly formatted line: %q", Lnum, L) } path := filepath.Clean(m[1]) if path == ".." || strings.HasPrefix(path, "../") || filepath.IsAbs(path) { return nil, nil, fmt.Errorf("line %d: path must remain within the DB root: %q", Lnum, L) } if _, seen := seenPaths[path]; seen { return nil, nil, fmt.Errorf("line %d: duplicate path entry: %q", Lnum, L) } seenPaths[path] = struct{}{} if m[2] == "inherit" { erasePaths[path] = struct{}{} continue } fileMode, err := strconv.ParseUint(m[3], 8, 12) if err != nil { return nil, nil, fmt.Errorf("line %d: invalid file mode: %q", Lnum, m[3]) } dirMode, err := strconv.ParseUint(m[4], 8, 12) if err != nil { return nil, nil, fmt.Errorf("line %d: invalid dir mode: %q", Lnum, m[4]) } // Validate UID uid := m[6] if uid == "-" { uid = "" } if uid != "" && uid != "$r" { _, err := passwd.ParseUID(uid) if err != nil { return nil, nil, fmt.Errorf("line %d: invalid UID: %q: %v", Lnum, uid, err) } } // Validate GID gid := m[7] if gid == "-" { gid = "" } if gid != "" && gid != "$r" { _, err = passwd.ParseGID(gid) if err != nil { return nil, nil, fmt.Errorf("line %d: invalid GID: %q: %v", Lnum, gid, err) } } // p := Permission{ Path: path, FileMode: os.FileMode(fileMode), DirMode: os.FileMode(dirMode), UID: uid, GID: gid, } ps = append(ps, p) } return ps, erasePaths, nil } acmetool-0.2.2/fdb/parseperm_test.go000066400000000000000000000026351435652113300174460ustar00rootroot00000000000000// +build cgo package fdb import ( "reflect" "strings" "testing" ) func TestParsePerm(t *testing.T) { var tests = []struct { In string Out []Permission Erase map[string]struct{} }{ {``, nil, map[string]struct{}{}}, {` # this is a comment foo/bar 0644 0755 foo/*/baz 0640 0750 alpha 0644 0755 root root beta 0644 0755 42 42 gamma 0644 0755 $r $r delta inherit x 0644 0755 root - y 0644 0755 - root `, []Permission{ {Path: "foo/bar", FileMode: 0644, DirMode: 0755}, {Path: "foo/*/baz", FileMode: 0640, DirMode: 0750}, {Path: "alpha", FileMode: 0644, DirMode: 0755, UID: "root", GID: "root"}, {Path: "beta", FileMode: 0644, DirMode: 0755, UID: "42", GID: "42"}, {Path: "gamma", FileMode: 0644, DirMode: 0755, UID: "$r", GID: "$r"}, {Path: "x", FileMode: 0644, DirMode: 0755, UID: "root", GID: ""}, {Path: "y", FileMode: 0644, DirMode: 0755, UID: "", GID: "root"}, }, map[string]struct{}{"delta": struct{}{}}}, } for _, tst := range tests { ps, erase, err := parsePermissions(strings.NewReader(tst.In)) if err != nil { t.Fatalf("error parsing permissions: %v", err) } if !reflect.DeepEqual(ps, tst.Out) { t.Fatalf("permissions don't match: got %#v, expected %#v", ps, tst.Out) } if !reflect.DeepEqual(erase, tst.Erase) { t.Fatalf("erase list doesn't match: got %v, expected %v", erase, tst.Erase) } } } acmetool-0.2.2/fdb/tempsymlink.go000066400000000000000000000013731435652113300167630ustar00rootroot00000000000000package fdb import ( "os" "path/filepath" "strconv" "sync" "time" ) var rand uint32 var randmu sync.Mutex func reseed() uint32 { return uint32(time.Now().UnixNano() + int64(os.Getpid())) } func nextSuffix() string { randmu.Lock() r := rand if r == 0 { r = reseed() } r = r*1664525 + 1013904223 rand = r randmu.Unlock() return strconv.Itoa(int(1e9 + r%1e9))[1:] } func tempSymlink(target string, fromDir string) (tmpName string, err error) { nconflict := 0 for i := 0; i < 10000; i++ { tmpName = filepath.Join(fromDir, "symlink."+nextSuffix()) err = os.Symlink(target, tmpName) if os.IsExist(err) { if nconflict++; nconflict > 10 { randmu.Lock() rand = reseed() randmu.Unlock() } continue } break } return } acmetool-0.2.2/fdb/util.go000066400000000000000000000033041435652113300153600ustar00rootroot00000000000000package fdb import ( "io/ioutil" "strconv" "strings" ) // Read a file as a string. Use like this: // // s, err := String(c.Open("file")) // func String(rs ReadStream, err error) (string, error) { if err != nil { return "", err } defer rs.Close() b, err := ioutil.ReadAll(rs) if err != nil { return "", err } return string(b), nil } // Read a file as []byte. Use like this: // // s, err := Bytes(c.Open("file")) // func Bytes(rs ReadStream, err error) ([]byte, error) { if err != nil { return nil, err } defer rs.Close() b, err := ioutil.ReadAll(rs) if err != nil { return nil, err } return b, nil } // Create an empty file, overwriting it if it exists. func CreateEmpty(c *Collection, name string) error { f, err := c.Create(name) if err != nil { return err } f.Close() return nil } // Determine whether a file exists. func Exists(c *Collection, name string) bool { f, err := c.Open(name) if err != nil { return false } defer f.Close() return true } // Write bytes to a file with the given name in the given collection. // // The byte arrays are concatenated in the given order. func WriteBytes(c *Collection, name string, bs ...[]byte) error { f, err := c.Create(name) if err != nil { return err } defer f.CloseAbort() for _, b := range bs { _, err = f.Write(b) if err != nil { return err } } f.Close() return nil } // Retrieve an unsigned integer in decimal form from a file with the given name // in the given collection. bits is passed to ParseUint. func Uint(c *Collection, name string, bits int) (uint64, error) { s, err := String(c.Open(name)) if err != nil { return 0, err } s = strings.TrimSpace(s) return strconv.ParseUint(s, 10, bits) } acmetool-0.2.2/hooks/000077500000000000000000000000001435652113300144445ustar00rootroot00000000000000acmetool-0.2.2/hooks/hooks.go000066400000000000000000000166741435652113300161340ustar00rootroot00000000000000// Package hooks provides functions to invoke a directory of executable hooks, // used to provide arbitrary handling of significant events. package hooks import ( "fmt" deos "github.com/hlandau/goutils/os" "github.com/hlandau/xlog" "os" "os/exec" "path/filepath" "strings" ) // Log site. var log, Log = xlog.New("acme.hooks") // The recommended hook paths are the paths at which executable hooks are // looked for. On POSIX-like systems, this is usually "/usr/lib/acme/hooks" and // "/usr/libexec/acme/hooks". var RecommendedPaths []string // The default hook paths default to the recommended hook paths but could be // changed at runtime. var DefaultPaths []string // Do not use. For build-time use by distributions only. If set to a non-empty // string at build time, DefaultPaths is set to a slice containing only this // value. var DefaultPath string // Provides contextual configuration information when executing a hook. type Context struct { // The hook directories to use. If zero-length, uses DefaultPaths. HookDirs []string // The state directory to report. Required. StateDir string // Arbitrary environment variables to set. Env map[string]string } func init() { // Allow overriding at build time. if DefaultPath != "" { DefaultPaths = []string{DefaultPath} RecommendedPaths = DefaultPaths return } DefaultPaths = []string{"/usr/libexec/acme/hooks", "/usr/lib/acme/hooks"} // Put the preferred directory first. prefDir, err := preferredHookDir(DefaultPaths) if err == nil { newDefaultPaths := []string{prefDir} for _, dp := range DefaultPaths { if dp != prefDir { newDefaultPaths = append(newDefaultPaths, dp) } } DefaultPaths = newDefaultPaths } RecommendedPaths = DefaultPaths } // Notifies hook programs that a live symlink has been updated. // // If hookDirectory is "", DefaultHookPath is used. stateDirectory and // hostnames are passed as information to the hooks. func NotifyLiveUpdated(ctx *Context, hostnames []string) error { if len(hostnames) == 0 { return nil } hostnameList := strings.Join(hostnames, "\n") + "\n" _, err := runParts(ctx, []byte(hostnameList), "live-updated") if err != nil { return err } return nil } // Invokes HTTP challenge start hooks. // // installed indicates whether at least one hook script indicated success. err // could still be returned in this case if an error occurs while executing some // other hook. func ChallengeHTTPStart(ctx *Context, hostname, targetFileName, token, ka string) (installed bool, err error) { return runParts(ctx, []byte(ka), "challenge-http-start", hostname, targetFileName, token) } func ChallengeHTTPStop(ctx *Context, hostname, targetFileName, token, ka string) error { _, err := runParts(ctx, []byte(ka), "challenge-http-stop", hostname, targetFileName, token) return err } func ChallengeTLSSNIStart(ctx *Context, hostname, targetFileName, validationName1, validationName2 string, pem string) (installed bool, err error) { return runParts(ctx, []byte(pem), "challenge-tls-sni-start", hostname, targetFileName, validationName1, validationName2) } func ChallengeTLSSNIStop(ctx *Context, hostname, targetFileName, validationName1, validationName2 string, pem string) (installed bool, err error) { return runParts(ctx, []byte(pem), "challenge-tls-sni-stop", hostname, targetFileName, validationName1, validationName2) } func challengeDNS(ctx *Context, op, hostname, targetFileName, body string) (installed bool, err error) { wildcardFlag := "" if strings.HasPrefix(hostname, "*.") { hostname = hostname[2:] wildcardFlag = "wildcard" } return runParts(ctx, nil, op, hostname, targetFileName, body, wildcardFlag) } func ChallengeDNSStart(ctx *Context, hostname, targetFileName, body string) (installed bool, err error) { return challengeDNS(ctx, "challenge-dns-start", hostname, targetFileName, body) } func ChallengeDNSStop(ctx *Context, hostname, targetFileName, body string) (uninstalled bool, err error) { return challengeDNS(ctx, "challenge-dns-stop", hostname, targetFileName, body) } func mergeEnvMap(m map[string]string, e []string) { for _, x := range e { parts := strings.SplitN(x, "=", 2) if len(parts) < 2 { continue } m[parts[0]] = parts[1] } } func flattenEnvMap(m map[string]string) []string { var e []string for k, v := range m { e = append(e, k+"="+v) } return e } func mergeEnv(envs ...[]string) []string { m := map[string]string{} for _, env := range envs { mergeEnvMap(m, env) } return flattenEnvMap(m) } // Implements functionality similar to the "run-parts" command on many distros. // Implementations vary, so it is reimplemented here. func runParts(ctx *Context, stdinData []byte, args ...string) (anySucceeded bool, err error) { dirs := ctx.HookDirs if len(dirs) == 0 { dirs = DefaultPaths } var dirs2 []string for _, directory := range dirs { fi, err := os.Stat(directory) if err == nil { // Do not execute a world-writable directory. if (fi.Mode() & 02) != 0 { return false, fmt.Errorf("refusing to execute hooks, directory is world-writable: %s", directory) } dirs2 = append(dirs2, directory) } else if !os.IsNotExist(err) { return false, err } } if len(dirs2) == 0 { // None of the directories exist; nothing to do. return false, nil } env := mergeEnv(os.Environ(), flattenEnvMap(ctx.Env), []string{"ACME_STATE_DIR=" + ctx.StateDir}) var ms []string for _, directory := range dirs2 { m, err := filepath.Glob(filepath.Join(directory, "*")) if err != nil { return false, err } ms = append(ms, m...) } for _, m := range ms { fi, err := os.Stat(m) if err != nil { log.Errore(err, "hook: ", m) continue } // Ignore 'hidden' files. if strings.HasPrefix(fi.Name(), ".") { continue } mode := fi.Mode() mType := mode & os.ModeType // Make sure it's not a directory, device, socket, pipe, etc. if mType != 0 && mType != os.ModeSymlink { log.Debugf("cannot execute hook, not a file: %s", m) continue } // Yes, this is vulnerable to race conditions; it's just to stop people // from shooting themselves in the foot. if (mode & 02) != 0 { log.Errorf("refusing to execute world-writable hook: %s", m) continue } // This doesn't check which mode bit (user,group,world) is applicable to // us but avoids cluttering the log for non-executable files. if (mode & 0111) == 0 { log.Debugf("cannot execute non-executable hook: %s", m) continue } var cmd *exec.Cmd if shouldSudoFile(m, fi) { log.Debugf("calling hook script (with sudo): %s", m) args2 := []string{"-n", "--", m} args2 = append(args2, args...) cmd = exec.Command("sudo", args2...) } else { log.Debugf("calling hook script: %s", m) cmd = exec.Command(m, args...) } cmd.Dir = "/" cmd.Env = env pipeR, pipeW, err := os.Pipe() if err != nil { return anySucceeded, err } defer pipeR.Close() go func() { defer pipeW.Close() pipeW.Write([]byte(stdinData)) }() cmd.Stdin = pipeR cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Run() logFailedExecution(m, err) if err == nil { anySucceeded = true } } return anySucceeded, nil } func logFailedExecution(hookPath string, err error) { if err == nil { return } exitCode, err2 := deos.GetExitCode(err) if err2 != nil { // Not an error code. ??? log.Errore(err2, "hook script: ", hookPath) return } switch exitCode { case 42: // Unsupported event type for this hook. Don't log anything; this is OK. default: log.Errore(err, "hook script: ", hookPath) } } acmetool-0.2.2/hooks/hooks_test.go000066400000000000000000000024741435652113300171640ustar00rootroot00000000000000package hooks import ( "fmt" "io/ioutil" "os" "path/filepath" "testing" ) const fileTpl = `#!/bin/sh %s [ -n "$ACME_STATE_DIR" ] || exit 1 echo NOTIFY-%d >> "$ACME_STATE_DIR/log" while read line; do echo L-$line >> "$ACME_STATE_DIR/log" done` var answer = []string{ `NOTIFY-0 L-a.b L-c.d L-e.f.g NOTIFY-1 L-a.b L-c.d L-e.f.g `, `NOTIFY-0 L-a.b L-c.d L-e.f.g NOTIFY-3 L-a.b L-c.d L-e.f.g `, } func TestNotify(t *testing.T) { dir, err := ioutil.TempDir("", "acme-notify-test") if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) notify1 := filepath.Join(dir, "notify1") notify2 := filepath.Join(dir, "notify2") notifyDirs := []string{notify1, notify2} for i := 0; i < 2; i++ { err = Replace(notifyDirs, "alpha", fmt.Sprintf(fileTpl, "", i*2+0)) if err != nil { t.Fatal(err) } err = Replace(notifyDirs, "beta", fmt.Sprintf(fileTpl, "#!acmetool-managed!#", i*2+1)) if err != nil { t.Fatal(err) } os.Remove(filepath.Join(dir, "log")) ctx := &Context{ HookDirs: notifyDirs, StateDir: dir, } err = NotifyLiveUpdated(ctx, []string{"a.b", "c.d", "e.f.g"}) if err != nil { t.Fatal(err) } b, err := ioutil.ReadFile(filepath.Join(dir, "log")) if err != nil { t.Fatal(err) } s := string(b) if s != answer[i] { t.Fatalf("mismatch: %v != %v", s, answer[i]) } } } acmetool-0.2.2/hooks/install.go000066400000000000000000000043461435652113300164500ustar00rootroot00000000000000package hooks import ( "bytes" "fmt" "os" "path/filepath" ) // Given a set of hook directories, returns whether a hook with the given name exists in any of them. func Exists(hookDirs []string, hookName string) bool { for _, hookDir := range hookDirs { _, err := os.Stat(filepath.Join(hookDir, hookName)) if err == nil { return true } } return false } // Installs a hook in the hooks directory. If the file already exists, it is // not overwritten unless it contains the string "#!acmetool-managed!#" in its // first 4096 bytes. func Replace(hookDirs []string, name, data string) error { if len(hookDirs) == 0 { hookDirs = DefaultPaths } if len(hookDirs) == 0 { return fmt.Errorf("no hooks directory configured") } // Find the directory in the filesystem which has the most parent components // of it already created. hookDirectory, err := preferredHookDir(hookDirs) if err != nil { return err } filename := filepath.Join(hookDirectory, name) isManaged, err := isManagedFile(filename) if os.IsNotExist(err) || (err == nil && isManaged) { return writeHook(filename, data) } return err } func preferredHookDir(hookDirs []string) (hookDirectory string, err error) { bestLA := 255 for _, dir := range hookDirs { var la int la, err = levelsAbsent(dir) if err != nil { return } if la < bestLA { hookDirectory = dir bestLA = la } } if hookDirectory == "" { hookDirectory = hookDirs[0] } return } func levelsAbsent(dir string) (int, error) { for i := 0; dir != "." && dir != "/"; i++ { _, err := os.Stat(dir) if err == nil { return i, nil } dir = filepath.Join(dir, "..") } return 255, fmt.Errorf("cannot find a level which exists") } func writeHook(filename, data string) error { err := os.MkdirAll(filepath.Dir(filename), 0755) if err != nil { return err } f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { return nil } defer f.Close() f.Write([]byte(data)) return nil } func isManagedFile(filename string) (bool, error) { f, err := os.Open(filename) if err != nil { return false, err } defer f.Close() b := make([]byte, 4096) n, _ := f.Read(b) b = b[0:n] return bytes.Index(b, []byte("#!acmetool-managed!#")) >= 0, nil } acmetool-0.2.2/hooks/os.go000066400000000000000000000020641435652113300154160ustar00rootroot00000000000000package hooks import ( deos "github.com/hlandau/goutils/os" "os" "os/exec" ) func runningAsRoot() bool { return os.Getuid() == 0 } func fileIsScript(fn string) bool { f, err := os.Open(fn) if err != nil { return false } defer f.Close() var b [2]byte n, _ := f.Read(b[:]) if n < 2 { return false } return string(b[:]) == "#!" } // Vulnerable to race conditions, but this is just a check. sudo enforces all // security properties. func shouldSudoFile(fn string, fi os.FileInfo) bool { if runningAsRoot() { return false } _, err := exec.LookPath("sudo") if err != nil { return false } // Only setuid files if the setuid bit is set. if (fi.Mode() & os.ModeSetuid) == 0 { return false } // Don't sudo anything which appears to be setuid'd for a non-root user. // This doesn't really buy us anything security-wise, but it's not what // we're expecting. uid, err := deos.GetFileUID(fi) if err != nil || uid != 0 { return false } // Make sure the file is a script, otherwise we can just execute it directly. return fileIsScript(fn) } acmetool-0.2.2/interaction/000077500000000000000000000000001435652113300156405ustar00rootroot00000000000000acmetool-0.2.2/interaction/auto.go000066400000000000000000000042411435652113300171400ustar00rootroot00000000000000package interaction import ( "fmt" "github.com/hlandau/xlog" ) var log, Log = xlog.New("acme.interactor") // Used by Auto. If this is set, only autoresponses can be used. Any challenge // without an autoresponse fails. --batch. var NonInteractive = false type autoInteractor struct{} // Interactor which automatically uses the most suitable challenge method. var Auto Interactor = autoInteractor{} // Used by Auto. If this is non-nil, all challenges are directed to it. There // is no fallback if the interceptor fails. Autoresponses and NonInteractive // take precedence over this. var Interceptor Interactor // Used by Auto. Do not use the Dialog mode. --stdio. var NoDialog = false var responsesReceived = map[string]*Response{} func (ai autoInteractor) Prompt(c *Challenge) (*Response, error) { res, err := ai.prompt(c) if err == nil && c.UniqueID != "" { responsesReceived[c.UniqueID] = res } return res, err } // Returns a map from challenge UniqueIDs to responses received for those // UniqueIDs. Do not mutate the returned map. func ResponsesReceived() map[string]*Response { return responsesReceived } func (autoInteractor) prompt(c *Challenge) (*Response, error) { r, err := Responder.Prompt(c) if err == nil || c.Implicit { return r, err } log.Infoe(err, "interaction auto-responder couldn't give a canned response") if NonInteractive { return nil, fmt.Errorf("cannot prompt the user: currently non-interactive; try running without --batch flag") } if Interceptor != nil { return Interceptor.Prompt(c) } if !NoDialog { r, err := Dialog.Prompt(c) if err == nil { return r, nil } } return Stdio.Prompt(c) } type dummySink struct{} func (dummySink) Close() error { return nil } func (dummySink) SetProgress(n, ofM int) { } func (dummySink) SetStatusLine(status string) { } func (autoInteractor) Status(info *StatusInfo) (StatusSink, error) { if NonInteractive { return dummySink{}, nil } if Interceptor != nil { s, err := Interceptor.Status(info) if err != nil { return dummySink{}, nil } return s, err } if !NoDialog { r, err := Dialog.Status(info) if err == nil { return r, nil } } return Stdio.Status(info) } acmetool-0.2.2/interaction/dialog.go000066400000000000000000000123571435652113300174360ustar00rootroot00000000000000package interaction import ( "fmt" "io/ioutil" "os" "os/exec" "strings" "sync" "syscall" ) type dialogInteractor struct{} // Invokes a dialog program to create terminal dialog boxes. Fails if no such // program is available. var Dialog Interactor = dialogInteractor{} type dialogStatusSink struct { closeChan chan struct{} closeOnce sync.Once closedChan chan struct{} updateChan chan struct{} pipeW *os.File infoMutex sync.Mutex statusLine string progress int cmd *exec.Cmd } func (ss *dialogStatusSink) Close() error { ss.closeOnce.Do(func() { close(ss.closeChan) }) <-ss.closedChan return nil } func (ss *dialogStatusSink) SetProgress(n, ofM int) { ss.infoMutex.Lock() defer ss.infoMutex.Unlock() ss.progress = int((float64(n) / float64(ofM)) * 100) ss.notify() } func (ss *dialogStatusSink) SetStatusLine(status string) { ss.infoMutex.Lock() defer ss.infoMutex.Unlock() ss.statusLine = status ss.notify() } func (ss *dialogStatusSink) notify() { select { case ss.updateChan <- struct{}{}: default: } } func (ss *dialogStatusSink) loop() { A: for { select { case <-ss.closeChan: break A case <-ss.updateChan: ss.infoMutex.Lock() statusLine := ss.statusLine progress := ss.progress ss.infoMutex.Unlock() fmt.Fprintf(ss.pipeW, "XXX\n%d\n%s\nXXX\n", progress, statusLine) } } ss.pipeW.Close() ss.cmd.Wait() close(ss.closedChan) } func (dialogInteractor) Status(c *StatusInfo) (StatusSink, error) { cmdName, _ := findDialogCommand() if cmdName == "" { return nil, fmt.Errorf("cannot find whiptail or dialog binary in path") } width := "78" height := fmt.Sprintf("%d", strings.Count(c.StatusLine, "\n")+5) var opts []string if c.Title != "" { opts = append(opts, "--backtitle", "ACME", "--title", c.Title) } opts = append(opts, "--gauge", c.StatusLine, height, width, "0") pipeR, pipeW, err := os.Pipe() if err != nil { return nil, err } defer pipeR.Close() cmd := exec.Command(cmdName, opts...) cmd.Stdin = pipeR cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Start() if err != nil { pipeW.Close() return nil, err } ss := &dialogStatusSink{ closeChan: make(chan struct{}), closedChan: make(chan struct{}), updateChan: make(chan struct{}, 10), pipeW: pipeW, cmd: cmd, } go ss.loop() return ss, nil } func (dialogInteractor) Prompt(c *Challenge) (*Response, error) { cmdName, cmdType := findDialogCommand() if cmdName == "" { return nil, fmt.Errorf("cannot find whiptail or dialog binary in path") } width := "78" height := "49" yesLabelArg := "--yes-label" noLabelArg := "--no-label" noTagsArg := "--no-tags" if cmdType == "whiptail" { yesLabelArg = "--yes-button" noLabelArg = "--no-button" noTagsArg = "--notags" } var opts []string if c.Title != "" { opts = append(opts, "--backtitle", "ACME", "--title", c.Title) } var err error var pipeR *os.File var pipeW *os.File switch c.ResponseType { case RTAcknowledge: opts = append(opts, "--msgbox", c.Body, height, width) case RTYesNo: yesLabel := c.YesLabel if yesLabel == "" { yesLabel = "Yes" } noLabel := c.NoLabel if noLabel == "" { noLabel = "No" } opts = append(opts, yesLabelArg, yesLabel, noLabelArg, noLabel, "--yesno", c.Body, height, width) case RTLineString: pipeR, pipeW, err = os.Pipe() if err != nil { return nil, err } defer pipeR.Close() defer pipeW.Close() opts = append(opts, "--output-fd", "3", "--inputbox", c.Body, height, width) case RTSelect: pipeR, pipeW, err = os.Pipe() if err != nil { return nil, err } defer pipeR.Close() defer pipeW.Close() opts = append(opts, "--output-fd", "3", noTagsArg, "--menu", c.Body, height, width, "5") for _, o := range c.Options { opts = append(opts, o.Value, o.Title) } } cmd := exec.Command(cmdName, opts...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if pipeW != nil { cmd.ExtraFiles = append(cmd.ExtraFiles, pipeW) } rc, xerr, err := runCommand(cmd) if err != nil { return nil, err } // If we get error code >1 (particularly 255) the dialog command probably // doesn't support some option we pass it. Return an error, which should make // us fall back to stdio. if rc > 1 { return nil, xerr } res := &Response{} if pipeW != nil { pipeW.Close() } switch c.ResponseType { case RTLineString, RTSelect: b, err := ioutil.ReadAll(pipeR) if err != nil { return nil, err } res.Value = string(b) fallthrough case RTYesNo, RTAcknowledge: if rc != 0 && rc != 1 { return nil, xerr } res.Cancelled = (rc == 1) } return res, nil } var dialogCommand = "" var dialogCommandType = "" func findDialogCommand() (string, string) { if dialogCommand != "" { return dialogCommand, dialogCommandType } // not using whiptail for now, see #18 for _, s := range []string{"dialog"} { p, err := exec.LookPath(s) if err == nil { dialogCommand = p dialogCommandType = s return dialogCommand, dialogCommandType } } return "", "" } func runCommand(cmd *exec.Cmd) (int, error, error) { err := cmd.Run() if err == nil { return 0, nil, nil } if e, ok := err.(*exec.ExitError); ok { if ws, ok := e.Sys().(syscall.WaitStatus); ok { return ws.ExitStatus(), err, nil } } return 255, err, err } acmetool-0.2.2/interaction/interaction.go000066400000000000000000000055121435652113300205110ustar00rootroot00000000000000// Package interaction provides facilities for asking the user questions, via // dialogs or stdio. package interaction // Interaction mode. Specifies the type of response requested from the user. type ResponseType int const ( // Acknowledgement only. Show notice and require user to acknowledge before // continuing. Response fields ignored. RTAcknowledge ResponseType = iota // Show notice and ask user to agree/disagree. Response has Cancelled set // if user disagreed. RTYesNo // Require user to enter a single-line string, returned as the Value of the // Response. RTLineString // Require user to select from a number of options. RTSelect ) // A challenge prompt to be shown to the user. type Challenge struct { ResponseType ResponseType // The response type. Title string // Title to be used for e.g. a dialog box if shown. Body string // The text to be shown to the user. May be multiple lines. YesLabel string // Label to use for RTYesNo 'Yes' label. NoLabel string // Label to use for RTYesNo 'No' label. // Prompt line used for stdio prompts. For RTAcknowledge, defaults to 'Press // Return to continue.' or similar. For RTYesNo, defaults to 'Agree? [Yn]' // or similar. Prompt string // Challenge type unique identifier. This identifies the meaning of the // dialog and can be used to respond automatically to known dialogs. // Optional. UniqueID string // Specifies the options for RTSelect. Options []Option // An implicit challenge will never be shown to the user but may be provided // by a response file. Implicit bool } // An option in an RTSelect challenge. type Option struct { Title string // Option title. Value string // Internal value that the option represents. } // A user's response to a prompt. type Response struct { Cancelled bool // Set this to true if the user cancelled the challenge. Value string // Value the user entered, if applicable. Noninteractive bool // Set to true if the response came from a noninteractive source. } // Specifies the initial parameters for a status dialog. type StatusInfo struct { Title string // Title to be used for the status dialog. StatusLine string // The status line. This may contain multiple lines if desired. } // Used to control a status dialog. type StatusSink interface { // Close the dialog and wait for it to terminate. Close() error // Set progress = (n/ofM)%. SetProgress(n, ofM int) // Set the status line(s). You cannot specify a number of lines that exceeds // the number of lines specified in the initial StatusLine. SetStatusLine(status string) } // An Interactor facilitates interaction with the user. type Interactor interface { // Synchronously present a prompt to the user. Prompt(*Challenge) (*Response, error) // Asynchronously present status information to the user. Status(*StatusInfo) (StatusSink, error) } acmetool-0.2.2/interaction/responder.go000066400000000000000000000014741435652113300201760ustar00rootroot00000000000000package interaction import "fmt" type responder struct{} var responses = map[string]*Response{} // Auto-responder. Provides canned responses if they have been set. Otherwise, // fails. var Responder Interactor = responder{} func (responder) Status(c *StatusInfo) (StatusSink, error) { return nil, fmt.Errorf("not supported") } func (responder) Prompt(c *Challenge) (*Response, error) { if c.UniqueID == "" { return nil, fmt.Errorf("cannot auto-respond to a challenge without a unique ID") } res := responses[c.UniqueID] if res == nil { return nil, fmt.Errorf("unknown unique ID, cannot respond: %#v", c.UniqueID) } return res, nil } // Configures a canned response for the given interaction UniqueID. func SetResponse(uniqueID string, res *Response) { res.Noninteractive = true responses[uniqueID] = res } acmetool-0.2.2/interaction/stdio.go000066400000000000000000000104161435652113300173130ustar00rootroot00000000000000package interaction import ( "bufio" "fmt" "github.com/hlandau/goutils/text" "github.com/mitchellh/go-wordwrap" "gopkg.in/cheggaaa/pb.v1" "os" "strconv" "strings" "sync" ) type stdioInteractor struct{} // Interactor which uses un-fancy stdio prompts. var Stdio Interactor = stdioInteractor{} type stdioStatusSink struct { closeChan chan struct{} closeOnce sync.Once closedChan chan struct{} updateChan chan struct{} infoMutex sync.Mutex statusLine string progress int } func (ss *stdioStatusSink) Close() error { ss.closeOnce.Do(func() { close(ss.closeChan) }) <-ss.closedChan return nil } func (ss *stdioStatusSink) SetProgress(n, ofM int) { ss.infoMutex.Lock() defer ss.infoMutex.Unlock() ss.progress = int((float64(n) / float64(ofM)) * 100) ss.notify() } func (ss *stdioStatusSink) SetStatusLine(status string) { ss.infoMutex.Lock() defer ss.infoMutex.Unlock() ss.statusLine = status ss.notify() } func (ss *stdioStatusSink) notify() { select { case ss.updateChan <- struct{}{}: default: } } func (ss *stdioStatusSink) loop() { bar := pb.StartNew(100) bar.ShowSpeed = false bar.ShowCounters = false bar.ShowTimeLeft = false bar.SetMaxWidth(lineLength) A: for { select { case <-ss.closeChan: break A case <-ss.updateChan: ss.infoMutex.Lock() statusLine := ss.statusLine idx := strings.IndexByte(statusLine, '\n') if idx >= 0 { statusLine = statusLine[0:idx] } progress := ss.progress ss.infoMutex.Unlock() bar.Set(progress) bar.Postfix(" " + statusLine) } } //bar.Update() bar.Finish() close(ss.closedChan) } func (stdioInteractor) Status(c *StatusInfo) (StatusSink, error) { ss := &stdioStatusSink{ closeChan: make(chan struct{}), closedChan: make(chan struct{}), updateChan: make(chan struct{}, 10), statusLine: c.StatusLine, } ss.updateChan <- struct{}{} go ss.loop() return ss, nil } func (stdioInteractor) Prompt(c *Challenge) (*Response, error) { switch c.ResponseType { case RTAcknowledge: return stdioAcknowledge(c) case RTYesNo: return stdioYesNo(c) case RTLineString: return stdioLineString(c) case RTSelect: return stdioSelect(c) default: return nil, fmt.Errorf("unsupported challenge type") } } func stdioAcknowledge(c *Challenge) (*Response, error) { p := c.Prompt if p == "" { p = "Press Return to continue." } PrintStderrMessage(c.Title, fmt.Sprintf("%s\n\n%s", c.Body, p)) waitReturn() return &Response{}, nil } func PrintStderrMessage(title, body string) { fmt.Fprintf(os.Stderr, "%s\n%s\n", titleLine(title), wordwrap.WrapString(body, lineLength)) } func stdioYesNo(c *Challenge) (*Response, error) { p := c.Prompt if p == "" { p = "Continue?" } fmt.Fprintf(os.Stderr, `%s %s `, titleLine(c.Title), c.Body) yes := waitYN(p) return &Response{Cancelled: !yes}, nil } func stdioLineString(c *Challenge) (*Response, error) { p := c.Prompt if p == "" { p = ">" } PrintStderrMessage(c.Title, fmt.Sprintf("%s\n\n%s", c.Body, p)) v := waitLine() return &Response{Value: v}, nil } func stdioSelect(c *Challenge) (*Response, error) { p := c.Prompt if p == "" { p = ">" } PrintStderrMessage(c.Title, fmt.Sprintf("%s\n\n", c.Body)) for i, o := range c.Options { t := o.Title if t == "" { t = o.Value } fmt.Fprintf(os.Stderr, " %v) %s\n", i+1, t) } fmt.Fprintf(os.Stderr, "%s ", p) v := strings.TrimSpace(waitLine()) n, err := strconv.ParseUint(v, 10, 31) if err != nil || n == 0 || int(n-1) >= len(c.Options) { return stdioSelect(c) } return &Response{Value: c.Options[int(n-1)].Value}, nil } func waitReturn() { waitLine() } func waitLine() string { s, _ := bufio.NewReader(os.Stdin).ReadString('\n') return strings.TrimRight(s, "\r\n") } func waitYN(prompt string) bool { r := bufio.NewReader(os.Stdin) for { fmt.Fprintf(os.Stderr, "%s [Yn] ", prompt) s, _ := r.ReadString('\n') if v, ok := text.ParseBoolUserDefaultYes(s); ok { return v } } } const lineLength = 70 func repeat(n int) string { return "--------------------------------------------------------------------------------"[80-n:] } func titleLine(title string) string { if title != "" { title = " " + title + " " } n := lineLength/2 - len(title)/2 s := "\n\n" + repeat(n) + title if len(s) < lineLength { s += repeat(lineLength - len(s)) } return s } acmetool-0.2.2/main.go000066400000000000000000000001241435652113300145710ustar00rootroot00000000000000package main import "github.com/hlandau/acmetool/cli" func main() { cli.Main() } acmetool-0.2.2/redirector/000077500000000000000000000000001435652113300154635ustar00rootroot00000000000000acmetool-0.2.2/redirector/redirector.go000066400000000000000000000160141435652113300201560ustar00rootroot00000000000000// Package redirector provides a basic HTTP server for redirecting HTTP // requests to HTTPS requests and serving ACME HTTP challenge values. package redirector import ( "errors" "fmt" deos "github.com/hlandau/goutils/os" "github.com/hlandau/xlog" "gopkg.in/hlandau/svcutils.v1/chroot" "gopkg.in/hlandau/svcutils.v1/passwd" "gopkg.in/tylerb/graceful.v1" "html" "net" "net/http" "os" "sync/atomic" "time" ) var log, Log = xlog.New("acme.redirector") // Configuration for redirector. type Config struct { Bind string `default:":80" usage:"Bind address"` ChallengePath string `default:"" usage:"Path containing HTTP challenge files"` ChallengeGID string `default:"" usage:"GID to chgrp the challenge path to (optional)"` ReadTimeout time.Duration `default:"" usage:"Maximum duration before timing out read of the request"` WriteTimeout time.Duration `default:"" usage:"Maximum duration before timing out write of the response"` StatusCode int `default:"308" usage:"HTTP redirect status code"` } // Simple HTTP to HTTPS redirector. type Redirector struct { cfg Config httpServer graceful.Server httpListener net.Listener stopping uint32 } // Instantiate an HTTP to HTTPS redirector. func New(cfg Config) (*Redirector, error) { r := &Redirector{ cfg: cfg, httpServer: graceful.Server{ Timeout: 100 * time.Millisecond, NoSignalHandling: true, Server: &http.Server{ Addr: cfg.Bind, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, }, }, } if r.cfg.StatusCode == 0 { r.cfg.StatusCode = 308 } // Try and make the challenge path if it doesn't exist. err := os.MkdirAll(r.cfg.ChallengePath, 0755) if err != nil { return nil, err } if r.cfg.ChallengeGID != "" { err := enforceGID(r.cfg.ChallengeGID, r.cfg.ChallengePath) if err != nil { return nil, err } } l, err := net.Listen("tcp", r.httpServer.Server.Addr) if err != nil { return nil, err } r.httpListener = l return r, nil } func enforceGID(gid, path string) error { newGID, err := passwd.ParseGID(gid) if err != nil { return err } // So this is a surprisingly complicated dance if we want to be free of // potentially hazardous race conditions. We have a path. We can't assume // anything about its ownership, or mode, whether it's a symlink, etc. // // The big risk is that someone is able to create a symlink pointing to // something they want to illicitly access. Note that since /var/run will // commonly be used and because this directory is world-writeable, ala /tmp, // this is a real risk. // // So we have to make sure we don't follow symlinks. Assume we are running // as root (necessary, since we're chowning), and that nothing running as // root is malicious. // // We open the directory as a file so we can modify it using that reference // without worrying about the resolution of the path changing under us. But // we need to make sure we don't follow symlinks. This requires special OS // support, alas. dir, err := deos.OpenNoSymlinks(path) if err != nil { return err } defer dir.Close() fi, err := dir.Stat() if err != nil { return err } // Attributes of the directory can still change, but its type certainly // can't. This guarantee is enough for our purposes. if (fi.Mode() & os.ModeType) != os.ModeDir { return fmt.Errorf("challenge path %#v is not a directory", path) } curUID, err := deos.GetFileUID(fi) if err != nil { return err } dir.Chmod((fi.Mode() | 0070) & ^os.ModeType) // Ignore errors. dir.Chown(curUID, newGID) // Ignore errors. return nil } func (r *Redirector) commonHandler(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { rw.Header().Set("Server", "acmetool-redirector") rw.Header().Set("Content-Security-Policy", "default-src 'none'") h.ServeHTTP(rw, req) }) } // Start the redirector. func (r *Redirector) Start() error { serveMux := http.NewServeMux() r.httpServer.Handler = r.commonHandler(serveMux) challengePath, ok := chroot.Rel(r.cfg.ChallengePath) if !ok { return fmt.Errorf("challenge path is not addressible inside chroot: %s", r.cfg.ChallengePath) } serveMux.HandleFunc("/", r.handleRedirect) serveMux.Handle("/.well-known/acme-challenge/", http.StripPrefix("/.well-known/acme-challenge/", http.FileServer(nolsDir(challengePath)))) go func() { err := r.httpServer.Serve(r.httpListener) if atomic.LoadUint32(&r.stopping) == 0 { log.Fatale(err, "serve") } }() log.Debugf("redirector running") return nil } // Stop the redirector. func (r *Redirector) Stop() error { atomic.StoreUint32(&r.stopping, 1) r.httpServer.Stop(r.httpServer.Timeout) <-r.httpServer.StopChan() return nil } // Respond to a request with a redirect. func (r *Redirector) handleRedirect(rw http.ResponseWriter, req *http.Request) { // Redirect. u := *req.URL u.Scheme = "https" if u.Host == "" { u.Host = req.Host } if u.Host == "" { rw.WriteHeader(400) return } us := u.String() rw.Header().Set("Location", us) // If we are receiving any cookies, these must be insecure cookies, ergo // cookies aren't being set securely properly. This is a security issue. // Deleting cookies after the fact doesn't change the fact that they were // sent in cleartext and are thus forever untrustworthy. But it increases // the probability of somebody noticing something is up. // // ... However, the HTTP specification makes it impossible to delete a cookie // unless we know its domain and path, which aren't transmitted in requests. if req.Method == "GET" { rw.Header().Set("Cache-Control", "public; max-age=31536000") rw.Header().Set("Content-Type", "text/html; charset=utf-8") } // This is a permanent redirect and the request method should be preserved. // It's unfortunate if the client has transmitted information in cleartext // via POST, etc., but there's nothing we can do about it at this stage. rw.WriteHeader(r.cfg.StatusCode) if req.Method == "GET" { // Redirects issued in response to GET SHOULD have a body pointing to the // new URL for clients which don't support redirects. (Whether the set of // clients supporting acceptably modern versions of TLS and not supporting // HTTP redirects is non-empty is another matter.) ue := html.EscapeString(us) rw.Write([]byte(fmt.Sprintf(redirBody, ue, ue))) } } const redirBody = ` Permanently Moved

Permanently Moved

This resource has moved permanently to %s.

` // Like http.Dir, but doesn't allow directory listings. type nolsDir string var errNoListing = errors.New("http: directory listing not allowed") func (d nolsDir) Open(name string) (http.File, error) { f, err := http.Dir(d).Open(name) if err != nil { return nil, err } fi, err := f.Stat() if err != nil { f.Close() return nil, err } if fi.IsDir() { f.Close() return nil, os.ErrNotExist } return f, nil } acmetool-0.2.2/redirector/redirector_test.go000066400000000000000000000025051435652113300212150ustar00rootroot00000000000000package redirector import ( denet "github.com/hlandau/goutils/net" "io/ioutil" "net/http" "os" "path/filepath" "testing" ) func TestRedirector(t *testing.T) { dir, err := ioutil.TempDir("", "acme-redirector-test") if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) r, err := New(Config{ Bind: ":9847", ChallengePath: dir, }) if err != nil { t.Fatal(err) } err = r.Start() if err != nil { t.Fatal(err) } defer r.Stop() req, err := http.NewRequest("FROBNICATE", "http://127.0.0.1:9847/foo/bar?alpha=beta", nil) if err != nil { t.Fatal(err) } res, err := http.DefaultTransport.RoundTrip(req) if err != nil { t.Fatal(err) } defer res.Body.Close() loc := res.Header.Get("Location") if loc != "https://127.0.0.1:9847/foo/bar?alpha=beta" { t.Fatalf("wrong Location: %v", loc) } err = ioutil.WriteFile(filepath.Join(dir, "foo"), []byte("bar"), 0644) if err != nil { t.Fatal(err) } req, err = http.NewRequest("GET", "http://127.0.0.1:9847/.well-known/acme-challenge/foo", nil) if err != nil { t.Fatal(err) } res, err = http.DefaultTransport.RoundTrip(req) if err != nil { t.Fatal(err) } defer res.Body.Close() b, err := ioutil.ReadAll(denet.LimitReader(res.Body, 1*1024*1024)) if err != nil { t.Fatal(err) } if string(b) != "bar" { t.Fatal("wrong response") } } acmetool-0.2.2/responder/000077500000000000000000000000001435652113300153225ustar00rootroot00000000000000acmetool-0.2.2/responder/dns.go000066400000000000000000000032201435652113300164320ustar00rootroot00000000000000package responder import ( "crypto" "encoding/json" "fmt" "gopkg.in/hlandau/acmeapi.v2/acmeutils" ) type DNSChallengeInfo struct { Hostname string Body string } type dnsResponder struct { rcfg Config validation []byte dnsString string } func newDNSResponder(rcfg Config) (Responder, error) { s := &dnsResponder{ rcfg: rcfg, validation: []byte("{}"), } if rcfg.Hostname == "" { return nil, fmt.Errorf("must provide a hostname") } var err error s.dnsString, err = acmeutils.DNSKeyAuthorization(rcfg.AccountKey, rcfg.Token) if err != nil { return nil, err } return s, nil } // Start is a no-op for the DNS method. func (s *dnsResponder) Start() error { // Try hooks. if startFunc := s.rcfg.ChallengeConfig.StartHookFunc; startFunc != nil { err := startFunc(&DNSChallengeInfo{ Hostname: s.rcfg.Hostname, Body: s.dnsString, }) return err } return fmt.Errorf("DNS challenge not supported") } // Stop is a no-op for the DNS method. func (s *dnsResponder) Stop() error { // Try hooks. if stopFunc := s.rcfg.ChallengeConfig.StopHookFunc; stopFunc != nil { err := stopFunc(&DNSChallengeInfo{ Hostname: s.rcfg.Hostname, Body: s.dnsString, }) log.Warne(err, "failed to uninstall DNS challenge via hook (ignoring)") return nil } return fmt.Errorf("DNS challenge not supported") } func (s *dnsResponder) RequestDetectedChan() <-chan struct{} { return nil } func (s *dnsResponder) Validation() json.RawMessage { return json.RawMessage(s.validation) } func (s *dnsResponder) ValidationSigningKey() crypto.PrivateKey { return nil } func init() { RegisterResponder("dns-01", newDNSResponder) } acmetool-0.2.2/responder/http.go000066400000000000000000000225411435652113300166340ustar00rootroot00000000000000package responder import ( "bytes" "crypto" "crypto/tls" "encoding/json" "fmt" "github.com/hlandau/acmetool/responder/reshttp" denet "github.com/hlandau/goutils/net" deos "github.com/hlandau/goutils/os" "gopkg.in/hlandau/acmeapi.v2/acmeutils" "io/ioutil" "net" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "time" ) // For testing use only. Determines the HTTP port which is listened on. This is // used because Pebble tries to talk to the client's HTTP responder on a // different HTTP port than the standard one. This use of non-privileged ports // eases testing. var InternalHTTPPort = 80 type HTTPChallengeInfo struct { Hostname string Filename string Body string } type httpResponder struct { rcfg Config response []byte requestDetectedChan chan struct{} portClaims []reshttp.PortClaim ka []byte validation []byte filePath string notifySupported bool // is notify supported? listening bool } func newHTTP(rcfg Config) (Responder, error) { s := &httpResponder{ rcfg: rcfg, requestDetectedChan: make(chan struct{}, 1), notifySupported: true, validation: []byte("{}"), } if rcfg.Hostname == "" { return nil, fmt.Errorf("must provide a hostname") } ka, err := acmeutils.KeyAuthorization(rcfg.AccountKey, rcfg.Token) if err != nil { return nil, err } s.ka = []byte(ka) return s, nil } func (s *httpResponder) notify() { // Notify callers that a request has been detected. select { case s.requestDetectedChan <- struct{}{}: default: } } // Start handling HTTP requests. func (s *httpResponder) Start() error { err := s.startActual() if err != nil { return err } if !s.rcfg.ChallengeConfig.HTTPNoSelfTest { log.Debugf("http-01 self test for %q", s.rcfg.Hostname) err = s.selfTest() if err != nil { log.Infoe(err, "http-01 self test failed: ", s.rcfg.Hostname) s.Stop() return err } } log.Debug("http-01 started") return nil } // This is currently the validation timeout used by Let's Encrypt, so let's // use the same value here. var selfTestTimeout = 5 * time.Second // Test that the challenge is reachable at the given hostname. If a hostname // was not provided, this test is skipped. func (s *httpResponder) selfTest() error { if s.rcfg.Hostname == "" { return nil } u := url.URL{ Scheme: "http", Host: s.rcfg.Hostname, Path: "/.well-known/acme-challenge/" + s.rcfg.Token, } if InternalHTTPPort != 80 { u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", InternalHTTPPort)) } trans := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, DisableKeepAlives: true, } client := &http.Client{ Transport: trans, Timeout: selfTestTimeout, } res, err := client.Get(u.String()) if err != nil { return err } defer res.Body.Close() if res.StatusCode != 200 { return fmt.Errorf("hostname %q: non-200 status code when doing self-test", s.rcfg.Hostname) } b, err := ioutil.ReadAll(denet.LimitReader(res.Body, 1*1024*1024)) if err != nil { return err } b = bytes.TrimSpace(b) if !bytes.Equal(b, s.ka) { return fmt.Errorf("hostname %q: got 200 response when doing self-test, but with the wrong data", s.rcfg.Hostname) } // If we detected a request, we support notifications, otherwise we don't. select { case <-s.requestDetectedChan: default: s.notifySupported = false } // Drain the notification channel in case we somehow made several requests. L: for { select { case <-s.requestDetectedChan: default: break L } } return nil } // Tries to write a challenge file to each of the directories. func webrootWriteChallenge(webroots map[string]struct{}, token string, ka []byte) { log.Debugf("writing %d webroot challenge files", len(webroots)) for wr := range webroots { os.MkdirAll(wr, 0755) // ignore errors fn := filepath.Join(wr, token) log.Debugf("writing webroot file %s", fn) // Because /var/run/acme/acme-challenge may not exist due to /var/run // possibly being a tmpfs, and because that tmpfs is likely to be world // writable, there is a risk of following a maliciously crafted symlink to // cause a file to be overwritten as root. Open the file using a // no-symlinks flag if the OS supports it, but only for /var/run paths; we // want to support symlinks for other paths, which are presumably properly // controlled. // // Unfortunately earlier components in the pathname will still be followed // if they are symlinks, but it looks like this is the best we can do. var f *os.File var err error if strings.HasPrefix(wr, "/var/run/") { f, err = deos.OpenFileNoSymlinks(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) } else { f, err = os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) } if err != nil { log.Infoe(err, "failed to open webroot file ", fn) continue } f.Write(ka) f.Close() } } // Tries to remove a challenge file from each of the directories. func webrootRemoveChallenge(webroots map[string]struct{}, token string) { for wr := range webroots { fn := filepath.Join(wr, token) log.Debugf("removing webroot file %s", fn) os.Remove(fn) // ignore errors } } // The standard webroot path, into which the responder always tries to install // challenges, not necessarily successfully. This is intended to be a standard, // system-wide path to look for challenges at. On POSIX-like systems, it is // usually "/var/run/acme/acme-challenge". var StandardWebrootPath string func init() { if StandardWebrootPath == "" { StandardWebrootPath = "/var/run/acme/acme-challenge" } } func (s *httpResponder) getWebroots() map[string]struct{} { webroots := map[string]struct{}{} for _, p := range s.rcfg.ChallengeConfig.WebPaths { if p != "" { webroots[strings.TrimRight(p, "/")] = struct{}{} } } // The webroot and redirector models both require us to drop the challenge at // a given path. If a webroot is not specified in the configuration, use an // ephemeral default that the redirector might be using anyway. webroots[StandardWebrootPath] = struct{}{} return webroots } func parseListenAddrs(addrs []string) map[string]struct{} { m := map[string]struct{}{} for _, s := range addrs { n, err := strconv.ParseUint(s, 10, 16) if err == nil { m[fmt.Sprintf("[::1]:%d", n)] = struct{}{} m[fmt.Sprintf("127.0.0.1:%d", n)] = struct{}{} continue } ta, err := net.ResolveTCPAddr("tcp", s) if err != nil { log.Warnf("invalid listen addr: %q: %v", s, err) continue } m[ta.String()] = struct{}{} } return m } func addrWeight(x string) int { host, _, err := net.SplitHostPort(x) if err != nil { return 0 } if host == "" { return -1 } ip := net.ParseIP(host) if ip != nil && ip.IsUnspecified() { if ip.To4() != nil { return -1 } return -2 } return 0 } type addrSorter []string func (a addrSorter) Len() int { return len(a) } func (a addrSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a addrSorter) Less(i, j int) bool { return addrWeight(a[i]) < addrWeight(a[j]) } func determineListenAddrs(userAddrs []string) []string { // Here's our brute force method: listen on everything that might work. addrs := parseListenAddrs(userAddrs) addrs[fmt.Sprintf("[::]:%d", InternalHTTPPort)] = struct{}{} // OpenBSD addrs[fmt.Sprintf(":%d", InternalHTTPPort)] = struct{}{} addrs["[::1]:402"] = struct{}{} addrs["127.0.0.1:402"] = struct{}{} addrs["[::1]:4402"] = struct{}{} addrs["127.0.0.1:4402"] = struct{}{} // Sort the strings so that 'all interfaces' addresses appear first, so that // they are not blocked by more specific entries such as the ones above, // which are always attempted. var addrsl []string for k := range addrs { addrsl = append(addrsl, k) } sort.Stable(addrSorter(addrsl)) return addrsl } func (s *httpResponder) startActual() error { // Determine and listen on sorted list of addresses. addrs := determineListenAddrs(s.rcfg.ChallengeConfig.HTTPPorts) for _, a := range addrs { pc, err := reshttp.AcquirePort(a, s.rcfg.Token, s.ka, s.notify) if err == nil { s.portClaims = append(s.portClaims, pc) } } // Even if none of the listeners managed to start, the webroot or redirector // methods might work. webrootWriteChallenge(s.getWebroots(), s.rcfg.Token, s.ka) // Try hooks. if startFunc := s.rcfg.ChallengeConfig.StartHookFunc; startFunc != nil { err := startFunc(&HTTPChallengeInfo{ Hostname: s.rcfg.Hostname, Filename: s.rcfg.Token, Body: string(s.ka), }) log.Errore(err, "start challenge hook") } return nil } // Stop handling HTTP requests. func (s *httpResponder) Stop() error { for _, pc := range s.portClaims { pc.Close() } s.portClaims = nil // Try and remove challenges. webrootRemoveChallenge(s.getWebroots(), s.rcfg.Token) // Try and stop hooks. if stopFunc := s.rcfg.ChallengeConfig.StopHookFunc; stopFunc != nil { err := stopFunc(&HTTPChallengeInfo{ Hostname: s.rcfg.Hostname, Filename: s.rcfg.Token, Body: string(s.ka), }) log.Errore(err, "stop challenge hook") } return nil } func (s *httpResponder) RequestDetectedChan() <-chan struct{} { if !s.notifySupported { return nil } return s.requestDetectedChan } func (s *httpResponder) Validation() json.RawMessage { return json.RawMessage(s.validation) } func (s *httpResponder) ValidationSigningKey() crypto.PrivateKey { return nil } func init() { RegisterResponder("http-01", newHTTP) } acmetool-0.2.2/responder/reshttp/000077500000000000000000000000001435652113300170135ustar00rootroot00000000000000acmetool-0.2.2/responder/reshttp/reshttp.go000066400000000000000000000047751435652113300210500ustar00rootroot00000000000000// Package reshttp allows multiple goroutines to register challenge responses // on an HTTP server concurrently. package reshttp import ( "github.com/hlandau/xlog" "gopkg.in/tylerb/graceful.v1" "net" "net/http" "strings" "sync" "time" ) var log, Log = xlog.New("acmetool.reshttp") type PortClaim interface { Close() error } type portClaim struct { port *port released bool filename string body []byte notifyFunc func() } func (pc *portClaim) Close() error { mutex.Lock() defer mutex.Unlock() if pc.released { return nil } delete(pc.port.claims, pc.filename) pc.port.refcount-- if pc.port.refcount == 0 { pc.port.Destroy() } pc.released = true return nil } type port struct { addr string refcount int server *graceful.Server claims map[string]*portClaim } func (p *port) Init() error { p.claims = map[string]*portClaim{} p.server = &graceful.Server{ NoSignalHandling: true, Server: &http.Server{ Addr: p.addr, Handler: p, }, } l, err := net.Listen("tcp", p.addr) if err != nil { log.Debuge(err, "failed to listen on ", p.addr) return err } log.Debugf("listening on %v", p.addr) go func() { defer l.Close() p.server.Serve(l) }() return nil } func (p *port) Destroy() { delete(ports, p.addr) p.server.Stop(10 * time.Millisecond) <-p.server.StopChan() } func (p *port) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if !strings.HasPrefix(req.URL.Path, "/.well-known/acme-challenge/") { http.NotFound(rw, req) return } fn := req.URL.Path[28:] body, notifyFunc := p.getClaim(fn) if body == nil { http.NotFound(rw, req) return } rw.Header().Set("Content-Type", "text/plain") rw.Write(body) if notifyFunc != nil { notifyFunc() } } func (p *port) getClaim(filename string) (body []byte, notifyFunc func()) { mutex.Lock() defer mutex.Unlock() pc, ok := p.claims[filename] if !ok { return nil, nil } return pc.body, pc.notifyFunc } var mutex sync.Mutex var ports = map[string]*port{} func AcquirePort(bindAddr, filename string, body []byte, notifyFunc func()) (PortClaim, error) { log.Debugf("acquire port %q %q", bindAddr, filename) mutex.Lock() defer mutex.Unlock() p, ok := ports[bindAddr] if !ok { p = &port{ addr: bindAddr, refcount: 0, } err := p.Init() if err != nil { return nil, err } ports[bindAddr] = p } p.refcount++ pc := &portClaim{ port: p, filename: filename, body: body, notifyFunc: notifyFunc, } p.claims[filename] = pc return pc, nil } acmetool-0.2.2/responder/responder.go000066400000000000000000000062741435652113300176630ustar00rootroot00000000000000// Package responder implements the various ACME challenge types. package responder import ( "crypto" "encoding/json" "fmt" "github.com/hlandau/xlog" ) // Log site. var log, Log = xlog.New("acme.responder") // A Responder implements a challenge type. // // After successfully instantiating a responder, you should call Start. // // You should then use the return values of Validation() and // ValidationSigningKey() to submit the challenge response. // // Once the challenge has been completed, as determined by polling, you must // call Stop. If RequestDetectedChan() is non-nil, it provides a hint as to // when polling may be fruitful. type Responder interface { // Become ready to be interrogated by the ACME server. Start() error // Stop responding to any queries by the ACME server. Stop() error // This channel is sent to when a request to the responder is detected, // which may indicates completion of the challenge is imminent. // // Returning nil indicates that request detection is not supported. RequestDetectedChan() <-chan struct{} // Return the validation object the signature for which was delivered. If // nil is returned, no validation object is submitted. Validation() json.RawMessage // Key which must sign validation object. If nil, account key is used. ValidationSigningKey() crypto.PrivateKey } // Used to instantiate a responder. type Config struct { // Information about the challenge to be completed. Type string // The responder type to be used. e.g. "http-01". AccountKey crypto.PrivateKey // The account private key. Token string // The challenge token. // "http-01", "dns-01": The hostname being verified. May be used for // pre-initiation self-testing. Required. Hostname string ChallengeConfig ChallengeConfig } // Information used to complete challenges, other than information provided by // the ACME server. type ChallengeConfig struct { // "http-01": The http responder may attempt to place challenges in these // locations. Optional. WebPaths []string // "http-01": The http responder may attempt to listen on these addresses. // Optional. HTTPPorts []string // Do not perform self test, but assume challenge is completable. HTTPNoSelfTest bool StartHookFunc HookFunc StopHookFunc HookFunc } // Returns the private key corresponding to the given public key, if it can be // found. If a corresponding private key cannot be found, return nil; do not // return an error. Returning an error short circuits. type PriorKeyFunc func(crypto.PublicKey) (crypto.PrivateKey, error) type HookFunc func(challengeInfo interface{}) error var responderTypes = map[string]func(Config) (Responder, error){} // Try and instantiate a responder using the given configuration. func New(rcfg Config) (Responder, error) { f, ok := responderTypes[rcfg.Type] if !ok { return nil, fmt.Errorf("challenge type not supported") } return f(rcfg) } // Register a responder type. Allows types other than those innately supported // by this package to be supported. Overrides any previously registered // responder of the same type. func RegisterResponder(typeName string, createFunc func(Config) (Responder, error)) { responderTypes[typeName] = createFunc } acmetool-0.2.2/solver/000077500000000000000000000000001435652113300146335ustar00rootroot00000000000000acmetool-0.2.2/solver/order.go000066400000000000000000000414111435652113300162760ustar00rootroot00000000000000package solver import ( "context" "fmt" "github.com/hlandau/acmetool/responder" "github.com/hlandau/acmetool/util" denet "github.com/hlandau/goutils/net" "github.com/hlandau/xlog" "gopkg.in/hlandau/acmeapi.v2" "sync" "time" ) var log, Log = xlog.New("acmetool.solver") type blacklist struct { mutex sync.Mutex m map[string]struct{} } func blacklistKey(hostname, challengeType string) string { return challengeType + "\n" + hostname } func (b *blacklist) Check(hostname, challengeType string) bool { b.mutex.Lock() defer b.mutex.Unlock() _, ok := b.m[blacklistKey(hostname, challengeType)] return ok } func (b *blacklist) Add(hostname, challengeType string) { b.mutex.Lock() defer b.mutex.Unlock() b.m[blacklistKey(hostname, challengeType)] = struct{}{} } // Creates, fulfils and finalises an order. Automatically tries different // challenges to the extent possible, and creates orders again if necessary // after challenge failure, until success or unrecoverable failure. func Order(ctx context.Context, rc *acmeapi.RealmClient, acct *acmeapi.Account, orderTemplate *acmeapi.Order, csr []byte, ccfg *responder.ChallengeConfig) (*acmeapi.Order, error) { // Make order. // Progress the order. => result: Success | Retry | Fail // Fulfil authorizations. // Fulfil challenges by preference/previously failed info; keep prev. failed for (hostname, challenge type) // Retry stuff // Start again if authorization becomes permanently failed // Have faith // Finalise bl := blacklist{m: map[string]struct{}{}} for { order := *orderTemplate err := rc.NewOrder(ctx, acct, &order) if err != nil { return nil, err } shouldRetry, err := orderProcess(ctx, rc, acct, &order, csr, ccfg, &bl) if err == nil { return &order, nil } if !shouldRetry { return nil, err } } } // Take a newly created order object as far as possible. // // Returns in one of three states: // - Success: err == nil -- OK // We're done. // - Fail: shouldRetry == true, err != nil -- Order failed but keep making new orders // Causes a new order to be made to start the process again. // - Fatal: shouldRetry == false, err != nil -- Order failed and we will never succeed, so stop // Stops the order process. func orderProcess(ctx context.Context, rc *acmeapi.RealmClient, acct *acmeapi.Account, order *acmeapi.Order, csr []byte, ccfg *responder.ChallengeConfig, bl *blacklist) (shouldRetry bool, err error) { // We just created the order, so it shouldn't be invalid. If it is, there's // no way we can get anywhere no matter how many times we try.. switch order.Status { case acmeapi.OrderPending: case acmeapi.OrderReady: break default: return false, fmt.Errorf("order (%q) was in state %q as soon as it was created, cannot continue", order.URL, order.Status) } if order.Status == acmeapi.OrderPending { shouldRetry, err := orderAuthorizeAll(ctx, rc, acct, order, ccfg, bl) if err != nil { return shouldRetry, err } // Get a fresh picture of the order status. orderAuthorizeAll doesn't refresh it. err = rc.LoadOrder(ctx, acct, order) if err != nil { return true, err } } // TODO: REMOVE LET'S ENCRYPT WORKAROUND once they fix this allowBoulderBugfix := true if order.Status != acmeapi.OrderReady && (!allowBoulderBugfix || order.Status != acmeapi.OrderPending) { return false, fmt.Errorf("finished authorizing order (%q) but status is not ready, got %q", order.URL, order.Status) } // Request issuance. err = rc.Finalize(ctx, acct, order, csr) if err != nil { // If finalization failed, this suggests something wrong with the CSR and retrying will be // pointless, so stop here. return false, err } return false, nil } // Tries to complete all the authorizations on an order. // // Returns in one of three states: // - Success: err == nil -- OK // We're done // - Fail: shouldRetry == true, err != nil -- One or more authorizations are dead, but subsequent orders might succeed // Cause a new order to be made to start the process again. // - Fatal: shouldRetry == false, err != nil -- One or more authorizations are unfulfillable and subsequent orders will never succeed // Stops the order process. func orderAuthorizeAll(ctx context.Context, rc *acmeapi.RealmClient, acct *acmeapi.Account, order *acmeapi.Order, ccfg *responder.ChallengeConfig, bl *blacklist) (shouldRetry bool, err error) { type result struct { isFatal bool err error } ch := make(chan result, len(order.AuthorizationURLs)) for i := range order.AuthorizationURLs { authURL := order.AuthorizationURLs[i] go func() { ctxAuth := ctx // TODO isFatal, err := orderAuthorizeOne(ctxAuth, rc, acct, authURL, ccfg, bl) ch <- result{isFatal, err} }() } var errors util.MultiError isFatal := false for i := 0; i < len(order.AuthorizationURLs); i++ { r := <-ch if r.isFatal { // CANCEL ALL isFatal = true } if r.err != nil { errors = append(errors, r.err) } } if len(errors) > 0 { return !isFatal, errors } return true, nil } // Tries to complete one authorization given the URL to it. Tries challenges in // sequence until the authorization becomes invalid or it is determined that // none of the challenges will work. Avoids challenges which are already // blacklisted and blacklists challenges which fail for the given (hostname, // challengeType). // // Returns in one of three states: // - Success: err == nil -- OK // We're done, authorization is now good // - Fail: isFatal == false, err != nil -- Authorization is unfulfillable but subsequent orders might succeed // Cause a new order to be made to start the process again. Challenge // blacklisting means a different strategy to complete the authorization // will be attempted next time. // - Fatal: isFatal == true, err != nil -- Authorization is unfulfillable and subsequent orders will never succeed // Authorization process failed and it has been determined that no // corresponding successor authorization in a subsequent order could ever // succeed either. Give up. func orderAuthorizeOne(ctx context.Context, rc *acmeapi.RealmClient, acct *acmeapi.Account, authURL string, ccfg *responder.ChallengeConfig, bl *blacklist) (isFatal bool, err error) { authz := &acmeapi.Authorization{ URL: authURL, } // Load authorization. err = rc.LoadAuthorization(ctx, acct, authz) if err != nil { // Assume a transient problem, return FAIL. If there is e.g. a network // issue, creation of a new order will fail and that will be fatal, so not // checking for fatal errors here is of little consequence. return } // If an authorization was invalid at the outset, consider this a fatal // error, otherwise we will just retry with new orders forever but never be // able to make any progress. We can only get here if the order is not // invalid, so this should only happen if the server creates new orders with // a non-final order status but an invalid authorization, which shouldn't // happen. Guard against it just in case. if authz.Status == acmeapi.AuthorizationInvalid { // Return FATAL. isFatal = true err = fmt.Errorf("authorization %q is invalid from the outset, even though order isn't", authz.URL) return } var challengeErrors util.MultiError outOfChallenges := false for { // If authorization has come to have a final state, return. // // This will occur either because // - this function has now successfully completed the authorization, or // - because the authorization was created in a final state (e.g. valid) // as soon as the order was created; this can happen if the server // carries over previous successful authorizations, etc. // This also handles cases where an authorization randomly transitions to // valid, though these aren't expected. if authz.Status.IsFinal() { if authz.Status == acmeapi.AuthorizationValid { // Return SUCCESS. return } // Authorization is dead and cannot be recovered. Return FAIL, // creating a new order and starting the process again. isFatal = outOfChallenges err = util.NewWrapError(challengeErrors, "authorization %q has non-valid final status %q", authz.URL, authz.Status) return } // If any challenge is valid, WTF? Return FATAL. for i := range authz.Challenges { if authz.Challenges[i].Status == acmeapi.ChallengeValid { err = fmt.Errorf("authorization %q has non-final status but contains a valid challenge: %q", authz.URL, authz.Status) isFatal = true return } } // If the authorization is not for a DNS identifier, return FATAL. if authz.Identifier.Type != acmeapi.IdentifierTypeDNS { err = fmt.Errorf("unsupported authorization identifier type %q, value %q", authz.Identifier.Type, authz.Identifier.Value) isFatal = true return } // Sort challenges by preference. preferenceOrder := SortChallenges(authz, PreferFast) // Initiate most preferred non-invalid challenge. preferred := "" secondBestPreferred := "" for _, i := range preferenceOrder { ch := &authz.Challenges[i] if !bl.Check(authz.Identifier.Value, ch.Type) && !ch.Status.IsFinal() { if preferred == "" { preferred = ch.URL } else if secondBestPreferred == "" { secondBestPreferred = ch.URL } else { break } } } // If we've blacklisted all challenges, return FATAL. if preferred == "" { err = util.NewWrapError(challengeErrors, "exhausted all possible challenges in authorization %q", authz.URL) isFatal = true return } // Try and complete our preferred challenge. If it fails, blacklist it. // orderCompleteChallenge returns once the challenge has succeeded, or once // it has been determined that it definitely cannot be completed, or once a // reasonable effort has been made (e.g. retry limit reached) without // success. In failure cases (err != nil), the authorization may or may not // have entered a final-invalid state as a result of this, so don't assume // the authorization has become final-invalid. ch, ok := findChallengeByURL(authz, preferred) if !ok { panic("challenge disappeared") } var authWasLoaded bool authWasLoaded, err = orderCompleteChallenge(ctx, rc, acct, authz, ch.URL, ccfg) if err != nil { // This (hostname, challengeType) failed, so blacklist it so we don't try // it again for the duration of this ordering process. bl.Add(authz.Identifier.Value, ch.Type) // As an optimisation, return FATAL instead of FAIL if the challenge we // just blacklisted was the final non-blacklisted challenge. This is an // optimization; if we don't do this, we'll create another order and call // this function, orderAuthorizeOne, again before bailing at "exhausted // all possible challenges" above. We can avoid this unnecessary creation // of an unused order by checking if this is the last non-blacklisted // challenge we're blacklisting. outOfChallenges = (secondBestPreferred == "") // Record the error. challengeErrors = append(challengeErrors, err) } // Whether or not orderCompleteChallenge thinks the challenge apparently // failed or not, just reload the authorization to check its current state // and take that as the actual source of truth (unless // orderCompleteChallenge just loaded it). This should be the most reliable // strategy. We check whether the authorization has gone final when we // continue the loop. if !authWasLoaded { err = rc.LoadAuthorization(ctx, acct, authz) if err != nil { return } } } } // Tries to complete a single challenge. Returns after it has been completed, // after it has been determined that it can no longer be completed, or after a // reasonable effort has been made to complete it. // // (If the server implements some manner of evergreen challenge which never // goes invalid, we don't want to retry forever as the means of completing the // challenge may not be setup, so we only try once. Retries after spurious // errors can be handled by the higher levels which invoke this, e.g. at the // next invocation of acmetool — we probably can't reliably ascertain whether // an error is spurious ourselves, so we just try once and assume that retries // will be handled by our invoker.) // // Returns in one of two states: // - Success: err == nil -- OK // Challenge was successfully completed; authorization should now be // final-valid. // - Fail: err != nil -- Challenge was attempted one time and failed, authorization MAY OR MAY NOT be final-invalid // Challenge was not successfully completed. This may or may not have // caused the authorization to transition to final-invalid; for example, // some challenges may fail before making any request to the ACME server // at all, for example if they detect that they have not been configured // (e.g. DNS challenges without any DNS hooks installed). By not assuming // the authorization has become invalid we can avoid creating unnecessary // orders. // // As an optimization, we return whether we reloaded the authorization after // any possible status changes, which means the caller doesn't need to reload // it again. func orderCompleteChallenge(ctx context.Context, rc *acmeapi.RealmClient, acct *acmeapi.Account, authz *acmeapi.Authorization, challengeURL string, ccfg *responder.ChallengeConfig) (authWasLoaded bool, err error) { oldCh, ok := findChallengeByURL(authz, challengeURL) if !ok { err = fmt.Errorf("challenge %q does not appear in authorization %q", challengeURL, authz.URL) return } // A challenge might remain pending after we fail to complete it if the // server is still willing to retry it. Since we want to limit how long we // wait for a challenge to complete, we count the number of errors listed for // the challenge by the server. When the number of errors increase (or the // challenge goes valid), we consider that to be one attempt and stop. oldCount := countErrors(&oldCh) // Get responder ready. r, err := responder.New(responder.Config{ Type: oldCh.Type, Token: oldCh.Token, AccountKey: acct.PrivateKey, Hostname: authz.Identifier.Value, ChallengeConfig: *ccfg, }) if err != nil { log.Debuge(err, "challenge instantiation failed") return } err = r.Start() if err != nil { log.Debuge(err, "challenge start failed") return } defer r.Stop() // RESPOND err = rc.RespondToChallenge(ctx, acct, &oldCh, r.Validation()) //r.ValidationSigningKey() if err != nil { return } b := denet.Backoff{ InitialDelay: 5 * time.Second, MaxDelay: 30 * time.Second, } for { // Wait until we have some suspicion that the challenge may have been // completed. log.Debugf("challenge %q (%q): waiting to poll", oldCh.URL, oldCh.Type) select { case <-ctx.Done(): err = ctx.Err() return case <-r.RequestDetectedChan(): log.Debugf("challenge %q (%q): request detected", oldCh.URL, oldCh.Type) case <-time.After(b.NextDelay()): log.Debugf("challenge %q (%q): periodically checking", oldCh.URL, oldCh.Type) } // We could reload just the challenge, but there's not much point, since // the challenges are embedded inline in the authorization, and this keeps // the authorization object up-to-date too. log.Debugf("challenge %q (%q): querying status", oldCh.URL, oldCh.Type) err = rc.WaitLoadAuthorization(ctx, acct, authz) if err != nil { return } authWasLoaded = true updatedCh, ok := findChallengeByURL(authz, challengeURL) if !ok { err = fmt.Errorf("challenge %q has disappeared from authorization %q", challengeURL, authz.URL) return } if updatedCh.Status == acmeapi.ChallengeValid { // Challenge is valid, we're done here. err = nil return } if updatedCh.Status.IsFinal() { // The challenge is final but not valid; there is no further prospect of // completing this challenge. err = util.NewWrapError(updatedCh.Error, "authorization %q challenge %q failed into final non-valid status %v", authz.URL, challengeURL, updatedCh.Status) log.Infoe(err, "unsuccessful challenge") return } // TODO: allow number of error-tries to be tolerated before bailing to be // configured; currently fix it at 1. if countErrors(&updatedCh) != oldCount { err = util.NewWrapError(updatedCh.Error, "authorization %q challenge %q failed", authz.URL, challengeURL) log.Infoe(err, "unsuccessful challenge") return } } } func findChallengeByURL(authz *acmeapi.Authorization, challengeURL string) (acmeapi.Challenge, bool) { for i := range authz.Challenges { if authz.Challenges[i].URL == challengeURL { return authz.Challenges[i], true } } return acmeapi.Challenge{}, false } func countErrors(ch *acmeapi.Challenge) int { if ch == nil || ch.Error == nil { return 0 } n := len(ch.Error.Subproblem) if n == 0 { return 1 } return n } acmetool-0.2.2/solver/preference.go000066400000000000000000000044321435652113300173030ustar00rootroot00000000000000package solver import ( "gopkg.in/hlandau/acmeapi.v2" "sort" ) // Any challenge having a preference at or below this value will never be used. const NonviableThreshold int32 = -1000000 type sorter struct { authz *acmeapi.Authorization order []int preferencer Preferencer } func (s *sorter) Len() int { return len(s.order) } func (s *sorter) Swap(i, j int) { s.order[i], s.order[j] = s.order[j], s.order[i] } func (s *sorter) Less(i, j int) bool { pi := s.preference(&s.authz.Challenges[i]) pj := s.preference(&s.authz.Challenges[j]) return pi < pj } func (s *sorter) preference(ch *acmeapi.Challenge) int32 { v := s.preferencer.Preference(ch) if v <= NonviableThreshold { return NonviableThreshold } return v } // Returns a list of indices to authz.Challenges, sorted by preference, most // preferred first. func SortChallenges(authz *acmeapi.Authorization, preferencer Preferencer) (preferenceOrder []int) { preferenceOrder = make([]int, len(authz.Challenges)) for i := 0; i < len(authz.Challenges); i++ { preferenceOrder[i] = i } s := sorter{ authz: authz, order: preferenceOrder, preferencer: preferencer, } sort.Stable(sort.Reverse(&s)) return } // TypePreferencer returns a preference according to the type of the challenge. // // Unknown challenge types are nonviable. type TypePreferencer map[string]int32 // Implements Preferencer. func (p TypePreferencer) Preference(ch *acmeapi.Challenge) int32 { v, ok := p[ch.Type] if !ok { return NonviableThreshold } return v } // Returns a copy of TypePreferencer, so that it can be mutated without // changing the original. func (p TypePreferencer) Copy() TypePreferencer { tp := TypePreferencer{} for k, v := range p { tp[k] = v } return tp } // PreferFast prefers fast types. var PreferFast = TypePreferencer{ "tls-sni-02": 2, "tls-sni-01": 1, "http-01": 0, // Disable DNS challenges for now. They're practically unusable and the Let's // Encrypt live server doesn't support them at this time anyway. "dns-01": -10, } // Determines the degree to which a challenge is preferred. Higher values are // more preferred. Any value <= NonviableThreshold will never be used. type Preferencer interface { // Get the preference for the given challenge. Preference(ch *acmeapi.Challenge) int32 } acmetool-0.2.2/solver/register.go000066400000000000000000000071461435652113300170160ustar00rootroot00000000000000package solver import ( "fmt" "github.com/hlandau/acmetool/interaction" "golang.org/x/net/context" "gopkg.in/hlandau/acmeapi.v2" "net/mail" ) // Using the given client, account and interactor (or interaction.Auto if nil), // register the client account if it does not already exist. Does not do anything // and does NOT update the registration if the account is already registered. // // The interactor is used to prompt for terms of service agreement, if // agreement has not already been obtained. An e. mail address is prompted for. func AssistedRegistration(ctx context.Context, cl *acmeapi.RealmClient, acct *acmeapi.Account, interactor interaction.Interactor) error { interactor = defaultInteraction(interactor) // We know for a fact the account has already been registered because we know // its URL. Don't do anything. if acct.URL != "" { return nil } // See if the account has already been registered. If so, the URL gets stored // in acct.URL and we're done. err := cl.LocateAccount(ctx, acct) if err == nil { return nil } // Check that the error that occured was a not found error. he, ok := err.(*acmeapi.HTTPError) if !ok { return err } if he.Problem == nil || he.Problem.Type != "urn:ietf:params:acme:error:accountDoesNotExist" { return err } // Get the directory metadata so we can get the terms of service URL. meta, err := cl.GetMeta(ctx) if err != nil { return err } // Prompt for ToS agreement if required. acct.TermsOfServiceAgreed = false if meta.TermsOfServiceURL != "" { res, err := interactor.Prompt(&interaction.Challenge{ Title: "Terms of Service Agreement Required", YesLabel: "I Agree", NoLabel: "Cancel", ResponseType: interaction.RTYesNo, UniqueID: "acme-agreement:" + meta.TermsOfServiceURL, Prompt: "Do you agree to the Terms of Service?", Body: fmt.Sprintf(`You must agree to the terms of service at the following URL to continue: %s Do you agree to the terms of service set out in the above document?`, meta.TermsOfServiceURL), }) if err != nil { return err } if res.Cancelled { return fmt.Errorf("terms of service agreement is required, but user declined") } acct.TermsOfServiceAgreed = true } // Get e. mail. email, err := getEmail(interactor) if err != nil { return err } if email == "-" { return fmt.Errorf("e. mail input cancelled") } if email != "" { acct.ContactURIs = []string{"mailto:" + email} } // Do the registration. err = cl.RegisterAccount(ctx, acct) if err != nil { return err } return nil } func getEmail(interactor interaction.Interactor) (string, error) { for { res, err := interactor.Prompt(&interaction.Challenge{ Title: "E. Mail Address Required", ResponseType: interaction.RTLineString, Prompt: "E. mail address: ", Body: `Please enter an e. mail address where you can be reached. Although entering an e. mail address is optional, it is highly recommended.`, UniqueID: "acme-enter-email", }) if err != nil { return "", err } if res.Value == "" { return "", nil } if res.Cancelled { return "-", nil } addr, err := mail.ParseAddress(res.Value) if err != nil { if res.Noninteractive { // If the e. mail address specified was invalid but we received it from // a noninteractive source, don't loop or we will loop forever. Instead // just act like one wasn't specified. return "", nil } continue } return addr.Address, nil } } func defaultInteraction(interactor interaction.Interactor) interaction.Interactor { if interactor == nil { return interaction.Auto } return interactor } acmetool-0.2.2/storage/000077500000000000000000000000001435652113300147655ustar00rootroot00000000000000acmetool-0.2.2/storage/abs.go000066400000000000000000000042121435652113300160600ustar00rootroot00000000000000package storage import ( "crypto" "errors" ) // Abstract storage interface. type Store interface { Close() error // Closes the database. Reload() error // Reloads the database from disk. Path() string // ACME state directory path. // These methods find an object by its identifier. Returns nil if the object // is not found. AccountByID(accountID string) *Account AccountByDirectoryURL(directoryURL string) *Account CertificateByID(certificateID string) *Certificate KeyByID(keyID string) *Key TargetByFilename(filename string) *Target DefaultTarget() *Target // Returns the default target. PreferredCertificateForHostname(hostname string) (*Certificate, error) VisitPreferredCertificates(func(hostname string, c *Certificate) error) error // The Visit methods call the given function for each known object of the // given type. Returning an error short-circuits. VisitAccounts(func(*Account) error) error VisitCertificates(func(*Certificate) error) error VisitKeys(func(*Key) error) error VisitTargets(func(*Target) error) error // Mutators. SaveTarget(*Target) error // Saves a target. RemoveTarget(filename string) error // Remove a target from the database. SaveCertificate(*Certificate) error // Saves certificate information. SaveAccount(*Account) error // Save account information. // Erase a whole certificate directory including URL, certificates, etc. RemoveCertificate(certificateID string) error // Erase a private key directory. RemoveKey(keyID string) error ImportKey(privateKey crypto.PrivateKey) (*Key, error) // Imports the key if it isn't already imported. ImportAccount(directoryURL string, privateKey crypto.PrivateKey) (*Account, error) // Imports an account key if it isn't already imported. ImportCertificate(acct *Account, url string) (*Certificate, error) // Imports a certificate if it isn't already imported. SetPreferredCertificateForHostname(hostname string, c *Certificate) error WriteMiscellaneousConfFile(filename string, data []byte) error } // Return this sentinel value to stop visitation. var StopVisiting = errors.New("[stop visiting]") acmetool-0.2.2/storage/config.go000066400000000000000000000040221435652113300165570ustar00rootroot00000000000000package storage import ( "crypto/elliptic" "github.com/hlandau/acmetool/fdb" "strings" ) // Legacy Configuration func (s *fdbStore) loadWebrootPaths() { if len(s.defaultTarget.Request.Challenge.WebrootPaths) != 0 { // Path list in default target file takes precedence. return } webrootPath, _ := fdb.String(s.db.Collection("conf").Open("webroot-path")) // ignore errors webrootPath = strings.TrimSpace(webrootPath) webrootPaths := strings.Split(webrootPath, "\n") for i := range webrootPaths { webrootPaths[i] = strings.TrimSpace(webrootPaths[i]) } if len(webrootPaths) == 1 && webrootPaths[0] == "" { webrootPaths = nil } s.defaultTarget.Request.Challenge.WebrootPaths = webrootPaths } func (s *fdbStore) loadRSAKeySize() { if s.defaultTarget.Request.Key.RSASize != 0 { // setting in default target file takes precedence return } n, err := fdb.Uint(s.db.Collection("conf"), "rsa-key-size", 31) if err != nil { return } s.defaultTarget.Request.Key.RSASize = int(n) if nn := clampRSAKeySize(int(n)); nn != int(n) { log.Warnf("An RSA key size of %d is not supported; must have %d <= size <= %d; clamping at %d", n, minRSASize, maxRSASize, nn) } } // Key Parameters const ( minRSASize = 2048 defaultRSASize = 2048 maxRSASize = 4096 ) func clampRSAKeySize(sz int) int { if sz == 0 { return defaultRSASize } if sz < minRSASize { return minRSASize } if sz > maxRSASize { return maxRSASize } return sz } const defaultCurve = "nistp256" // Make sure the curve name is valid and use a default curve name. "clamp" is // not the sanest name here but is consistent with clampRSAKeySize. func clampECDSACurve(curveName string) string { switch curveName { case "nistp256", "nistp384", "nistp521": return curveName default: return defaultCurve } } func getECDSACurve(curveName string) elliptic.Curve { switch clampECDSACurve(curveName) { case "nistp256": return elliptic.P256() case "nistp384": return elliptic.P384() case "nistp521": return elliptic.P521() default: return nil } } acmetool-0.2.2/storage/neuter.go000066400000000000000000000030461435652113300166210ustar00rootroot00000000000000package storage // In some cases it is desirable to load configuration information such as the // default target file, but very undesirable to load sensitive information such // as private keys. For example, the HTTP to HTTPS redirector is a public-facing // service and as such, is run privilege-dropped and chrooted for mitigation // purposes in the unlikely event that a vulnerability is identified in this // program or its dependencies, each written in a memory-safe language. // However, this could all be for nought if extremely valuable data such as // private keys is kept in process memory after dropping privileges. It is // therefore essential that private keys NEVER touch the memory of an acmetool // process launched to serve as a redirector. // // Hence this function. Calling this function neuters the storage package. // Neuter does two things: // // - It panics if a storage instance has ever been created in this process // before the first call to Neuter. // // - It changes the behaviour of the storage package so that all future loads // of state directories load configuration information, but no private keys. // // Thus, once Neuter has returned, this is essentially a guarantee that no // private keys ever have been or ever will be loaded into the process. A call // to Neuter cannot be reversed except by starting a new process. func Neuter() { if hasTouchedSensitiveData { panic("cannot neuter storage package after it has already been used") } isNeutered = true } var isNeutered = false var hasTouchedSensitiveData = false acmetool-0.2.2/storage/storage-fdb.go000066400000000000000000000446351435652113300175250ustar00rootroot00000000000000// Package storage implements the state directory specification, providing // a logical API access layer. package storage import ( "crypto" "crypto/x509" "fmt" "github.com/hlandau/acmetool/fdb" "github.com/hlandau/acmetool/util" "github.com/hlandau/xlog" "gopkg.in/hlandau/acmeapi.v2" "gopkg.in/hlandau/acmeapi.v2/acmeutils" "gopkg.in/yaml.v2" "io" "io/ioutil" "os" "strings" ) var log, Log = xlog.New("acme.storage") // ACME client store. {{{1 type fdbStore struct { db *fdb.DB path string certs map[string]*Certificate // key: certificate ID accounts map[string]*Account // key: account ID keys map[string]*Key // key: key ID targets map[string]*Target // key: target filename preferred map[string]*Certificate // key: hostname defaultTarget *Target // from conf } func (s *fdbStore) WriteMiscellaneousConfFile(filename string, data []byte) error { return fdb.WriteBytes(s.db.Collection("conf"), filename, data) } // Trivial accessors. {{{1 func (s *fdbStore) AccountByID(accountID string) *Account { return s.accounts[accountID] } func (s *fdbStore) AccountByDirectoryURL(directoryURL string) *Account { for _, a := range s.accounts { if a.MatchesURL(directoryURL) { return a } } return nil } func (s *fdbStore) VisitAccounts(f func(a *Account) error) error { for _, a := range s.accounts { err := f(a) if err != nil { return err } } return nil } func (s *fdbStore) CertificateByID(certificateID string) *Certificate { return s.certs[certificateID] } func (s *fdbStore) VisitCertificates(f func(c *Certificate) error) error { for _, c := range s.certs { err := f(c) if err != nil { return err } } return nil } func (s *fdbStore) TargetByFilename(filename string) *Target { return s.targets[filename] } func (s *fdbStore) VisitTargets(f func(t *Target) error) error { for _, t := range s.targets { err := f(t) if err != nil { return err } } return nil } // Return the default target. Persist changes to the default target by calling SaveTarget. func (s *fdbStore) DefaultTarget() *Target { return s.defaultTarget } func (s *fdbStore) KeyByID(keyID string) *Key { return s.keys[keyID] } func (s *fdbStore) VisitKeys(f func(k *Key) error) error { for _, k := range s.keys { err := f(k) if err != nil { return err } } return nil } func (s *fdbStore) loadPreferred() error { s.preferred = map[string]*Certificate{} c := s.db.Collection("live") links, err := c.List() if err != nil { return err } for _, linkName := range links { link, err := c.ReadLink(linkName) if err != nil { return err } certID := link.Target[6:] cert := s.CertificateByID(certID) if cert == nil { // This should never happen because fdb checks symlinks, though maybe if // there was an empty certificate directory... return fmt.Errorf("unknown certificate: %q", certID) } s.preferred[linkName] = cert } return nil } func (s *fdbStore) VisitPreferredCertificates(f func(hostname string, c *Certificate) error) error { for hostname, c := range s.preferred { err := f(hostname, c) if err != nil { return err } } return nil } func (s *fdbStore) PreferredCertificateForHostname(hostname string) (*Certificate, error) { c := s.preferred[hostname] if c == nil { return nil, fmt.Errorf("not found: %q", hostname) } return c, nil } func (s *fdbStore) SetPreferredCertificateForHostname(hostname string, c *Certificate) error { err := s.db.Collection("live").WriteLink(hostname, fdb.Link{Target: "certs/" + c.ID()}) if err != nil { return err } s.preferred[hostname] = c return nil } // Default paths and permissions. {{{1 // The recommended path is the hardcoded, default, recommended path to be used // for a system-wide state storage directory. It may vary by system and // platform. On most POSIX-like systems, it is "/var/lib/acme". Specific builds // might customise it. var RecommendedPath string func init() { // Allow the path to be overridden at build time. if RecommendedPath == "" { RecommendedPath = "/var/lib/acme" } } var storePermissions = []fdb.Permission{ {Path: ".", DirMode: 0755, FileMode: 0644}, {Path: "accounts", DirMode: 0700, FileMode: 0600}, {Path: "desired", DirMode: 0755, FileMode: 0644}, {Path: "live", DirMode: 0755, FileMode: 0644}, {Path: "certs", DirMode: 0755, FileMode: 0644}, {Path: "certs/*/haproxy", DirMode: 0700, FileMode: 0600}, // hack for HAProxy {Path: "keys", DirMode: 0700, FileMode: 0600}, {Path: "conf", DirMode: 0755, FileMode: 0644}, {Path: "tmp", DirMode: 0700, FileMode: 0600}, } // Initialization and loading. {{{1 // Create a new client store using the given path. func NewFDB(path string) (Store, error) { if path == "" { path = RecommendedPath } dbCfg := fdb.Config{ Path: path, } if !isNeutered { dbCfg.Permissions = storePermissions dbCfg.PermissionsPath = "conf/perm" } db, err := fdb.Open(dbCfg) if err != nil { return nil, fmt.Errorf("open fdb: %v", err) } s := &fdbStore{ db: db, path: path, } err = s.Reload() if err != nil { return nil, err } return s, nil } // Close the store. func (s *fdbStore) Close() error { return nil } // State directory path. func (s *fdbStore) Path() string { return s.path } // Reload from disk. func (s *fdbStore) Reload() error { if !isNeutered { hasTouchedSensitiveData = true err := s.loadAccounts() if err != nil { return err } err = s.loadKeys() if err != nil { return err } err = s.loadCerts() if err != nil { return err } } err := s.loadTargets() if err != nil { return err } if !isNeutered { err = s.loadPreferred() if err != nil { return err } } return nil } func (s *fdbStore) loadAccounts() error { c := s.db.Collection("accounts") serverNames, err := c.List() if err != nil { return err } s.accounts = map[string]*Account{} for _, serverName := range serverNames { sc := c.Collection(serverName) accountNames, err := sc.List() log.Errore(err, "failed to list accounts for server ", serverName) if err != nil { return err } for _, accountName := range accountNames { ac := sc.Collection(accountName) err := s.validateAccount(serverName, accountName, ac) log.Errore(err, "failed to load account ", accountName) if err != nil && IsWellFormattedCertificateOrKeyID(accountName) { // If the account name is not a well-formatted key ID and it fails to // load, ignore errors. return err } } } return nil } func (s *fdbStore) validateAccount(serverName, accountName string, c *fdb.Collection) error { f, err := c.Open("privkey") if err != nil { return err } defer f.Close() b, err := ioutil.ReadAll(f) if err != nil { return err } pk, err := acmeutils.LoadPrivateKey(b) if err != nil { return err } f.Close() directoryURL, err := decodeAccountURLPart(serverName) if err != nil { return err } account := &Account{ PrivateKey: pk, DirectoryURL: directoryURL, } accountID := account.ID() actualAccountID := serverName + "/" + accountName if accountID != actualAccountID { return fmt.Errorf("account ID mismatch: %#v != %#v", accountID, actualAccountID) } s.accounts[accountID] = account return nil } func (s *fdbStore) loadKeys() error { s.keys = map[string]*Key{} c := s.db.Collection("keys") keyIDs, err := c.List() if err != nil { return err } for _, keyID := range keyIDs { kc := c.Collection(keyID) err := s.validateKey(keyID, kc) log.Errore(err, "failed to load key ", keyID) if err != nil && IsWellFormattedCertificateOrKeyID(keyID) { // If the key fails to load and it has an invalid key ID, ignore errors. return err } } return nil } func (s *fdbStore) validateKey(keyID string, kc *fdb.Collection) error { f, err := kc.Open("privkey") if err != nil { return err } defer f.Close() b, err := ioutil.ReadAll(f) if err != nil { return err } pk, err := acmeutils.LoadPrivateKey(b) if err != nil { return err } actualKeyID, err := determineKeyIDFromKey(pk) if err != nil { return err } if actualKeyID != keyID { return fmt.Errorf("key ID mismatch: %#v != %#v", keyID, actualKeyID) } k := &Key{ ID: actualKeyID, PrivateKey: pk, } s.keys[actualKeyID] = k return nil } func (s *fdbStore) loadCerts() error { s.certs = map[string]*Certificate{} c := s.db.Collection("certs") certIDs, err := c.List() if err != nil { return err } for _, certID := range certIDs { kc := c.Collection(certID) err := s.validateCert(certID, kc) log.Errore(err, "failed to load certificate ", certID) if err != nil && IsWellFormattedCertificateOrKeyID(certID) { // If the certificate fails to load and it has an invalid cert ID, // ignore errors. return err } } return nil } func (s *fdbStore) validateCert(certID string, c *fdb.Collection) error { ss, err := fdb.String(c.Open("url")) if err != nil { return err } ss = strings.TrimSpace(ss) if !acmeapi.ValidURL(ss) { return fmt.Errorf("certificate order has invalid URI") } actualCertID := determineCertificateID(ss) if certID != actualCertID { return fmt.Errorf("cert ID mismatch: %#v != %#v", certID, actualCertID) } crt := &Certificate{ URL: ss, Certificates: nil, Cached: false, RevocationDesired: fdb.Exists(c, "revoke"), Revoked: fdb.Exists(c, "revoked"), } fullchain, err := fdb.Bytes(c.Open("fullchain")) if err == nil { certs, err := acmeutils.LoadCertificates(fullchain) if err != nil { return err } xcrt, err := x509.ParseCertificate(certs[0]) if err != nil { return err } keyID := determineKeyIDFromCert(xcrt) crt.Key = s.keys[keyID] if crt.Key != nil { err := c.WriteLink("privkey", fdb.Link{Target: "keys/" + keyID + "/privkey"}) if err != nil { return err } } crt.Certificates = certs crt.Cached = true } acctLink, err := c.ReadLink("account") if err == nil { if !strings.HasPrefix(acctLink.Target, "accounts/") { return fmt.Errorf("malformed certificate account symlink: %q %q", certID, acctLink.Target) } crt.Account = s.AccountByID(acctLink.Target[9:]) if crt.Account == nil { log.Warnf("certificate directory %#v contains account reference %#v but no such account was found", certID, acctLink.Target) } } s.certs[certID] = crt return nil } func (s *fdbStore) loadTargets() error { s.targets = map[string]*Target{} // default target confc := s.db.Collection("conf") dtgt, err := s.validateTargetInner("target", confc, true) if err == nil { dtgt.genericise() s.defaultTarget = dtgt } else { if !os.IsNotExist(err) { log.Errore(err, "error loading default target file") } s.defaultTarget = &Target{} } // Legacy support. We have to do this here so that these defaults get copied // across to the targets. s.loadWebrootPaths() s.loadRSAKeySize() // targets c := s.db.Collection("desired") desiredKeys, err := c.List() if err != nil { return err } for _, desiredKey := range desiredKeys { err := s.validateTarget(desiredKey, c) log.Errore(err, "failed to load target ", desiredKey) // Ignore errors, best effort. } return nil } func (s *fdbStore) validateTarget(desiredKey string, c *fdb.Collection) error { tgt, err := s.validateTargetInner(desiredKey, c, false) if err != nil { return err } s.targets[desiredKey] = tgt return nil } func (s *fdbStore) validateTargetInner(desiredKey string, c *fdb.Collection, loadingDefault bool) (*Target, error) { b, err := fdb.Bytes(c.Open(desiredKey)) if err != nil { return nil, err } var tgt *Target if loadingDefault { tgt = &Target{} } else { tgt = s.defaultTarget.CopyGeneric() } tgt.Filename = desiredKey err = yaml.Unmarshal(b, tgt) if err != nil { return nil, err } if len(tgt.Satisfy.Names) == 0 { if len(tgt.LegacyNames) > 0 { tgt.Satisfy.Names = tgt.LegacyNames } else { tgt.Satisfy.Names = []string{desiredKey} } } if tgt.Request.Provider == "" { tgt.Request.Provider = tgt.LegacyProvider } err = normalizeNames(tgt.Satisfy.Names) if err != nil { return nil, fmt.Errorf("invalid target: %s: %v", desiredKey, err) } if len(tgt.Request.Names) == 0 { tgt.Request.Names = tgt.Satisfy.Names tgt.Request.implicitNames = true } // tgt.Request.Account is not set; it is for use by other code. return tgt, nil } // Saving {{{1 // Serializes the target to disk. Call after changing any settings. func (s *fdbStore) SaveTarget(t *Target) error { // Some basic validation. err := t.Validate() if err != nil { return err } if t != s.defaultTarget { t.ensureFilename() } tcopy := *t if t == s.defaultTarget { tcopy.genericise() } // don't serialize default request names list if tcopy.Request.implicitNames { tcopy.Request.Names = nil } b, err := yaml.Marshal(&tcopy) if err != nil { return err } // Save. if t == s.defaultTarget { return fdb.WriteBytes(s.db.Collection("conf"), "target", b) } return fdb.WriteBytes(s.db.Collection("desired"), t.Filename, b) } func (s *fdbStore) RemoveTarget(filename string) error { return s.db.Collection("desired").Delete(filename) } func (s *fdbStore) SaveCertificate(cert *Certificate) error { c := s.db.Collection("certs/" + cert.ID()) if cert.RevocationDesired { err := fdb.CreateEmpty(c, "revoke") if err != nil { return err } } if cert.Revoked { err := fdb.CreateEmpty(c, "revoked") if err != nil { return err } } if len(cert.Certificates) == 0 { return nil } fcert, err := c.Create("cert") if err != nil { return err } defer fcert.CloseAbort() fchain, err := c.Create("chain") if err != nil { return err } defer fchain.CloseAbort() ffullchain, err := c.Create("fullchain") if err != nil { return err } defer ffullchain.CloseAbort() err = acmeutils.SaveCertificates(io.MultiWriter(fcert, ffullchain), cert.Certificates[0]) if err != nil { return err } for _, ec := range cert.Certificates[1:] { err = acmeutils.SaveCertificates(io.MultiWriter(fchain, ffullchain), ec) if err != nil { return err } } fcert.Close() fchain.Close() ffullchain.Close() return nil } func (s *fdbStore) SaveAccount(a *Account) error { coll := s.db.Collection("accounts/" + a.ID()) w, err := coll.Create("privkey") if err != nil { return err } defer w.CloseAbort() err = acmeutils.SavePrivateKey(w, a.PrivateKey) if err != nil { return err } w.Close() return nil } // Removal {{{1 func (s *fdbStore) RemoveCertificate(certificateID string) error { _, ok := s.certs[certificateID] if !ok { return fmt.Errorf("certificate does not exist: %s", certificateID) } err := s.db.Collection("certs").Delete(certificateID) if err != nil { return err } delete(s.certs, certificateID) return nil } func (s *fdbStore) RemoveKey(keyID string) error { _, ok := s.keys[keyID] if !ok { return fmt.Errorf("key does not exist: %s", keyID) } err := s.db.Collection("keys").Delete(keyID) if err != nil { return err } delete(s.keys, keyID) return nil } // Importing {{{1 // Give a PEM-encoded key file, imports the key into the store. If the key is // already installed, returns nil. func (s *fdbStore) ImportKey(privateKey crypto.PrivateKey) (*Key, error) { keyID, err := determineKeyIDFromKey(privateKey) if err != nil { return nil, err } k, ok := s.keys[keyID] if ok { return k, nil } c := s.db.Collection("keys/" + keyID) err = s.saveKey(c, privateKey) if err != nil { return nil, err } k = &Key{ PrivateKey: privateKey, ID: keyID, } s.keys[keyID] = k return k, nil } // Given a certificate URL, imports the certificate into the store. The // certificate will be retrieved on the next reconcile. If a certificate with // that URL already exists, this is a no-op and returns nil. func (s *fdbStore) ImportCertificate(acct *Account, url string) (*Certificate, error) { certID := determineCertificateID(url) c, ok := s.certs[certID] if ok { return c, nil } coll := s.db.Collection("certs/" + certID) err := coll.WriteLink("account", fdb.Link{"accounts/" + acct.ID()}) if err != nil { return nil, err } err = fdb.WriteBytes(coll, "url", []byte(url)) if err != nil { return nil, err } c = &Certificate{ URL: url, Account: acct, } s.certs[certID] = c return c, nil } // Given an account private key and the provider directory URL, imports that account key. // If the account already exists and has a private key, this is a no-op and returns nil. func (s *fdbStore) ImportAccount(directoryURL string, privateKey crypto.PrivateKey) (*Account, error) { accountID, err := determineAccountID(directoryURL, privateKey) if err != nil { return nil, err } a, ok := s.accounts[accountID] if ok { return a, nil } err = s.saveKey(s.db.Collection("accounts/"+accountID), privateKey) if err != nil { return nil, err } a = &Account{ PrivateKey: privateKey, DirectoryURL: directoryURL, } s.accounts[accountID] = a return a, nil } // Saves a key as a file named "privkey" inside the given collection. func (s *fdbStore) saveKey(c *fdb.Collection, privateKey crypto.PrivateKey) error { f, err := c.Create("privkey") if err != nil { return err } defer f.CloseAbort() err = acmeutils.SavePrivateKey(f, privateKey) if err != nil { return err } return f.Close() } // Save a private key inside a key ID collection under the given collection. func (s *fdbStore) saveKeyUnderID(c *fdb.Collection, privateKey crypto.PrivateKey) (keyID string, err error) { keyID, err = determineKeyIDFromKey(privateKey) if err != nil { return } err = s.saveKey(c.Collection(keyID), privateKey) return } // Revocation marking {{{1 // Try to revoke the certificate with the given certificate ID. // If a key ID is given, revoke all certificates with using key ID. func (s *fdbStore) RevokeByCertificateOrKeyID(certID string) error { c, ok := s.certs[certID] if !ok { return s.revokeByKeyID(certID) } if c.Revoked { log.Warnf("%v already revoked", c) return nil } col := s.db.Collection("certs/" + c.ID()) err := fdb.CreateEmpty(col, "revoke") if err != nil { return err } c.RevocationDesired = true return nil } func (s *fdbStore) revokeByKeyID(keyID string) error { k, ok := s.keys[keyID] if !ok { return fmt.Errorf("cannot find certificate or key with given ID: %q", keyID) } var merr util.MultiError for _, c := range s.certs { if c.Key != k { continue } err := s.RevokeByCertificateOrKeyID(c.ID()) if err != nil { merr = append(merr, fmt.Errorf("failed to mark %v for revocation: %v", c, err)) } } if len(merr) > 0 { return merr } return nil } acmetool-0.2.2/storage/types.go000066400000000000000000000214521435652113300164640ustar00rootroot00000000000000package storage import ( "crypto" "crypto/ecdsa" "crypto/rsa" "encoding/base32" "fmt" "github.com/gofrs/uuid" "gopkg.in/hlandau/acmeapi.v2" "strings" ) // Represents stored account data. type Account struct { // N. Account private key. PrivateKey crypto.PrivateKey // N. Server directory URL. DirectoryURL string // ID: determined from DirectoryURL and PrivateKey. // Path: formed from ID. // Registration URL: can be recovered automatically. } // Returns the account ID (server URL/key ID). func (a *Account) ID() string { accountID, err := determineAccountID(a.DirectoryURL, a.PrivateKey) log.Panice(err) return accountID } // Returns true iff the account is for a given provider URL. func (a *Account) MatchesURL(p string) bool { return p == a.DirectoryURL } func (a *Account) String() string { return fmt.Sprintf("Account(%v)", a.ID()) } // Convert storage Account object to a new acmeapi.Account suitable for making // requests. func (a *Account) ToAPI() *acmeapi.Account { return &acmeapi.Account{ PrivateKey: a.PrivateKey, } } // Represents the "satisfy" section of a target file. type TargetSatisfy struct { // N. List of SANs required to satisfy this target. May include hostnames // (and maybe one day SRV-IDs). May include wildcard hostnames, but ACME // doesn't support those yet. Names []string `yaml:"names,omitempty"` // N. Renewal margin in days. Defaults to 30. Margin int `yaml:"margin,omitempty"` // D. Reduced name set, after disjunction operation. Derived from Names for // each label (or label ""). //ReducedNamesByLabel map[string][]string `yaml:"-"` // N. Key configuration items which are required to satisfy a target. Key TargetSatisfyKey `yaml:"key,omitempty"` } // Represents the "satisfy": "key" section of a target file. type TargetSatisfyKey struct { // N. Type of key to require. "" means do not require any specific type of // key. Type string `yaml:"type,omitempty"` } // Represents the "request" section of a target file. type TargetRequest struct { // N/d. List of SANs to place on any obtained certificate. Defaults to the // names in the satisfy section. Names []string `yaml:"names,omitempty"` // Used to track whether Names was explicitly specified, for reserialization purposes. implicitNames bool // N. Currently, this is the provider directory URL. An account matching it // will be used. At some point, a way to specify a particular account should // probably be added. Provider string `yaml:"provider,omitempty"` // D. Account to use. The storage package does not set this; it is for the // convenience of consuming code. To be determined via Provider string. Account *Account `yaml:"-"` // Settings relating to the creation of new keys used to request // corresponding certificates. Key TargetRequestKey `yaml:"key,omitempty"` // Settings relating to the completion of challenges. Challenge TargetRequestChallenge `yaml:"challenge,omitempty"` // N. Request OCSP Must Staple in CSRs? OCSPMustStaple bool `yaml:"ocsp-must-staple,omitempty"` } // Settings for keys generated as part of certificate requests. type TargetRequestKey struct { // N. Key type to use in making a request. "rsa" or "ecdsa". Default "rsa". Type string `yaml:"type,omitempty"` // N. RSA key size to use for new RSA keys. Defaults to 2048 bits. RSASize int `yaml:"rsa-size,omitempty"` // N. ECDSA curve. "nistp256" (default), "nistp384" or "nistp521". ECDSACurve string `yaml:"ecdsa-curve,omitempty"` // N. The key ID of an existing key to use for the purposes of making // requests. If not set, always generate a new key. ID string `yaml:"id,omitempty"` } func (k *TargetRequestKey) String() string { switch k.Type { case "", "rsa": return fmt.Sprintf("rsa-%d", clampRSAKeySize(k.RSASize)) case "ecdsa": return fmt.Sprintf("ecdsa-%s", clampECDSACurve(k.ECDSACurve)) default: return k.Type // ... } } // Settings relating to the completion of challenges. type TargetRequestChallenge struct { // N. Webroot paths to use when completing challenges. WebrootPaths []string `yaml:"webroot-paths,omitempty"` // N. Ports to listen on when completing challenges. HTTPPorts []string `yaml:"http-ports,omitempty"` // N. Perform HTTP self-test? Defaults to true. Rarely needed. If disabled, // HTTP challenges will be performed without self-testing. HTTPSelfTest *bool `yaml:"http-self-test,omitempty"` // N. Environment variables to pass to hooks. Env map[string]string `yaml:"env,omitempty"` // N. Inherited environment variables. Used internally. InheritedEnv map[string]string `yaml:"-"` } // Represents a stored target descriptor. type Target struct { // Specifies conditions which must be met. Satisfy TargetSatisfy `yaml:"satisfy,omitempty"` // Specifies parameters used when requesting certificates. Request TargetRequest `yaml:"request,omitempty"` // N. Priority. Controls symlink generation. See state storage specification. Priority int `yaml:"priority,omitempty"` // N. Label. Controls symlink generation. See state storage specification. Label string `yaml:"label,omitempty"` // LEGACY. Names to be satisfied. Moved to Satisfy.Names. LegacyNames []string `yaml:"names,omitempty"` // LEGACY. Provider URL to used. Moved to Request.Provider. LegacyProvider string `yaml:"provider,omitempty"` // Internal use. The filename under which the target is stored. Filename string `yaml:"-"` } func (t *Target) String() string { return fmt.Sprintf("Target(%s;%s;%d)", strings.Join(t.Satisfy.Names, ","), t.Request.Provider, t.Priority) } // Validates a target for basic sanity. Returns the first error found or nil. func (t *Target) Validate() error { if t.Request.Provider != "" && !acmeapi.ValidURL(t.Request.Provider) { return fmt.Errorf("invalid provider URL: %q", t.Request.Provider) } return nil } func (t *Target) ensureFilename() { if t.Filename != "" { return } // Unfortunately we can't really check if the first hostname exists as a filename // and use another name instead as this would create all sorts of race conditions. // We have to use a random name. nprefix := "" if len(t.Satisfy.Names) > 0 { nprefix = t.Satisfy.Names[0] + "-" } b := uuid.Must(uuid.NewV4()).Bytes() str := strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(b), "=")) t.Filename = nprefix + str } // Returns a copy of the target. func (t *Target) Copy() *Target { // A Target contains no pointers to part of the target which should be copied. // i.e. all pointers point to other things not part of the copy. Thus we can // just copy the value. If Target is ever changed to reference any component // of itself via pointer, this must be changed! tt := *t tt.Request.Challenge.InheritedEnv = map[string]string{} for k, v := range t.Request.Challenge.InheritedEnv { tt.Request.Challenge.InheritedEnv[k] = v } for k, v := range t.Request.Challenge.Env { tt.Request.Challenge.InheritedEnv[k] = v } tt.Request.Challenge.Env = nil return &tt } // Returns a copy of the target, but zeroes any very specific fields // like names. func (t *Target) CopyGeneric() *Target { tt := t.Copy() tt.genericise() return tt } func (t *Target) genericise() { t.Satisfy.Names = nil //t.Satisfy.ReducedNamesByLabel = nil t.Request.Names = nil t.LegacyNames = nil } // Represents stored certificate information. type Certificate struct { // N. URL to the order used to obtain the certificate. Not a direct URL to // the certificate blob. URL string // N. Whether this certificate should be revoked. RevocationDesired bool // N (for now). Whether this certificate has been revoked. Revoked bool // N. Now required due to need to support POST-as-GET. The account under // which the certificate was requested. nil if this is unknown due to being a // legacy certificate directory. Account *Account // D. Certificate data retrieved from URL, plus chained certificates. // The end certificate comes first, the root last, etc. Certificates [][]byte // D. True if the certificate has been downloaded. Cached bool // D. The private key for the certificate. Key *Key // D. ID: formed from hash of certificate URL. // D. Path: formed from ID. } // Returns a string summary of the certificate. func (c *Certificate) String() string { return fmt.Sprintf("Certificate(%v)", c.ID()) } // Returns the certificate ID. func (c *Certificate) ID() string { return determineCertificateID(c.URL) } // Represents a stored key. type Key struct { // N. The key. PrivateKey crypto.PrivateKey // D. ID: Derived from the key itself. ID string // D. Path: formed from ID. } // Returns a string summary of the key. func (k *Key) String() string { return fmt.Sprintf("Key(%v)", k.ID) } // Returns the type name of the key ("rsa" or "ecdsa"). func (k *Key) Type() string { switch k.PrivateKey.(type) { case *rsa.PrivateKey: return "rsa" case *ecdsa.PrivateKey: return "ecdsa" default: return "" } } acmetool-0.2.2/storage/util.go000066400000000000000000000125611435652113300162760ustar00rootroot00000000000000package storage import ( "crypto" "crypto/ecdsa" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base32" "fmt" "gopkg.in/hlandau/acmeapi.v2/acmeutils" "io" "math/big" "net/url" "path/filepath" "regexp" "strings" ) func decodeAccountURLPart(part string) (string, error) { scheme := "https" if strings.HasPrefix(part, "http:") { scheme = "http" part = part[5:] } unesc, err := url.QueryUnescape(part) if err != nil { return "", err } p := scheme + "://" + unesc u, err := url.Parse(p) if err != nil { return "", err } if u.Path == "" { u.Path = "/" } return u.String(), nil } func accountURLPart(directoryURL string) (string, error) { u, err := url.Parse(directoryURL) if err != nil { return "", err } if u.Scheme != "https" && u.Scheme != "http" { return "", fmt.Errorf("scheme must be HTTPS (or HTTP)") } directoryURL = u.String() s := directoryURL[strings.IndexByte(directoryURL, ':')+3:] if u.Path == "/" { s = s[0 : len(s)-1] } s = lowerEscapes(url.QueryEscape(s)) if u.Scheme == "http" { s = "http:" + s } return s, nil } func lowerEscapes(s string) string { b := []byte(s) state := 0 for i := 0; i < len(b); i++ { switch state { case 0: if b[i] == '%' { state = 1 } case 1: if b[i] == '%' { state = 0 } else { state = 2 } b[i] = lowerChar(b[i]) case 2: state = 0 b[i] = lowerChar(b[i]) } } return string(b) } func lowerChar(c byte) byte { if c >= 'A' && c <= 'F' { return c - 'A' + 'a' } return c } // 'root' must be an absolute path. func pathIsWithin(subject, root string) (bool, error) { os := subject subject, err := filepath.EvalSymlinks(subject) if err != nil { log.Errore(err, "eval symlinks: ", os, " ", root) return false, err } subject, err = filepath.Abs(subject) if err != nil { return false, err } return strings.HasPrefix(subject, ensureSeparator(root)), nil } func ensureSeparator(p string) string { if !strings.HasSuffix(p, string(filepath.Separator)) { return p + string(filepath.Separator) } return p } func determineKeyIDFromCert(c *x509.Certificate) string { h := sha256.New() h.Write(c.RawSubjectPublicKeyInfo) return strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(h.Sum(nil)), "=")) } func getPublicKey(pk crypto.PrivateKey) crypto.PublicKey { switch pkv := pk.(type) { case *rsa.PrivateKey: return &pkv.PublicKey case *ecdsa.PrivateKey: return &pkv.PublicKey default: panic("unsupported key type") } } func determineKeyIDFromKey(pk crypto.PrivateKey) (string, error) { return determineKeyIDFromKeyIntl(getPublicKey(pk), pk) } func determineKeyIDFromKeyIntl(pubk crypto.PublicKey, pk crypto.PrivateKey) (string, error) { cc := &x509.Certificate{ SerialNumber: big.NewInt(1), } cb, err := x509.CreateCertificate(rand.Reader, cc, cc, pubk, pk) if err != nil { return "", err } c, err := x509.ParseCertificate(cb) if err != nil { return "", err } return determineKeyIDFromCert(c), nil } type psuedoPrivateKey struct { pk crypto.PublicKey } func (ppk *psuedoPrivateKey) Public() crypto.PublicKey { return ppk.pk } func (ppk *psuedoPrivateKey) Sign(io.Reader, []byte, crypto.SignerOpts) ([]byte, error) { return []byte{0}, nil } // Given a public key, returns the key ID. func DetermineKeyIDFromPublicKey(pubk crypto.PublicKey) (string, error) { // Trick crypto/x509 into creating a certificate so we can grab the // subjectPublicKeyInfo by giving it a fake private key generating an invalid // signature. ParseCertificate doesn't verify the signature so this will // work. // // Yes, this is very hacky, but avoids having to duplicate code in crypto/x509. determineKeyIDFromKeyIntl(pubk, psuedoPrivateKey{}) cc := &x509.Certificate{ SerialNumber: big.NewInt(1), } cb, err := x509.CreateCertificate(rand.Reader, cc, cc, pubk, &psuedoPrivateKey{pubk}) if err != nil { return "", err } c, err := x509.ParseCertificate(cb) if err != nil { return "", err } return determineKeyIDFromCert(c), nil } func determineAccountID(providerURL string, privateKey interface{}) (string, error) { u, err := accountURLPart(providerURL) if err != nil { return "", err } keyID, err := determineKeyIDFromKey(privateKey) if err != nil { return "", err } return u + "/" + keyID, nil } func determineCertificateID(url string) string { h := sha256.New() h.Write([]byte(url)) b := h.Sum(nil) return strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(b), "=")) } var reCertID = regexp.MustCompile(`^[a-z0-9]{52}$`) // Returns true iff the given string could (possibly) be a valid certificate // (or key) ID. func IsWellFormattedCertificateOrKeyID(certificateID string) bool { return reCertID.MatchString(certificateID) } func targetGt(a *Target, b *Target) bool { if a == nil && b == nil { return false // equal } else if b == nil { return true // a > nil } else if a == nil { return false // nil < a } if a.Priority > b.Priority { return true } else if a.Priority < b.Priority { return false } return len(a.Satisfy.Names) > len(b.Satisfy.Names) } func containsName(names []string, name string) bool { for _, n := range names { if n == name { return true } } return false } func normalizeNames(names []string) error { for i := range names { n, err := acmeutils.NormalizeHostname(names[i]) if err != nil { return err } names[i] = n } return nil } acmetool-0.2.2/storage/util_test.go000066400000000000000000000011161435652113300173270ustar00rootroot00000000000000package storage import ( "crypto/rand" "crypto/rsa" "testing" ) // Make sure the determineKeyIDFromKey and determineKeyIDFromPublicKey // functions produce the same result. func TestKeyID(t *testing.T) { pk, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("error: %v", err) } keyID, err := determineKeyIDFromKey(pk) if err != nil { t.Fatalf("error: %v", err) } keyID2, err := DetermineKeyIDFromPublicKey(&pk.PublicKey) if err != nil { t.Fatalf("error: %v", err) } if keyID != keyID2 { t.Fatalf("key ID mismatch: %#v != %#v", keyID, keyID2) } } acmetool-0.2.2/storageops/000077500000000000000000000000001435652113300155075ustar00rootroot00000000000000acmetool-0.2.2/storageops/config.go000066400000000000000000000016141435652113300173050ustar00rootroot00000000000000package storageops import "github.com/hlandau/acmetool/storage" // Update targets to remove any mention of hostname from all targets. The // targets are resaved to disk. func RemoveTargetHostname(s storage.Store, hostname string) error { return s.VisitTargets(func(t *storage.Target) error { if !containsName(t.Satisfy.Names, hostname) { return nil // continue } t.Satisfy.Names = removeStringFromList(t.Satisfy.Names, hostname) t.Request.Names = removeStringFromList(t.Request.Names, hostname) if len(t.Satisfy.Names) == 0 { return s.RemoveTarget(t.Filename) } return s.SaveTarget(t) }) } func containsName(names []string, name string) bool { for _, n := range names { if n == name { return true } } return false } func removeStringFromList(ss []string, s string) []string { var r []string for _, x := range ss { if x != s { r = append(r, x) } } return r } acmetool-0.2.2/storageops/cull.go000066400000000000000000000017501435652113300170000ustar00rootroot00000000000000package storageops import "github.com/hlandau/acmetool/storage" func Cull(s storage.Store, simulate bool) error { certificatesToCull := map[string]*storage.Certificate{} // Relink before culling. err := Relink(s) if err != nil { return err } // Select all certificates. s.VisitCertificates(func(c *storage.Certificate) error { certificatesToCull[c.ID()] = c return nil }) // Unselect any certificate which is currently referenced. s.VisitPreferredCertificates(func(hostname string, c *storage.Certificate) error { delete(certificatesToCull, c.ID()) return nil }) // Now delete any certificate which is not generally valid. for certID, c := range certificatesToCull { if CertificateGenerallyValid(c) { continue } if simulate { log.Noticef("would delete certificate %s", certID) } else { log.Noticef("deleting certificate %s", certID) err := s.RemoveCertificate(certID) log.Errore(err, "failed to delete certificate ", certID) } } return nil } acmetool-0.2.2/storageops/keysize.go000066400000000000000000000016041435652113300175220ustar00rootroot00000000000000package storageops import "crypto/elliptic" const ( minRSASize = 2048 defaultRSASize = 2048 maxRSASize = 4096 ) func clampRSAKeySize(sz int) int { if sz == 0 { return defaultRSASize } if sz < minRSASize { return minRSASize } if sz > maxRSASize { return maxRSASize } return sz } const defaultCurve = "nistp256" // Make sure the curve name is valid and use a default curve name. "clamp" is // not the sanest name here but is consistent with clampRSAKeySize. func clampECDSACurve(curveName string) string { switch curveName { case "nistp256", "nistp384", "nistp521": return curveName default: return defaultCurve } } func getECDSACurve(curveName string) elliptic.Curve { switch clampECDSACurve(curveName) { case "nistp256": return elliptic.P256() case "nistp384": return elliptic.P384() case "nistp521": return elliptic.P521() default: return nil } } acmetool-0.2.2/storageops/reconcile-util.go000066400000000000000000000124541435652113300207620ustar00rootroot00000000000000package storageops import ( "crypto/x509" "fmt" "github.com/hlandau/acmetool/storage" ) func HaveUncachedCertificates(s storage.Store) bool { haveUncached := false s.VisitCertificates(func(c *storage.Certificate) error { if !c.Cached { haveUncached = true } return nil }) return haveUncached } // Returns the strings in ys not contained in xs. func stringsNotIn(xs, ys []string) []string { m := map[string]struct{}{} for _, x := range xs { m[x] = struct{}{} } var zs []string for _, y := range ys { _, ok := m[y] if !ok { zs = append(zs, y) } } return zs } func ensureConceivablySatisfiable(t *storage.Target) { // We ensure that every stipulation in the satisfy section can be met by the request // parameters. excludedNames := stringsNotIn(t.Request.Names, t.Satisfy.Names) if len(excludedNames) > 0 { log.Warnf("%v can never be satisfied because names to be requested are not a superset of the names to be satisfied; adding names automatically to render target satisfiable", t) } for _, n := range excludedNames { t.Request.Names = append(t.Request.Names, n) } if t.Satisfy.Key.Type != "" { t.Request.Key.Type = t.Satisfy.Key.Type } } func DoesCertificateSatisfy(c *storage.Certificate, t *storage.Target) bool { if c.Revoked { log.Debugf("%v cannot satisfy %v because it is revoked", c, t) return false } if len(c.Certificates) == 0 { log.Debugf("%v cannot satisfy %v because it has no actual certificates", c, t) return false } if c.Key == nil { // A certificate we don't have the key for is unusable. log.Debugf("%v cannot satisfy %v because we do not have a key for it", c, t) return false } cc, err := x509.ParseCertificate(c.Certificates[0]) if err != nil { log.Debugf("%v cannot satisfy %v because we cannot parse it: %v", c, t, err) return false } names := map[string]struct{}{} for _, name := range cc.DNSNames { names[name] = struct{}{} } for _, name := range t.Satisfy.Names { _, ok := names[name] if !ok { log.Debugf("%v cannot satisfy %v because required hostname %q is not listed on it: %#v", c, t, name, cc.DNSNames) return false } } if t.Satisfy.Key.Type != "" && t.Satisfy.Key.Type != c.Key.Type() { log.Debugf("%v cannot satisfy %v because required key type (%q) does not match (%q)", c, t, t.Satisfy.Key.Type, c.Key.Type()) return false } log.Debugf("%v satisfies %v", c, t) return true } func FindBestCertificateSatisfying(s storage.Store, t *storage.Target) (*storage.Certificate, error) { var bestCert *storage.Certificate err := s.VisitCertificates(func(c *storage.Certificate) error { if DoesCertificateSatisfy(c, t) { isBetterThan, err := CertificateBetterThan(c, bestCert) if err != nil { return err } if isBetterThan { log.Tracef("findBestCertificateSatisfying: %v > %v", c, bestCert) bestCert = c } else { log.Tracef("findBestCertificateSatisfying: %v <= %v", c, bestCert) } } return nil }) if err != nil { return nil, err } if bestCert == nil { return nil, fmt.Errorf("%v: no certificate satisfies this target", t) } return bestCert, nil } func CertificateBetterThan(a, b *storage.Certificate) (bool, error) { if b == nil || a == nil { return (b == nil && a != nil), nil } if len(a.Certificates) == 0 || len(b.Certificates) == 0 { return false, fmt.Errorf("need two certificates to compare") } ac, err := x509.ParseCertificate(a.Certificates[0]) bc, err2 := x509.ParseCertificate(b.Certificates[0]) if err != nil || err2 != nil { if err == nil && err2 != nil { log.Tracef("certBetterThan: parseable certificate is better than unparseable certificate") return true, nil } return false, nil } isAfter := ac.NotAfter.After(bc.NotAfter) log.Tracef("certBetterThan: (%v > %v)=%v", ac.NotAfter, bc.NotAfter, isAfter) return isAfter, nil } func CertificateNeedsRenewing(c *storage.Certificate, t *storage.Target) bool { if len(c.Certificates) == 0 { log.Debugf("%v: not renewing because it has no actual certificates (???)", c) return false } cc, err := x509.ParseCertificate(c.Certificates[0]) if err != nil { log.Debugf("%v: not renewing because its end certificate is unparseable", c) return false } renewTime := renewTime(cc.NotBefore, cc.NotAfter, t) needsRenewing := !InternalClock.Now().Before(renewTime) log.Debugf("%v needsRenewing=%v notAfter=%v", c, needsRenewing, cc.NotAfter) return needsRenewing } // This is used to detertmine whether to cull certificates. func CertificateGenerallyValid(c *storage.Certificate) bool { // This function is very conservative because if we return false // the certificate will get deleted. Revocation and expiry are // good reasons to delete. We already know the certificate is // unreferenced. if c.Revoked { log.Debugf("%v not generally valid because it is revoked", c) return false } if len(c.Certificates) == 0 { // If we have no actual certificates, give the benefit of the doubt. // Maybe the certificate is undownloaded. log.Debugf("%v has no actual certificates, assuming valid", c) return true } cc, err := x509.ParseCertificate(c.Certificates[0]) if err != nil { log.Debugf("%v cannot be parsed, assuming valid", c) return false } if !InternalClock.Now().Before(cc.NotAfter) { log.Debugf("%v not generally valid because it is expired", c) return false } return true } acmetool-0.2.2/storageops/reconcile.go000066400000000000000000000431401435652113300200030ustar00rootroot00000000000000// Package storageops implements operations on the state directory. package storageops import ( "context" "crypto" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" "fmt" "github.com/hlandau/acmetool/hooks" "github.com/hlandau/acmetool/responder" "github.com/hlandau/acmetool/solver" "github.com/hlandau/acmetool/storage" "github.com/hlandau/acmetool/util" "github.com/hlandau/xlog" "github.com/jmhodges/clock" "gopkg.in/hlandau/acmeapi.v2" "gopkg.in/hlandau/acmeapi.v2/acmeendpoints" "net/http" "path/filepath" "sort" "strings" "time" ) var log, Log = xlog.New("acmetool.storageops") // Internal use only. Used for testing purposes. Do not change. var InternalClock = clock.Default() // Internal use only. Used for testing purposes. Do not change. var InternalHTTPClient *http.Client // Optional configuration for the Reconcile operation. type ReconcileConfig struct { // If non-empty, a set of target names/paths to limit reconciliation to. // Essentially, the reconciliation engine acts as if only these targets // exist. Otherwise all targets are used. Targets []string } type reconcile struct { store storage.Store cfg ReconcileConfig // Cache of account clients to avoid duplicated directory lookups. accountClients map[*storage.Account]*acmeapi.RealmClient } func makeReconcile(store storage.Store, cfg ReconcileConfig) *reconcile { return &reconcile{ store: store, cfg: cfg, accountClients: map[*storage.Account]*acmeapi.RealmClient{}, } } func EnsureRegistration(store storage.Store) error { return makeReconcile(store, ReconcileConfig{}).EnsureRegistration() } func GetAccountURL(store storage.Store) (string, error) { return makeReconcile(store, ReconcileConfig{}).GetAccountURL() } func (r *reconcile) EnsureRegistration() error { a, err := r.getAccountByDirectoryURL("") if err != nil { return err } cl, err := r.getClientForAccount(a) if err != nil { return err } return solver.AssistedRegistration(context.TODO(), cl, a.ToAPI(), nil) } func (r *reconcile) GetAccountURL() (string, error) { a, err := r.getAccountByDirectoryURL("") if err != nil { return "", err } cl, err := r.getClientForAccount(a) if err != nil { return "", err } aapi := a.ToAPI() if aapi.URL == "" { err = cl.LocateAccount(context.TODO(), aapi) if err != nil { return "", err } } return aapi.URL, nil } func Reconcile(store storage.Store, cfg ReconcileConfig) error { r := makeReconcile(store, cfg) reconcileErr := r.Reconcile() log.Errore(reconcileErr, "failed to reconcile") reloadErr := r.store.Reload() log.Errore(reloadErr, "failed to reload after reconciliation") relinkErr := r.Relink() log.Errore(relinkErr, "failed to relink after reconciliation") err := reconcileErr if err == nil { err = reloadErr } if err == nil { err = relinkErr } return err } func Relink(store storage.Store) error { err := makeReconcile(store, ReconcileConfig{}).Relink() log.Errore(err, "failed to relink") return err } func (r *reconcile) Relink() error { hostnameTargetMapping, err := r.disjoinTargets() if err != nil { return err } var updatedHostnames []string for name, tgt := range hostnameTargetMapping { c, err := FindBestCertificateSatisfying(r.store, tgt) if err != nil { log.Debugf("could not find certificate satisfying %v: %v", tgt, err) continue } log.Tracef("relink: best certificate satisfying %v is %v", tgt, c) cprev, err := r.store.PreferredCertificateForHostname(name) if c != cprev || err != nil { log.Debugf("relinking: %v -> %v (was %v)", name, c, cprev) updatedHostnames = append(updatedHostnames, name) err = r.store.SetPreferredCertificateForHostname(name, c) log.Errore(err, "failed to set preferred certificate for hostname") } } ctx := &hooks.Context{ StateDir: r.store.Path(), } err = hooks.NotifyLiveUpdated(ctx, updatedHostnames) // ignore error log.Errore(err, "failed to call notify hooks") return nil } func (r *reconcile) disjoinTargets() (hostnameTargetMapping map[string]*storage.Target, err error) { var targets []*storage.Target r.store.VisitTargets(func(t *storage.Target) error { targets = append(targets, t) return nil }) sort.Stable(sort.Reverse(targetSorter(targets))) // Hostname-target mapping. // // N.B. The 'Reduced Names'/'Reduced Names By Label' data isn't actually used // for anything currently, so we disable computation of it currently. hostnameTargetMapping = map[string]*storage.Target{} for _, tgt := range targets { //tgt.Satisfy.ReducedNamesByLabel = nil for _, name := range tgt.Satisfy.Names { key := name if tgt.Label != "" { key += ":" + tgt.Label } _, exists := hostnameTargetMapping[key] if !exists { hostnameTargetMapping[key] = tgt //if tgt.Satisfy.ReducedNamesByLabel == nil { // tgt.Satisfy.ReducedNamesByLabel = map[string][]string{} //} //reducedNames, _ := tgt.Satisfy.ReducedNamesByLabel[tgt.Label] //tgt.Satisfy.ReducedNamesByLabel[tgt.Label] = append(reducedNames, name) } } } // Debugging information. for name, tgt := range hostnameTargetMapping { log.Debugf("disjoint hostname mapping: %q -> %v", name, tgt) } return } func (r *reconcile) Reconcile() error { err := r.processUncachedCertificates() if err != nil { return err } //err = r.processPendingRevocations() //log.Errore(err, "could not process pending revocations") err = r.processTargets() log.Errore(err, "error while processing targets") if err != nil { return err } return nil } func (r *reconcile) processUncachedCertificates() error { if !HaveUncachedCertificates(r.store) { return nil } log.Debug("there are uncached certificates - downloading them") err := r.downloadUncachedCertificates() if err != nil { log.Errore(err, "error while downloading uncached certificates") return err } log.Debug("reloading after downloading uncached certificates") err = r.store.Reload() if err != nil { log.Errore(err, "failed to reload after downloading uncached certificates") return err } log.Debug("finished reloading after downloading uncached certificates") if HaveUncachedCertificates(r.store) { log.Error("failed to download all uncached certificates") return fmt.Errorf("cannot obtain one or more uncached certificates") } return nil } func (r *reconcile) downloadUncachedCertificates() error { return r.store.VisitCertificates(func(c *storage.Certificate) error { if c.Cached { return nil } err := r.downloadCertificateAdaptive(c) if err != nil { // If the download fails, consider whether the error is permanent or // temporary. If temporary, don't hold up other certificates and continue // for now. We'll try again when next invoked. if util.IsTemporary(err) { // continue visitation log.Errore(err, "temporary error when trying to download certificate") return nil } else { // Permanent error, stop. // TODO: We might want to switch this to deleting the certificate at // some point. return err } } return nil }) } func (r *reconcile) getAccountByDirectoryURL(directoryURL string) (*storage.Account, error) { if directoryURL == "" { directoryURL = r.store.DefaultTarget().Request.Provider } if directoryURL == "" { directoryURL = acmeendpoints.DefaultEndpoint.DirectoryURL } if !acmeapi.ValidURL(directoryURL) { return nil, fmt.Errorf("directory URL is not a valid HTTPS URL") } ma := r.store.AccountByDirectoryURL(directoryURL) if ma != nil { return ma, nil } return r.createNewAccount(directoryURL) } func (r *reconcile) createNewAccount(directoryURL string) (*storage.Account, error) { pk, err := generateKey(&r.store.DefaultTarget().Request.Key) if err != nil { return nil, err } a := &storage.Account{ PrivateKey: pk, DirectoryURL: directoryURL, } err = r.store.SaveAccount(a) if err != nil { log.Errore(err, "failed to save account") return nil, err } return a, nil } func (r *reconcile) getGenericClient() (*acmeapi.RealmClient, error) { return r.getClientForDirectoryURL("") } func (r *reconcile) getClientForDirectoryURL(directoryURL string) (*acmeapi.RealmClient, error) { // Upgrade old directory URLs. endp, err := acmeendpoints.ByDirectoryURL(directoryURL) if err == nil { directoryURL = endp.DirectoryURL } // Create client. return acmeapi.NewRealmClient(acmeapi.RealmClientConfig{ DirectoryURL: directoryURL, HTTPClient: InternalHTTPClient, }) } func (r *reconcile) getClientForAccount(a *storage.Account) (*acmeapi.RealmClient, error) { cl := r.accountClients[a] if cl == nil { var err error cl, err = r.getClientForDirectoryURL(a.DirectoryURL) if err != nil { return nil, err } r.accountClients[a] = cl } return cl, nil } func (r *reconcile) targetIsSelected(t *storage.Target) (selected bool, err error) { if len(r.cfg.Targets) == 0 { selected = true return } for _, spec := range r.cfg.Targets { // If the spec is just a one-component path ("foo"), treat it as a match on // a name inside the "desired" directory. if spec == t.Filename { selected = true return } // Get absolute path of target filename and absolute path of provided // pathspec, interpreting it as a path, and see if they match. var tgtFilename string tgtFilename, err = filepath.Abs(filepath.Join(r.store.Path(), "desired", t.Filename)) if err != nil { return } var absSpec string absSpec, err = filepath.Abs(spec) if err != nil { return } if absSpec == tgtFilename { selected = true return } } log.Debugf("target not selected: %q", t.Filename) return } func (r *reconcile) processTargets() error { var merr util.MultiError r.store.VisitTargets(func(t *storage.Target) error { selected, err := r.targetIsSelected(t) if err != nil || !selected { return err } c, err := FindBestCertificateSatisfying(r.store, t) log.Debugf("%v: best certificate satisfying is %v, err=%v", t, c, err) if err == nil && !CertificateNeedsRenewing(c, t) { log.Debugf("%v: have best certificate which does not need renewing, skipping target", t) return nil // continue } log.Debugf("%v: requesting certificate", t) err = r.requestCertificateForTarget(t) log.Errore(err, t, ": failed to request certificate") if err != nil { // Do not block satisfaction of other targets just because one fails; // collect errors and return them as one. merr = append(merr, &TargetSpecificError{ Target: t, Err: err, }) } return nil }) log.Debugf("done processing targets, reconciliation complete, %d errors occurred", len(merr)) if len(merr) != 0 { return merr } return nil } func (r *reconcile) getRequestAccount(tr *storage.TargetRequest) (*storage.Account, error) { if tr.Account != nil { return tr.Account, nil } // This will create the account if it doesn't exist. acct, err := r.getAccountByDirectoryURL(tr.Provider) if err != nil { return nil, err } return acct, nil } func (r *reconcile) requestCertificateForTarget(t *storage.Target) error { ensureConceivablySatisfiable(t) acct, err := r.getRequestAccount(&t.Request) if err != nil { return err } cl, err := r.getClientForAccount(acct) if err != nil { return err } apiAcct := acct.ToAPI() err = solver.AssistedRegistration(context.TODO(), cl, apiAcct, nil) if err != nil { return err } csr, err := r.createCSR(t) if err != nil { return err } orderTpl := acmeapi.Order{} for _, name := range t.Request.Names { orderTpl.Identifiers = append(orderTpl.Identifiers, acmeapi.Identifier{ Type: acmeapi.IdentifierTypeDNS, Value: name, }) } log.Debugf("%v: ordering certificate", t) order, err := solver.Order(context.TODO(), cl, apiAcct, &orderTpl, csr, r.targetToChallengeConfig(t)) if err != nil { return err } c, err := r.store.ImportCertificate(acct, order.URL) if err != nil { log.Errore(err, "could not import certificate") return err } err = r.downloadCertificateAdaptive(c) if err != nil { return err } return nil } func (r *reconcile) targetToChallengeConfig(t *storage.Target) *responder.ChallengeConfig { trc := &t.Request.Challenge hctx := &hooks.Context{ StateDir: r.store.Path(), Env: map[string]string{}, } for k, v := range trc.InheritedEnv { hctx.Env[k] = v } for k, v := range trc.Env { hctx.Env[k] = v } startHookFunc := func(challengeInfo interface{}) error { switch v := challengeInfo.(type) { case *responder.HTTPChallengeInfo: _, err := hooks.ChallengeHTTPStart(hctx, v.Hostname, t.Filename, v.Filename, v.Body) return err case *responder.DNSChallengeInfo: installed, err := hooks.ChallengeDNSStart(hctx, v.Hostname, t.Filename, v.Body) if err == nil && !installed { return fmt.Errorf("could not install DNS challenge, no hooks succeeded") } return err default: return nil } } stopHookFunc := func(challengeInfo interface{}) error { switch v := challengeInfo.(type) { case *responder.HTTPChallengeInfo: return hooks.ChallengeHTTPStop(hctx, v.Hostname, t.Filename, v.Filename, v.Body) case *responder.DNSChallengeInfo: uninstalled, err := hooks.ChallengeDNSStop(hctx, v.Hostname, t.Filename, v.Body) if err == nil && !uninstalled { return fmt.Errorf("could not uninstall DNS challenge, no hooks succeeded") } return err default: return nil } } httpSelfTest := true if trc.HTTPSelfTest != nil { httpSelfTest = *trc.HTTPSelfTest } return &responder.ChallengeConfig{ WebPaths: trc.WebrootPaths, HTTPPorts: trc.HTTPPorts, HTTPNoSelfTest: !httpSelfTest, StartHookFunc: startHookFunc, StopHookFunc: stopHookFunc, } } var ( oidTLSFeature = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24} mustStapleFeatureValue = []byte{0x30, 0x03, 0x02, 0x01, 0x05} ) func (r *reconcile) createCSR(t *storage.Target) ([]byte, error) { if len(t.Request.Names) == 0 { return nil, fmt.Errorf("cannot request a certificate with no names") } csr := &x509.CertificateRequest{ DNSNames: t.Request.Names, Subject: pkix.Name{ CommonName: t.Request.Names[0], }, } if t.Request.OCSPMustStaple { csr.ExtraExtensions = append(csr.ExtraExtensions, pkix.Extension{ Id: oidTLSFeature, Value: mustStapleFeatureValue, }) } pk, err := r.generateOrGetKey(&t.Request.Key) if err != nil { log.Errore(err, "could not generate key while generating CSR for", t) return nil, err } _, err = r.store.ImportKey(pk) if err != nil { log.Errore(err, "could not import freshly generated key while generating CSR for", t) return nil, err } csr.SignatureAlgorithm, err = signatureAlgorithmFromKey(pk) if err != nil { return nil, err } return x509.CreateCertificateRequest(rand.Reader, csr, pk) } func (r *reconcile) generateOrGetKey(trk *storage.TargetRequestKey) (crypto.PrivateKey, error) { if trk.ID != "" { k := r.store.KeyByID(strings.TrimSpace(strings.ToLower(trk.ID))) if k != nil { return k.PrivateKey, nil } log.Warnf("target requests specific key %q but it cannot be found, generating a new key", trk.ID) } return generateKey(trk) } func (r *reconcile) downloadCertificateAdaptive(c *storage.Certificate) error { log.Debugf("downloading certificate %v", c) if c.Account == nil { return fmt.Errorf("cannot download certificate because it is unknown which account requested it: %v", c) } cl, err := r.getClientForDirectoryURL(c.Account.DirectoryURL) if err != nil { return err } acctAPI := c.Account.ToAPI() if acctAPI.URL == "" { err = cl.LocateAccount(context.TODO(), acctAPI) if err != nil { return err } } order := &acmeapi.Order{} cert := &acmeapi.Certificate{} isCert, err := cl.LoadOrderOrCertificate(context.TODO(), c.URL, acctAPI, order, cert) if err != nil { return err } if !isCert { // It's an order URL, so we need to a) wait for the order to be complete, // and b) download the certificate via the URL given. // Wait for the order to be complete. waitLimit := time.Now().Add(10 * time.Minute) for !order.Status.IsFinal() { // How long should it take for an order to finish after finalization is // requested? Probably not long for the Let's Encrypt use case, but it's // not hard to imagine weird implementations where there's a long waiting // time (e.g. manual approval). We don't want to hang forever waiting, so // let's bail after a while in the expectation we'll try again later when // cron next invokes us. if time.Now().After(waitLimit) { err = fmt.Errorf("took more than 10 minutes to wait for an order (%q, status %q) to become final; giving up for now", order.URL, order.Status) err = util.NewPertError(true, err) return err } err = cl.WaitLoadOrder(context.TODO(), acctAPI, order) if err != nil { return err } } if order.Status != acmeapi.OrderValid { // Order is final not not valid, which means the server has reneged on an // order after finalisation. Not sure whether this can happen, but it // wouldn't surprise me if such implementations show up. As per ACME-SSS, // we should treat this as a 'permanent error' and delete the certificate. return fmt.Errorf("order became invalid after finalisation: %q (%q)", order.URL, order.Status) } // Download the certificate. cert = &acmeapi.Certificate{ URL: order.CertificateURL, } err = cl.LoadCertificate(context.TODO(), acctAPI, cert) if err != nil { return err } } // At this point we have the certificate in 'cert', and cert.URL is set. if len(cert.CertificateChain) == 0 { return fmt.Errorf("nil certificate?") } c.Certificates = cert.CertificateChain c.Cached = true err = r.store.SaveCertificate(c) if err != nil { log.Errore(err, "failed to save certificate after retrieval", c) return err } return nil } // todo change solver.Order to not wait for finalisation acmetool-0.2.2/storageops/revoke.go000066400000000000000000000016341435652113300173350ustar00rootroot00000000000000package storageops import ( "fmt" "github.com/hlandau/acmetool/storage" "github.com/hlandau/acmetool/util" ) func RevokeByCertificateOrKeyID(s storage.Store, id string) error { c := s.CertificateByID(id) if c == nil { return revokeByKeyID(s, id) } if c.Revoked { log.Warnf("%v already revoked", c) return nil } c.RevocationDesired = true return s.SaveCertificate(c) } func revokeByKeyID(s storage.Store, keyID string) error { k := s.KeyByID(keyID) if k == nil { return fmt.Errorf("cannot find certificate or key with given ID: %q", keyID) } var merr util.MultiError s.VisitCertificates(func(c *storage.Certificate) error { if c.Key != k { return nil // continue } err := RevokeByCertificateOrKeyID(s, c.ID()) if err != nil { merr = append(merr, fmt.Errorf("failed to mark %v for revocation: %v", c, err)) } return nil }) if len(merr) > 0 { return merr } return nil } acmetool-0.2.2/storageops/util.go000066400000000000000000000043201435652113300170120ustar00rootroot00000000000000package storageops import ( "crypto" "crypto/ecdsa" "crypto/rand" "crypto/rsa" "crypto/x509" "fmt" "github.com/hlandau/acmetool/storage" "time" ) type targetSorter []*storage.Target func (ts targetSorter) Len() int { return len(ts) } func (ts targetSorter) Swap(i, j int) { ts[i], ts[j] = ts[j], ts[i] } func (ts targetSorter) Less(i, j int) bool { return targetGt(ts[j], ts[i]) } func targetGt(a *storage.Target, b *storage.Target) bool { if a == nil && b == nil { return false // equal } else if b == nil { return true // a > nil } else if a == nil { return false // nil < a } if a.Priority > b.Priority { return true } else if a.Priority < b.Priority { return false } return len(a.Satisfy.Names) > len(b.Satisfy.Names) } // This is 30 days, which is a bit high, but Let's Encrypt sends expiration // emails at 19 days, so... const defaultRenewalMarginDays = 30 func renewTime(notBefore, notAfter time.Time, t *storage.Target) time.Time { renewalMarginDays := defaultRenewalMarginDays if t.Satisfy.Margin > 0 { renewalMarginDays = t.Satisfy.Margin } renewalMargin := time.Duration(renewalMarginDays) * 24 * time.Hour validityPeriod := notAfter.Sub(notBefore) renewSpan := validityPeriod / 3 if renewSpan > renewalMargin { renewSpan = renewalMargin } return notAfter.Add(-renewSpan) } func signatureAlgorithmFromKey(pk crypto.PrivateKey) (x509.SignatureAlgorithm, error) { switch pk.(type) { case *rsa.PrivateKey: return x509.SHA256WithRSA, nil case *ecdsa.PrivateKey: return x509.ECDSAWithSHA256, nil default: return x509.UnknownSignatureAlgorithm, fmt.Errorf("unknown key type %T", pk) } } func generateKey(trk *storage.TargetRequestKey) (pk crypto.PrivateKey, err error) { switch trk.Type { default: fallthrough // ... case "", "rsa": pk, err = rsa.GenerateKey(rand.Reader, clampRSAKeySize(trk.RSASize)) case "ecdsa": pk, err = ecdsa.GenerateKey(getECDSACurve(trk.ECDSACurve), rand.Reader) } return } // Error associated with a specific target, for clarity of error messages. type TargetSpecificError struct { Target *storage.Target Err error } func (tse *TargetSpecificError) Error() string { return fmt.Sprintf("error satisfying %v: %v", tse.Target, tse.Err) } acmetool-0.2.2/util/000077500000000000000000000000001435652113300142765ustar00rootroot00000000000000acmetool-0.2.2/util/multierror.go000066400000000000000000000031021435652113300170250ustar00rootroot00000000000000package util import "fmt" // Used to return multiple errors, for example when several targets cannot be // reconciled. This prevents one failing target from blocking others. type MultiError []error func (merr MultiError) Error() string { s := "" for _, e := range merr { if s != "" { s += "; \n" } s += e.Error() } return "the following errors occurred:\n" + s } // Used to return an error that wraps another error. type WrapError struct { Msg string Sub error } // Create a new error that wraps another error. func NewWrapError(sub error, msg string, args ...interface{}) *WrapError { return &WrapError{ Msg: fmt.Sprintf(msg, args...), Sub: sub, } } func (werr *WrapError) Error() string { return fmt.Sprintf("%s [due to inner error: %v]", werr.Msg, werr.Sub) } // PertError knows whether it's temporary or not. type PertError struct { error temporary bool } // Create an error that knows whether it's temporary or not. func NewPertError(isTemporary bool, sub error) error { return &PertError{sub, isTemporary} } // Returns true iff the error is temporary. Compatible with the Temporary // method of the "net" package's OpError type. func (e *PertError) Temporary() bool { return e.temporary } type tmp interface { Temporary() bool } // Returns whether an error is temporary or not. An error is temporary if it // implements the interface { Temporary() bool } and that method returns true. // Errors which don't implement this interface aren't temporary. func IsTemporary(err error) bool { x, ok := err.(tmp) if !ok { return false } return x.Temporary() }