├── .drone.yml ├── .drone └── script ├── .gitignore ├── .travis.yml ├── .travis ├── after_success ├── before_cache ├── before_install ├── boulder.patch ├── check-copr-token ├── crosscompile ├── dist-readme.md ├── make_debian_env ├── make_rpm_spec └── script ├── Makefile ├── README.md ├── _doc ├── FAQ.md ├── NOROOT.md ├── PACKAGING-PATHS.md ├── PROGRAMMATIC-DOWNLOADING.md ├── SCHEMA.md ├── WSCONFIG.md ├── contrib │ ├── APKBUILD │ ├── dns.hook │ ├── ovh.hook │ ├── perm.example │ ├── response-file.yaml │ └── tinydns.hook └── guide │ ├── ENTER │ ├── Makefile │ ├── acmetool.8.adoc │ ├── img │ ├── acmetool-logo-black.png │ └── acmetool-logo.png │ ├── include-man.xsl │ ├── index.adoc │ ├── style.css │ └── to-xhtml.xsl ├── cli ├── doc.go ├── main.go ├── main_ig_test.go ├── quickstart-linux.go ├── quickstart-nlinux.go └── quickstart.go ├── cmd └── acmetool │ └── main.go ├── fdb ├── fdb.go ├── fdb_test.go ├── mkdir.go ├── parseperm.go ├── parseperm_test.go ├── tempsymlink.go └── util.go ├── hooks ├── hooks.go ├── hooks_test.go ├── install.go └── os.go ├── interaction ├── auto.go ├── dialog.go ├── interaction.go ├── responder.go └── stdio.go ├── main.go ├── redirector ├── redirector.go └── redirector_test.go ├── responder ├── dns.go ├── http.go ├── reshttp │ └── reshttp.go └── responder.go ├── solver ├── order.go ├── preference.go └── register.go ├── storage ├── abs.go ├── config.go ├── neuter.go ├── storage-fdb.go ├── types.go ├── util.go └── util_test.go ├── storageops ├── config.go ├── cull.go ├── keysize.go ├── reconcile-util.go ├── reconcile.go ├── revoke.go └── util.go └── util └── multierror.go /.drone.yml: -------------------------------------------------------------------------------- 1 | pipeline: 2 | #doc: 3 | # image: asciidoctor/docker-asciidoctor 4 | # secrets: [ rsync_password ] 5 | # commands: 6 | # - apk add --no-cache libxslt docbook-xsl rsync 7 | # - "(cd _doc/guide && make && make deploy; )" 8 | 9 | test: 10 | image: golang:latest 11 | commands: 12 | - export GOPATH=/drone 13 | - export CGO_ENABLED=0 14 | - export PEBBLE_VA_ALWAYS_VALID=1 15 | - .drone/script 16 | -------------------------------------------------------------------------------- /.drone/script: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | ACMEAPI="$GOPATH/src/gopkg.in/hlandau/acmeapi.v2" 4 | 5 | go get ./... github.com/letsencrypt/pebble/cmd/pebble 6 | go install ./... github.com/letsencrypt/pebble/cmd/pebble 7 | 8 | "$ACMEAPI/.drone/with-pebble" go test -tags integration ./... 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | acmetool 2 | _doc/guide/out 3 | _doc/guide/tmp 4 | _doc/guide/docbook-xsl 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.x 4 | 5 | addons: 6 | hosts: 7 | - dom1.acmetool-test.devever.net 8 | - dom2.acmetool-test.devever.net 9 | - boulder 10 | - boulder-mysql 11 | - boulder-rabbitmq 12 | - publisher.boulder 13 | - ra.boulder 14 | - ca.boulder 15 | - va.boulder 16 | - sa.boulder 17 | apt: 18 | packages: 19 | - lsb-release 20 | - gcc 21 | - libssl-dev 22 | - libffi-dev 23 | - ca-certificates 24 | - rsyslog 25 | - libcap-dev 26 | - gcc-multilib 27 | - libc6-dev-i386 28 | - libcap-dev:i386 29 | - debhelper 30 | - devscripts 31 | - realpath 32 | - dput 33 | - gnupg 34 | - lintian 35 | - ncftp 36 | - rpm 37 | - fakeroot 38 | - jq 39 | mariadb: "10.1" 40 | 41 | sudo: false 42 | 43 | services: 44 | - rabbitmq 45 | 46 | before_install: 47 | - source ./.travis/before_install 48 | install: 49 | - go get -v -t ./... 50 | script: 51 | - source ./.travis/script 52 | after_success: 53 | - source ./.travis/after_success 54 | before_cache: 55 | - source ./.travis/before_cache 56 | 57 | env: 58 | global: 59 | # GITHUB_TOKEN for automatic releases 60 | - secure: "OA/Trkip03Ee3145oxrbHv3oM7dFpoX2h3y65CzyecQ2v8X4/l5pOwyMiJei5i20zm+QrK0iP9JttbDR9hY71d1DoxMXRGW0YHGFEutUQLZFpkPHLv7klSq8RjRGzpusSaxAtpEF27ZS+7NU42awYynWDzVsK4cglH9CimrS1glr2lKA5bXucqFROlqbI5GzXEdZJXhdGlKZWQWo83Hwe8JTwvIN8xRn5xZ33yxeMDl6SgQ3UhEs6zmsAQphGZ1pNcQaPjtyFtwEBeVQCsYW0loo8gUyjsfippSfGciu+g1J6sGVBj3HxGWWKmMa7lMaCEpL5CUKVcT2WH+LefYLHX5ZkyK8EQwt8QzrO1+X268+SulbWu2rf9SFQlLgoazIa8N8qfd8wVlo6Z3Jiy5YNHhHImMRYtgh5q3lo/5COUrPSgPBx4+VdciuMLxVYw96lTrPcMd4/J2gVYAf7f3AXeOpi/zF0T1WyD/64X0xKquYrbBzGbrEH4EM68vXQBiK5Q2sAEwhMUZNhgAqlKRzpqQoe/Cdx/Stm6cuFt6r87TbJfYiHGCZehveASWwH/Nk1HogOXjv/iVikxOqUiuqy0Q7GLPuFdcAGuLjqxS3wmdN1pBEGVqtSKA/3xrJptKlniz6+1hWr+H1ttTRTgok6ViX/POf+CW11VsfVo7qjyc=" 61 | # PPA_ENCRYPTION_ID 62 | - 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=" 63 | # PPA_ENCRYPTION_PASS 64 | - 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=" 65 | # COPR_LOGIN_TOKEN expires=2018-08-11 66 | # format: $login:$token 67 | - 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=" 68 | 69 | branches: 70 | only: 71 | - master 72 | - dev 73 | - /^test-.*$/ 74 | - /^v[0-9].*$/ 75 | cache: 76 | directories: 77 | - $HOME/tcache 78 | 79 | notifications: 80 | webhooks: 81 | urls: 82 | - "https://scalar.vector.im/api/neb/services/hooks/dHJhdmlzLWNpLyU0MDE0ODgzMyUzQW1hdHJpeC5vcmcvJTIxSWhNWURnRHl4aXVjUlpMSW1yJTNBbWF0cml4Lm9yZw" 83 | on_success: change 84 | on_failure: always 85 | on_start: never 86 | -------------------------------------------------------------------------------- /.travis/after_success: -------------------------------------------------------------------------------- 1 | #!bash 2 | 3 | # Only upload version tags. 4 | if ! [[ "$TRAVIS_TAG" =~ ^v[0-9] ]]; then 5 | echo Skipping release upload because this build is not for a release tag. 6 | return 0 7 | fi 8 | 9 | [ -n "$GITHUB_TOKEN" ] || { echo "Don't appear to have GitHub token, cannot continue."; return 0; } 10 | [ -e "/tmp/crosscompiled" ] || { echo "Not crosscompiled?"; return 1; } 11 | 12 | # Make archives. 13 | echo Archiving releases... 14 | ACME_DIR="$(pwd)" 15 | cd "$GOPATH/releasing/idist" 16 | for x in *; do 17 | echo "$x" 18 | cp "$GOPATH/src/github.com/$TRAVIS_REPO_SLUG/.travis/dist-readme.md" "$x"/README.md || \ 19 | cp "$GOPATH/src/github.com/$TRAVIS_REPO_SLUG/README.md" "$x"/ || true 20 | tar -zcf "../dist/$(basename "$x").tar.gz" "$x" 21 | done 22 | 23 | # Must be in the right directory when calling ghr. 24 | cd "$ACME_DIR" 25 | 26 | echo Uploading releases... 27 | PPA_NAME=rhea COPR_PROJECT_ID=5993 28 | grep -F '[draft]' /tmp/commit-message && \ 29 | GHR_OPTIONS="--draft" PPA_NAME=testppa COPR_PROJECT_ID=6071 30 | TRAVIS_REPO_OWNER="$(echo "$TRAVIS_REPO_SLUG" | sed 's#/.*##g')" 31 | travis_retry ghr $GHR_OPTIONS -u "$TRAVIS_REPO_OWNER" "$TRAVIS_TAG" "$GOPATH/releasing/dist/" 32 | 33 | # Prepare Ubuntu PPA signing key. 34 | echo Preparing Ubuntu PPA signing key... 35 | wget -qO ppa-private.asc.enc "https://www.devever.net/~hl/f/ppa-private-${PPA_ENCRYPTION_ID}.asc.enc" 36 | export PPA_ENCRYPTION_ID= 37 | openssl enc -d -aes-128-cbc -md sha256 -salt -pass env:PPA_ENCRYPTION_PASS -in "ppa-private.asc.enc" -out "ppa-private.asc" 38 | export PPA_ENCRYPTION_PASS= 39 | shred -u ppa-private.asc.enc 40 | export GNUPGHOME="$ACME_DIR/.travis/.gnupg" 41 | mkdir -p "$GNUPGHOME" 42 | gpg --batch --import < ppa-private.asc 43 | shred -u ppa-private.asc 44 | cat < "$HOME/.devscripts" 50 | DEBSIGN_KEYID="Hugo Landau (2017 PPA Signing) " 51 | END 52 | 53 | UBUNTU_RELEASES="precise trusty xenial yakkety zesty vivid" 54 | for distro_name in $UBUNTU_RELEASES; do 55 | echo Creating Debian source environment for ${distro_name}... 56 | $GOPATH/src/github.com/$TRAVIS_REPO_SLUG/.travis/make_debian_env "$GOPATH/releasing/dbuilds/$distro_name" "$GOPATH/releasing/dist/" "$TRAVIS_TAG" "$distro_name" 57 | 58 | echo Creating Debian source archive for ${distro_name}... 59 | cd $GOPATH/releasing/dbuilds/$distro_name/acmetool_*[0-9] 60 | debuild -S 61 | done 62 | 63 | echo Deleting keys... 64 | find "$GNUPGHOME" -type f -exec shred -u '{}' ';' 65 | rm -rf "$GNUPGHOME" 66 | 67 | echo Uploading Debian source archives... 68 | cd "$GOPATH/releasing/dbuilds" 69 | ( 70 | echo 'open ppa.launchpad.net' 71 | echo 'set passive on' 72 | echo "cd ~hlandau/$PPA_NAME" 73 | for f in ./*/acmetool_*.dsc ./*/acmetool*.diff.gz ./*/acmetool_*_source.changes ./xenial/acmetool_*.orig.tar.gz; do 74 | echo "put $f" 75 | done 76 | echo 'quit' 77 | ) | ncftp 78 | 79 | # RPM. 80 | cd "$ACME_DIR/.travis" 81 | mkdir -p "$HOME/rpmbuild/SPECS" "$HOME/rpmbuild/SOURCES" 82 | RPMS="acmetool acmetool-nocgo" 83 | for x in $RPMS; do 84 | $GOPATH/src/github.com/$TRAVIS_REPO_SLUG/.travis/make_rpm_spec "$TRAVIS_TAG" "$x" > "$HOME/rpmbuild/SPECS/${x}.spec" 85 | done 86 | ln $GOPATH/releasing/dist/acmetool_*.orig.tar.gz $HOME/rpmbuild/SOURCES/ 87 | echo travis_fold:start:build-srpm 88 | for x in $RPMS; do 89 | rpmbuild -bs "$HOME/rpmbuild/SPECS/${x}.spec" 90 | done 91 | echo travis_fold:end:build-srpm 92 | 93 | COPR_CHROOTS="$(curl "https://copr.fedorainfracloud.org/api_2/projects/$COPR_PROJECT_ID/chroots" | jq '.chroots|map(.chroot.name)')" 94 | COPR_CHROOTS_CGO="$(echo "$COPR_CHROOTS" | jq 'map(select(contains("86")))')" 95 | 96 | for srpm in $HOME/rpmbuild/SRPMS/acmetool-*.rpm; do 97 | if [[ $srpm != *nocgo* ]]; then 98 | cat < /tmp/rpm-metadata 99 | { 100 | "project_id": $COPR_PROJECT_ID, 101 | "chroots": $COPR_CHROOTS_CGO 102 | } 103 | END 104 | else 105 | cat < /tmp/rpm-metadata 106 | { 107 | "project_id": $COPR_PROJECT_ID, 108 | "chroots": $COPR_CHROOTS 109 | } 110 | END 111 | fi 112 | 113 | echo Uploading $srpm 114 | curl -u "$COPR_LOGIN_TOKEN" \ 115 | -F 'metadata=&1 | grep -qvE '(\.(md|txt)$)|_doc/' || { 4 | echo Documentation-only update. Skipping travis proper. 5 | exit 6 | } 7 | 8 | previous_build_failed() { 9 | let TRAVIS_PREVIOUS_BUILD_NUMBER="$TRAVIS_BUILD_NUMBER - 1" 10 | [ "$TRAVIS_BUILD_NUMBER" == "1" ] || \ 11 | 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" | \ 12 | python3 -c 'import sys,json; k=json.load(sys.stdin); print(k); sys.exit(int(k["builds"][0]["state"] == "passed"))' 13 | } 14 | 15 | if [ -n "$TRAVIS_TAG" ]; then 16 | git show "$TRAVIS_TAG" 2>&1 | sed -n '/^diff --git/q;p' > /tmp/commit-message 17 | else 18 | git log --format=%B --no-merges -n 1 &> /tmp/commit-message 19 | fi 20 | 21 | ! grep -qF '[ci fast]' /tmp/commit-message || \ 22 | [ -n "$TRAVIS_TAG" ] || \ 23 | previous_build_failed || { 24 | echo Travis not needed for this update. 25 | exit 26 | } 27 | -------------------------------------------------------------------------------- /.travis/boulder.patch: -------------------------------------------------------------------------------- 1 | diff --git a/cmd/shell.go b/cmd/shell.go 2 | index 353d5e1f..8c2a432b 100644 3 | --- a/cmd/shell.go 4 | +++ b/cmd/shell.go 5 | @@ -24,7 +24,6 @@ import ( 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | - "log/syslog" 10 | "net/http" 11 | "net/http/pprof" 12 | "os" 13 | @@ -130,19 +129,7 @@ func StatsAndLogging(logConf SyslogConfig, addr string) (metrics.Scope, blog.Log 14 | } 15 | 16 | func NewLogger(logConf SyslogConfig) blog.Logger { 17 | - tag := path.Base(os.Args[0]) 18 | - syslogger, err := syslog.Dial( 19 | - "", 20 | - "", 21 | - syslog.LOG_INFO, // default, not actually used 22 | - tag) 23 | - FailOnError(err, "Could not connect to Syslog") 24 | - syslogLevel := int(syslog.LOG_INFO) 25 | - if logConf.SyslogLevel != 0 { 26 | - syslogLevel = logConf.SyslogLevel 27 | - } 28 | - logger, err := blog.New(syslogger, logConf.StdoutLevel, syslogLevel) 29 | - FailOnError(err, "Could not connect to Syslog") 30 | + logger := blog.NewMock() 31 | 32 | _ = blog.Set(logger) 33 | cfsslLog.SetLogger(cfsslLogger{logger}) 34 | diff --git a/start.py b/start.py 35 | index 1c1a90bb..b5a0955c 100755 36 | --- a/start.py 37 | +++ b/start.py 38 | @@ -19,6 +19,7 @@ import startservers 39 | if not startservers.start(race_detection=False): 40 | sys.exit(1) 41 | try: 42 | + open('/tmp/boulder-has-started','wb').write('x') 43 | os.wait() 44 | 45 | # If we reach here, a child died early. Log what died: 46 | diff --git a/test/config-next/va.json b/test/config-next/va.json 47 | index f90e1856..8721b69f 100644 48 | --- a/test/config-next/va.json 49 | +++ b/test/config-next/va.json 50 | @@ -3,7 +3,7 @@ 51 | "userAgent": "boulder", 52 | "debugAddr": ":8004", 53 | "portConfig": { 54 | - "httpPort": 5002, 55 | + "httpPort": 80, 56 | "httpsPort": 5001, 57 | "tlsPort": 5001 58 | }, 59 | diff --git a/test/config/ca.json b/test/config/ca.json 60 | index d5f78c2c..66cfa251 100644 61 | --- a/test/config/ca.json 62 | +++ b/test/config/ca.json 63 | @@ -27,11 +27,11 @@ 64 | ] 65 | }, 66 | "Issuers": [{ 67 | - "ConfigFile": "test/test-ca.key-pkcs11.json", 68 | + "File": "test/test-ca.key", 69 | "CertFile": "test/test-ca2.pem", 70 | "NumSessions": 2 71 | }, { 72 | - "ConfigFile": "test/test-ca.key-pkcs11.json", 73 | + "File": "test/test-ca.key", 74 | "CertFile": "test/test-ca.pem", 75 | "NumSessions": 2 76 | }], 77 | diff --git a/test/config/va.json b/test/config/va.json 78 | index 91a6727c..2921c453 100644 79 | --- a/test/config/va.json 80 | +++ b/test/config/va.json 81 | @@ -3,7 +3,7 @@ 82 | "userAgent": "boulder", 83 | "debugAddr": ":8004", 84 | "portConfig": { 85 | - "httpPort": 5002, 86 | + "httpPort": 80, 87 | "httpsPort": 5001, 88 | "tlsPort": 5001 89 | }, 90 | diff --git a/test/hostname-policy.json b/test/hostname-policy.json 91 | index 5eaad17e..503badc1 100644 92 | --- a/test/hostname-policy.json 93 | +++ b/test/hostname-policy.json 94 | @@ -5,12 +5,6 @@ 95 | ], 96 | "Blacklist": [ 97 | "in-addr.arpa", 98 | - "example", 99 | - "example.net", 100 | - "example.org", 101 | - "invalid", 102 | - "local", 103 | - "localhost", 104 | - "test" 105 | + "invalid" 106 | ] 107 | } 108 | diff --git a/test/rate-limit-policies.yml b/test/rate-limit-policies.yml 109 | index 157d9d2b..6e8070b6 100644 110 | --- a/test/rate-limit-policies.yml 111 | +++ b/test/rate-limit-policies.yml 112 | @@ -4,7 +4,7 @@ totalCertificates: 113 | threshold: 100000 114 | certificatesPerName: 115 | window: 2160h 116 | - threshold: 2 117 | + threshold: 10000 118 | overrides: 119 | ratelimit.me: 1 120 | lim.it: 0 121 | @@ -41,7 +41,7 @@ pendingOrdersPerAccount: 122 | threshold: 3 123 | certificatesPerFQDNSet: 124 | window: 24h 125 | - threshold: 5 126 | + threshold: 5000 127 | overrides: 128 | le.wtf: 10000 129 | le1.wtf: 10000 130 | -------------------------------------------------------------------------------- /.travis/check-copr-token: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | TRAVIS_FILE="$(dirname "$0")/../.travis.yml" 4 | [ -e "$TRAVIS_FILE" ] || exit 1 5 | 6 | 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')" 7 | 8 | EXPIRY_S="$(date -d "$EXPIRY" +%s)" 9 | NOW_S="$(date +%s)" 10 | 11 | if [ "$NOW_S" -ge "$EXPIRY_S" ]; then 12 | echo >&2 "Outdated copr token. Renew it and update expiry date in .travis.yml." 13 | exit 1 14 | fi 15 | -------------------------------------------------------------------------------- /.travis/crosscompile: -------------------------------------------------------------------------------- 1 | #!bash 2 | # Test cross-compilation. The binaries produced are also used for release 3 | # upload in after_success if this is a release tag. 4 | 5 | [ -e "/tmp/crosscompiled" ] && return 6 | touch /tmp/crosscompiled 7 | 8 | echo travis_fold:start:crosscompile 9 | echo Cross-compiling releases... 10 | mkdir -p "$GOPATH/releasing/idist" "$GOPATH/releasing/dist" 11 | 12 | # Assume that x86 machines don't necessarily have SSE2. Whereas for amd64, 13 | # require SSE2. 14 | 15 | REPO=github.com/$TRAVIS_REPO_SLUG 16 | BINARIES=$REPO/cmd/acmetool 17 | export BUILDNAME="by travis" 18 | BUILDINFO="$($GOPATH/src/github.com/hlandau/buildinfo/gen $BINARIES)" 19 | 20 | # cgo crosscompile 21 | export GOARM=5 22 | gox -ldflags "$BUILDINFO" -cgo -osarch 'linux/amd64' -output "$GOPATH/releasing/idist/acmetool-$TRAVIS_TAG-{{.OS}}_{{.Arch}}_cgo/bin/{{.Dir}}" $BINARIES 23 | RESULT1=$? 24 | GO386=387 gox -ldflags "$BUILDINFO" -cgo -osarch 'linux/386' -output "$GOPATH/releasing/idist/acmetool-$TRAVIS_TAG-{{.OS}}_{{.Arch}}_cgo/bin/{{.Dir}}" $BINARIES 25 | RESULT2=$? 26 | 27 | # non-cgo crosscompile 28 | 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 29 | RESULT3=$? 30 | 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 31 | RESULT4=$? 32 | 33 | echo travis_fold:end:crosscompile 34 | 35 | # Defer exiting to get as much error output as possible upfront. 36 | echo "cgo crosscompile (amd64) exited with code $RESULT1" 37 | echo "cgo crosscompile (386) exited with code $RESULT2" 38 | echo "non-cgo crosscompile (amd64) exited with code $RESULT3" 39 | echo "non-cgo crosscompile (386) exited with code $RESULT4" 40 | 41 | if [ "$RESULT1" != "0" ]; then 42 | exit $RESULT1 43 | fi 44 | if [ "$RESULT2" != "0" ]; then 45 | exit $RESULT2 46 | fi 47 | if [ "$RESULT3" != "0" ]; then 48 | exit $RESULT3 49 | fi 50 | if [ "$RESULT4" != "0" ]; then 51 | exit $RESULT4 52 | fi 53 | 54 | # Generate man page. 55 | "$GOPATH/releasing/idist/acmetool-$TRAVIS_TAG-linux_amd64_cgo/bin/acmetool" --help-man > acmetool.8 || echo Failed to generate man page 56 | 57 | for x in $GOPATH/releasing/idist/*; do 58 | mkdir -p "$x/doc" 59 | cp -a acmetool.8 "$x/doc/" 60 | done 61 | -------------------------------------------------------------------------------- /.travis/dist-readme.md: -------------------------------------------------------------------------------- 1 | # ACME Client Utilities 2 | 3 | More information: 4 | 5 | ## Installation and Usage 6 | 7 | You have downloaded a binary release of acmetool. Here are some simple 8 | installation instructions: 9 | 10 | $ sudo cp -a bin/acmetool /usr/local/bin/ 11 | 12 | # Run the quickstart wizard. Sets up account, cronjob, etc. 13 | $ sudo acmetool quickstart 14 | 15 | # Request the hostnames you want: 16 | $ sudo acmetool want example.com www.example.com 17 | 18 | # Now you have certificates: 19 | $ ls /var/lib/acme/live/example.com/ 20 | 21 | For more information on using acmetool, please see the full README at 22 | https://github.com/hlandau/acme 23 | 24 | ## Licence 25 | 26 | © 2015 Hugo Landau MIT License 27 | 28 | File issues at . 29 | -------------------------------------------------------------------------------- /.travis/make_debian_env: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to generate files for debbuild causing it to generate a 'source package' 3 | # to be uploaded to launchpad to generate PPA 'binary packages'. These source packages 4 | # are just binaries with a build script that outputs them as a 'binary package'. 5 | # So sue me. 6 | 7 | if [ -z "$1" -o -z "$2" -o -z "$3" -o -z "$4" ]; then 8 | echo Usage: "$0" "" "" "" "" 9 | echo "Create a debian build environment suitable for running debuild [-S] in." 10 | exit 1 11 | fi 12 | 13 | OUTDIR="$1" 14 | ARCHIVEDIR="$2" 15 | VERSION="$(echo "$3" | sed 's/^v//g')" 16 | ORIGDIR="$(dirname "$0")" 17 | DISTRO="$4" 18 | 19 | mkdir -p "$OUTDIR" "$ARCHIVEDIR" 20 | OUTDIR="$(realpath "$OUTDIR")" 21 | ARCHIVEDIR="$(realpath "$ARCHIVEDIR")" 22 | ORIGDIR="$(realpath "$ORIGDIR")" 23 | REVISION="1${DISTRO}1" 24 | 25 | DATE_R="$(date -R)" 26 | # The non-cgo builds here are used by the RPM builds, which also reply on the 27 | # .orig.tar.gz file built here. 28 | ARCHS="386_cgo amd64_cgo 386 amd64 arm arm64 ppc64 ppc64le" 29 | 30 | mkdir -p "$ARCHIVEDIR" 31 | for ARCH in $ARCHS; do 32 | 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" 33 | done 34 | 35 | mkdir -p "$OUTDIR/acmetool_$VERSION" 36 | cd "$OUTDIR/acmetool_$VERSION" 37 | for ARCH in $ARCHS; do 38 | tar xvf "$ARCHIVEDIR/acmetool-v$VERSION-linux_$ARCH.tar.gz" 39 | done 40 | 41 | cd .. 42 | if [ ! -e "$ARCHIVEDIR/acmetool_$VERSION.orig.tar.gz" ]; then 43 | tar zcvf "$ARCHIVEDIR/acmetool_$VERSION.orig.tar.gz" "acmetool_$VERSION" 44 | fi 45 | [ -e "./acmetool_$VERSION.orig.tar.gz" ] || ln "$ARCHIVEDIR/acmetool_$VERSION.orig.tar.gz" 46 | cd "$ORIGDIR" 47 | 48 | UNPACKDIR="$OUTDIR/acmetool_$VERSION" 49 | DEBIANDIR="$UNPACKDIR/debian" 50 | mkdir -p "$DEBIANDIR/source" 51 | 52 | ### debian cruft 53 | ############################################################# 54 | echo 9 > "$DEBIANDIR/compat" 55 | echo 1.0 > "$DEBIANDIR/source/format" 56 | 57 | cat < "$DEBIANDIR/changelog" 58 | acmetool ($VERSION-$REVISION) $DISTRO; urgency=medium 59 | 60 | * Changelog information not maintained in Debian packaging. 61 | See 62 | 63 | -- Hugo Landau $DATE_R 64 | END 65 | 66 | cat <<'END' > "$DEBIANDIR/control" 67 | Source: acmetool 68 | Maintainer: Hugo Landau 69 | Section: utils 70 | Priority: optional 71 | Standards-Version: 3.9.6 72 | Build-Depends: debhelper (>= 9), wget, ca-certificates, curl, libcap-dev 73 | 74 | Package: acmetool 75 | Architecture: amd64 i386 armhf arm64 ppc64el 76 | Depends: ${misc:Depends}, ${shlibs:Depends} 77 | Description: command line tool for automatically acquiring certificates 78 | acmetool is an easy-to-use command line tool for automatically acquiring 79 | certificates from ACME servers (such as Let's Encrypt). Designed to flexibly 80 | integrate into your webserver setup to enable automatic verification. Unlike 81 | the official Let's Encrypt client, this doesn't modify your web server 82 | configuration. 83 | . 84 | You can perform verifications using port 80 or 443 (if you don't yet have a 85 | server running on one of them); via webroot; by configuring your webserver to 86 | proxy requests for /.well-known/acme-challenge/ to a special port (402) which 87 | acmetool can listen on. 88 | . 89 | acmetool is intended to be "magic-free". All of acmetool's state is stored in 90 | a simple, comprehensible directory of flat files. 91 | . 92 | acmetool is intended to work like "make". The state directory expresses target 93 | domain names, and whenever acmetool is invoked, it ensures that valid 94 | certificates are available to meet those names. Certificates which will expire 95 | soon are renewed. acmetool is thus idempotent and minimises the use of state. 96 | END 97 | 98 | cat <<'END' > "$DEBIANDIR/copyright" 99 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 100 | Upstream-Name: acmetool 101 | Upstream-Contact: Hugo Landau 102 | Source: https://github.com/hlandau/acme 103 | 104 | Files: * 105 | Copyright: 2015, Hugo Landau 106 | License: MIT 107 | Copyright © 2015 Hugo Landau 108 | . 109 | Permission is hereby granted, free of charge, to any person obtaining a copy of 110 | this software and associated documentation files (the "Software"), to deal in 111 | the Software without restriction, including without limitation the rights to 112 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 113 | of the Software, and to permit persons to whom the Software is furnished to do 114 | so, subject to the following conditions: 115 | . 116 | The above copyright notice and this permission notice shall be included in all 117 | copies or substantial portions of the Software. 118 | . 119 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 120 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 121 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 122 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 123 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 124 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 125 | SOFTWARE. 126 | 127 | Files: debian/* 128 | Copyright: 2015, Christian Pointner 129 | License: GPL-3+ 130 | This program is free software: you can redistribute it and/or modify 131 | it under the terms of the GNU General Public License as published by 132 | the Free Software Foundation, either version 3 of the License, or 133 | (at your option) any later version. 134 | . 135 | This program is distributed in the hope that it will be useful, 136 | but WITHOUT ANY WARRANTY; without even the implied warranty of 137 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 138 | GNU General Public License for more details. 139 | . 140 | You should have received a copy of the GNU General Public License 141 | along with this program. If not, see '/usr/share/common-licenses/GPL-3'. 142 | END 143 | 144 | ### debian build script 145 | ############################################################# 146 | cat <<'END' > "$DEBIANDIR/rules" 147 | #!/usr/bin/make -f 148 | %: 149 | dh $@ 150 | END 151 | 152 | ### debian miscellaneous files 153 | ############################################################# 154 | cat <<'END' > "$DEBIANDIR/acmetool.lintian-overrides" 155 | acmetool: embedded-library usr/bin/acmetool: libyaml 156 | acmetool: new-package-should-close-itp-bug 157 | END 158 | 159 | cat <<'END' > "$DEBIANDIR/acmetool.dirs" 160 | /usr/lib/acme 161 | /var/lib/acme 162 | END 163 | 164 | cat <<'END' > "$DEBIANDIR/acmetool.postrm" 165 | #!/bin/sh 166 | # postrm script for acmetool 167 | 168 | set -e 169 | 170 | if [ "$1" = "purge" ]; then 171 | # Remove cron job. 172 | if [ -f "/etc/cron.d/acmetool" ]; then 173 | rm -f /etc/cron.d/acmetool 174 | if [ -x "$(which invoke-rc.d 2>/dev/null)" ]; then 175 | invoke-rc.d cron reload || true 176 | else 177 | /etc/init.d/cron reload || true 178 | fi 179 | fi 180 | 181 | # Remove managed hooks. 182 | if [ -d "/usr/lib/acme/hooks" ]; then 183 | for hook in /usr/lib/acme/hooks/*; do 184 | grep -q '#!acmetool-managed!#' "$hook" && rm -f "$hook" || true 185 | done 186 | rmdir --ignore-fail-on-non-empty /usr/lib/acme/hooks || true 187 | fi 188 | 189 | # Warn about non-removed data. 190 | rmdir --ignore-fail-on-non-empty "/var/lib/acme" || true 191 | if [ -d "/var/lib/acme" ]; then 192 | echo '╔══════ acmetool purge ══════════════════════════════════════════════╗' 193 | echo '║ The acmetool state directory will be kept. If you really want to ║' 194 | echo '║ delete all your certificates and configurations you need to remove ║' 195 | echo '║ it manually using the following command: ║' 196 | echo '║ rm -rf /var/lib/acme ║' 197 | echo '╚════════════════════════════════════════════════════════════════════╝' 198 | fi 199 | fi 200 | 201 | #DEBHELPER# 202 | 203 | exit 0 204 | END 205 | 206 | ### "source" build file 207 | ############################################################# 208 | cat <<'END' > "$UNPACKDIR/Makefile" 209 | # Invoked with DESTDIR. 210 | ARCH := $(shell sh ./getarch) 211 | VERSION := $(shell cat ./version) 212 | SRCDIR := acmetool-v$(VERSION)-linux_$(ARCH) 213 | 214 | build: 215 | 216 | install: 217 | install -Dm 755 "$(SRCDIR)/bin/acmetool" "$(DESTDIR)/usr/bin/acmetool" 218 | install -Dm 644 ./acme-reload.default "$(DESTDIR)/etc/default/acme-reload" 219 | if [ -e "$(SRCDIR)/doc/acmetool.8" ]; then \ 220 | install -Dm 644 "$(SRCDIR)/doc/acmetool.8" "$(DESTDIR)/usr/share/man/man8/acmetool.8"; \ 221 | fi 222 | 223 | clean: 224 | 225 | END 226 | 227 | cat <<'END' > "$UNPACKDIR/acme-reload.default" 228 | # Space separated list of services to restart after certificates are changed. 229 | # By default, this is a list of common webservers like apache2, nginx, haproxy, 230 | # etc. You can append to this list or replace it entirely. 231 | SERVICES="$SERVICES" 232 | END 233 | 234 | cat <<'END' > "$UNPACKDIR/getarch" 235 | #!/bin/sh 236 | set -e 237 | case "$DEB_BUILD_ARCH" in 238 | i386) echo 386_cgo;; 239 | amd64) echo amd64_cgo;; 240 | armhf|armel) echo arm;; 241 | ppc64el) echo ppc64le;; 242 | *) echo "$DEB_BUILD_ARCH";; 243 | esac 244 | END 245 | 246 | echo "$VERSION" > "$UNPACKDIR/version" 247 | -------------------------------------------------------------------------------- /.travis/make_rpm_spec: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION="$1" 4 | if [ -z "$VERSION" ]; then 5 | echo usage: $0 1.2.34 6 | exit 1 7 | fi 8 | VERSION="$(echo "$VERSION" | sed s/^v//g)" 9 | 10 | CGO_SUFFIX="_cgo" 11 | NOCGO_SUFFIX= 12 | NOTNOCGO_SUFFIX="-nocgo" 13 | if [ "$2" == "acmetool-nocgo" ]; then 14 | CGO_SUFFIX= 15 | NOCGO_SUFFIX="-nocgo" 16 | NOTNOCGO_SUFFIX= 17 | fi 18 | 19 | cat < boulder.log || cat boulder.log ; } & 39 | START_PID=$$ 40 | 41 | # Wait for boulder to come up. 42 | echo Waiting for boulder to come up... 43 | while ((1)); do 44 | kill -0 "$START_PID" || break 45 | [ -e /tmp/boulder-has-started ] && break 46 | 47 | sleep 1 48 | done 49 | echo Boulder up. 50 | echo ---------------------------------------------------------------- 51 | 52 | # Run tests. 53 | cd "$ACME_DIR" 54 | 55 | echo travis_fold:start:go-tests 56 | time go test -v -tags=integration ./... 57 | RESULT=$? 58 | echo travis_fold:end:go-tests 59 | 60 | echo travis_fold:start:boulder-log 61 | echo Dumping boulder log 62 | cat $GOPATH/src/github.com/letsencrypt/boulder/boulder.log 63 | echo travis_fold:end:boulder-log 64 | 65 | echo Done with exit code $RESULT 66 | if [ "$RESULT" != "0" ]; then 67 | exit $RESULT 68 | fi 69 | 70 | # Crosscompilation failures are rare now and crosscompiling takes a long time 71 | # so only do it for tags. 72 | if [ -n "$TRAVIS_TAG" ]; then 73 | time source ./.travis/crosscompile 74 | fi 75 | 76 | # No point stopping boulder, travis will do it. 77 | # Don't exit here, we need after_success to run and this script is sourced. 78 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJNAME=git.devever.net/hlandau/acmetool 2 | BINARIES=$(PROJNAME) 3 | 4 | ############################################################################### 5 | # v1.12 NNSC:github.com/hlandau/degoutils/_stdenv/Makefile.ref 6 | # This is a standard Makefile for building Go code designed to be copied into 7 | # other projects. Code below this line is not intended to be modified. 8 | # 9 | # NOTE: Use of this Makefile is not mandatory. People familiar with the use 10 | # of the "go" command who have a GOPATH setup can use go get/go install. 11 | 12 | # XXX: prebuild-checks needs bash, fix this at some point 13 | SHELL := $(shell which bash) 14 | 15 | -include Makefile.extra 16 | -include Makefile.assets 17 | 18 | ## Paths 19 | ifeq ($(GOPATH),) 20 | # for some reason export is necessary for FreeBSD's gmake 21 | export GOPATH := $(shell pwd) 22 | endif 23 | ifeq ($(GOBIN),) 24 | export GOBIN := $(GOPATH)/bin 25 | endif 26 | ifeq ($(PREFIX),) 27 | export PREFIX := /usr/local 28 | endif 29 | 30 | DIRS=src bin public 31 | 32 | ## Quieting 33 | Q=@ 34 | QI=@echo -e "\t[$(1)]\t $(2)"; 35 | ifeq ($(V),1) 36 | Q= 37 | QI= 38 | endif 39 | 40 | ## Buildinfo 41 | ifeq ($(USE_BUILDINFO),1) 42 | BUILDINFO_FLAG=-ldflags "$$($$GOPATH/src/github.com/hlandau/buildinfo/gen $(1))" 43 | endif 44 | 45 | ## Standard Rules 46 | all: prebuild-checks $(DIRS) 47 | $(call QI,GO-INSTALL,$(BINARIES))go install $(BUILDFLAGS) $(call BUILDINFO_FLAG,$(BINARIES)) $(BINARIES) 48 | 49 | prebuild-checks: 50 | $(call QI,RELOCATE)if [ `find . -iname '*.go' | grep -v ./src/ | wc -l` != 0 ]; then \ 51 | if [ -e "$(GOPATH)/src/$(PROJNAME)/" ]; then \ 52 | 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)/'."; \ 53 | exit 1; \ 54 | fi; \ 55 | mkdir -p "$(GOPATH)/src/$(PROJNAME)/"; \ 56 | for x in ./* ./.*; do \ 57 | [ "$$x" == "./src" ] && continue; \ 58 | mv -n "$$x" "$(GOPATH)/src/$(PROJNAME)/"; \ 59 | done; \ 60 | ln -s "$(GOPATH)/src/$(PROJNAME)/Makefile"; \ 61 | [ -e "$(GOPATH)/src/$(PROJNAME)/_doc" ] && ln -s "$(GOPATH)/src/$(PROJNAME)/_doc" doc; \ 62 | [ -e "$(GOPATH)/src/$(PROJNAME)/_tpl" ] && ln -s "$(GOPATH)/src/$(PROJNAME)/_tpl" tpl; \ 63 | fi; \ 64 | exit 0 65 | 66 | $(DIRS): | .gotten 67 | $(call QI,DIRS)mkdir -p $(GOPATH)/src $(GOBIN); \ 68 | if [ ! -e "src" ]; then \ 69 | ln -s $(GOPATH)/src src; \ 70 | fi; \ 71 | if [ ! -e "bin" ]; then \ 72 | ln -s $(GOBIN) bin; \ 73 | fi 74 | 75 | .gotten: 76 | $(call QI,GO-GET,$(PROJNAME))go get $(PROJNAME)/... 77 | $(Q)touch .gotten 78 | 79 | .NOTPARALLEL: prebuild-checks $(DIRS) 80 | .PHONY: all test install prebuild-checks 81 | 82 | test: 83 | $(call QI,GO-TEST,$(PROJNAME))for x in $(PROJNAME); do go test -cover -v $$x/...; done 84 | 85 | install: all 86 | $(call QI,INSTALL,$(BINARIES))for x in $(BINARIES); do \ 87 | install -Dp $(GOBIN)/`basename "$$x"` $(DESTDIR)$(PREFIX)/bin; \ 88 | done 89 | 90 | update: | .gotten 91 | $(call QI,GO-GET,$(PROJNAME))go get -u $(PROJNAME)/... 92 | -------------------------------------------------------------------------------- /_doc/FAQ.md: -------------------------------------------------------------------------------- 1 | # [This document has moved.](https://hlandau.github.com/acmetool/userguide#faq) 2 | -------------------------------------------------------------------------------- /_doc/NOROOT.md: -------------------------------------------------------------------------------- 1 | # [This document has moved.](https://hlandau.github.io/acme/userguide#root-configured-non-root-operation) 2 | -------------------------------------------------------------------------------- /_doc/PACKAGING-PATHS.md: -------------------------------------------------------------------------------- 1 | # On packaging acmetool for distribution: changing default paths 2 | 3 | acmetool uses paths such as "/var/lib/acme" and "/usr/lib(exec)/acme/hooks" by 4 | default. It may be desired to change these paths for the purposes of a specific 5 | distribution. It is thus possible to override these paths when building acmetool. 6 | 7 | The following arguments to `go build` demonstrate which paths may be customized 8 | and how. This example also includes version information, which ensures that 9 | `--version` output will be informative. 10 | 11 | If you set the BUILDNAME environment variable, you can specify a short, 12 | one-line string providing your build information. This defaults, if not set, to 13 | the date and hostname. (You could set it to a constant value if you are 14 | pursing reproducible builds.) 15 | 16 | ```sh 17 | $ go build -ldflags " 18 | -X git.devever.net/hlandau/acmetool/storage.RecommendedPath=\"/var/lib/acme\" 19 | -X git.devever.net/hlandau/acmetool/hooks.DefaultPath=\"/usr/lib/acme/hooks\" 20 | -X git.devever.net/hlandau/acmetool/responder.StandardWebrootPath=\"/var/run/acme/acme-challenge\" 21 | $($GOPATH/src/github.com/hlandau/buildinfo/gen git.devever.net/hlandau/acmetool) 22 | " git.devever.net/hlandau/acmetool 23 | ``` 24 | -------------------------------------------------------------------------------- /_doc/PROGRAMMATIC-DOWNLOADING.md: -------------------------------------------------------------------------------- 1 | # How to download binary releases programmatically 2 | 3 | ## With curl 4 | 5 | ```sh 6 | 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"])')" 7 | curl -Ls -o acmetool-bin.tar.gz "https://github.com/hlandau/acmetool/releases/download/$VER/acmetool-$VER-linux_amd64_cgo.tar.gz" 8 | ``` 9 | 10 | ## With wget 11 | 12 | ```sh 13 | 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"])')" 14 | wget --quiet -O acmetool-bin.tar.gz "https://github.com/hlandau/acmetool/releases/download/$VER/acmetool-$VER-linux_amd64_cgo.tar.gz" 15 | ``` 16 | -------------------------------------------------------------------------------- /_doc/WSCONFIG.md: -------------------------------------------------------------------------------- 1 | # [This document has moved.](https://hlandau.github.io/acmetool/userguide#web-server-configuration) 2 | -------------------------------------------------------------------------------- /_doc/contrib/APKBUILD: -------------------------------------------------------------------------------- 1 | # Contributor: Hugo Landau 2 | # Maintainer: Hugo Landau 3 | # 4 | # This is a build script for the Alpine Linux build system. 5 | # Please do not submit it to Alpine Linux at this time. 6 | # 7 | # To build, put this file in an empty directory and run abuild. 8 | # You may need to setup abuild signing keys first; see Alpine documentation. 9 | # 10 | if [ "$(find version -mmin -30 2>/dev/null | wc -l)" == "0" ]; then 11 | curl -s -H 'Accept: application/vnd.github.v3+json' \ 12 | 'https://api.github.com/repos/hlandau/acmetool/releases/latest' | \ 13 | sed 's/^.*"tag_name": *"v\([^"]*\)".*$/\1/;tx;d;:x' > version.tmp || exit 1 14 | mv version.tmp version 15 | fi 16 | 17 | pkgname=acmetool 18 | pkgver="$(cat version)" 19 | pkgrel=0 20 | pkgdesc="ACME/Let's Encrypt client" 21 | url="https://github.com/hlandau/acmetool" 22 | arch="all" 23 | license="MIT" 24 | depends="libcap" 25 | makedepends="libcap-dev go bash git curl" 26 | install="" 27 | subpackages="$pkgname-doc" 28 | options="!strip" 29 | source="" 30 | 31 | prepare() { 32 | cd "$srcdir" 33 | git clone -b "v$pkgver" https://github.com/hlandau/acmetool || return 1 34 | } 35 | 36 | build() { 37 | cd "$srcdir/acmetool" || return 1 38 | make USE_BUILDINFO=1 || return 1 39 | 40 | # For some reason this is necessary in order for the buildinfo to get 41 | # included properly. 42 | rm "$srcdir/acmetool/bin/$pkgname" || return 1 43 | make USE_BUILDINFO=1 || return 1 44 | } 45 | 46 | package() { 47 | install -Dm0755 "$srcdir/acmetool/bin/$pkgname" "$pkgdir"/usr/bin/$pkgname || return 1 48 | mkdir -p "$pkgdir"/usr/share/man/man8 49 | "$pkgdir"/usr/bin/$pkgname --help-man | gzip > \ 50 | "$pkgdir"/usr/share/man/man8/acmetool.man.gz || return 1 51 | } 52 | -------------------------------------------------------------------------------- /_doc/contrib/dns.hook: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This is an example DNS hook script which uses the nsupdate utility to update 3 | # nameservers. The script waits until updates have propagated to all 4 | # nameservers listed for a zone. The script fails if this takes more than 60 5 | # seconds by default; this timeout can be adjusted. 6 | # 7 | # The script is ready to use, but to use it you must create 8 | # /etc/default/acme-dns or /etc/conf.d/acme-dns and set the following options: 9 | # 10 | # # Needed if using TSIG for updates. If authenticating updates by source IP, 11 | # # not necessary. 12 | # TSIG_KEY_NAME="hmac-sha256:tk1" 13 | # TSIG_KEY="a base64-encoded TSIG key" 14 | # 15 | # # DNS synchronization timeout in seconds. Default is 60. 16 | # DNS_SYNC_TIMEOUT=60 17 | # 18 | # # Optional: inject extra arguments and commands to nsupdate. 19 | # NSUPDATE_ARGS="-v" 20 | # nsupdate_cmds() { 21 | # # Usually not necessary: 22 | # echo zone example.com. 23 | # } 24 | # 25 | # Having done this, rename it to /usr/lib[exec]/acme/hooks/dns. 26 | # 27 | # How to test this script: 28 | # ./dns.hook challenge-dns-start example.com "" "foobar" 29 | # ./dns.hook challenge-dns-stop example.com "" "foobar" 30 | # 31 | set -e 32 | 33 | get_apex() { 34 | local name="$1" 35 | if [ -z "$name" ]; then 36 | echo "$0: couldn't get apex for $name" >&2 37 | return 1 38 | fi 39 | local ans="`dig +noall +answer SOA "${name}."`" 40 | if [ "`echo "$ans" | grep SOA | wc -l`" == "1" -a "`echo "$ans" | grep CNAME | wc -l`" == "0" ]; then 41 | APEX="$name" 42 | return 43 | fi 44 | local sname="$(echo $name | sed 's/^[^.]\+\.//')" 45 | get_apex "$sname" 46 | } 47 | 48 | waitns() { 49 | local ns="$1" 50 | for ctr in $(seq 1 "$DNS_SYNC_TIMEOUT"); do 51 | [ "$(dig +short "@${ns}" TXT "_acme-challenge.${CH_HOSTNAME}." | grep -- "$CH_TXT_VALUE" | wc -l)" == "1" ] && return 0 52 | sleep 1 53 | done 54 | 55 | # Best effort cleanup. 56 | echo $0: timed out waiting ${DNS_SYNC_TIMEOUT}s for nameserver $ns >&2 57 | updns delete || echo $0: failed to clean up records after timing out >&2 58 | 59 | return 1 60 | } 61 | 62 | updns() { 63 | local op="$1" 64 | ( 65 | declare -f nsupdate_cmds >/dev/null && nsupdate_cmds "$APEX" 66 | [ -n "$TSIG_KEY" ] && echo key "$TSIG_KEY_NAME" "$TSIG_KEY" 67 | echo update $op "_acme-challenge.${CH_HOSTNAME}." 60 IN TXT "\"${CH_TXT_VALUE}\"" 68 | echo send 69 | ) | nsupdate $NSUPDATE_ARGS 70 | } 71 | 72 | [ -e "/etc/default/acme-dns" ] && . /etc/default/acme-dns 73 | [ -e "/etc/conf.d/acme-dns" ] && . /etc/conf.d/acme-dns 74 | # e.g. 75 | # TSIG_KEY_NAME="hmac-sha256:tk1" 76 | # TSIG_KEY="base64-key-value" 77 | 78 | EVENT_NAME="$1" 79 | CH_HOSTNAME="$2" 80 | CH_TARGET_FILENAME="$3" 81 | CH_TXT_VALUE="$4" 82 | [ -z "$DNS_SYNC_TIMEOUT" ] && DNS_SYNC_TIMEOUT=60 83 | 84 | # Older versions of this script used TKIP_KEY{,_NAME} instead of 85 | # TSIG_KEY{,_NAME}. Brainfart — TKIP is part of Wi-Fi's WPA2 and has nothing to 86 | # do with DNS's TSIG. Support the old naming. 87 | if [ -n "$TKIP_KEY" ]; then 88 | TSIG_KEY="$TKIP_KEY" 89 | fi 90 | if [ -n "$TKIP_KEY_NAME" ]; then 91 | TSIG_KEY_NAME="$TKIP_KEY_NAME" 92 | fi 93 | 94 | case "$EVENT_NAME" in 95 | challenge-dns-start) 96 | get_apex "$CH_HOSTNAME" 97 | updns add 98 | 99 | # Wait for all nameservers to update. 100 | for ns in $(dig +short NS "${APEX}."); do 101 | waitns "$ns" 102 | done 103 | ;; 104 | 105 | challenge-dns-stop) 106 | get_apex "$CH_HOSTNAME" 107 | updns delete 108 | ;; 109 | 110 | *) 111 | exit 42 112 | ;; 113 | esac 114 | -------------------------------------------------------------------------------- /_doc/contrib/ovh.hook: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This is a hook to complete DNS challenge using OVH API. 4 | # 5 | # For this script to work you need the following prereqs: 6 | # 7 | # 1) Install ovh-cli (https://github.com/toorop/ovh-cli) 8 | # 2) Install dig (Centos/RH: bind-utils; Ubuntu/Debian: dnsutils) 9 | # 3) Register OVH API (execute ovh-cli without parameters and set environment variables: OVH_CONSUMER_KEY/OVH_APP_SECRET/OVH_APP_KEY) 10 | # 4) Place this script to /usr/lib[exec]/acme/hooks/ovh 11 | # 12 | # FAQ: 13 | # 14 | # Q: I have my domains under several OVH accounts, how do I manage them all? 15 | # A: Setup wrapper scripts and pass them to OVH_API_SCRIPTS environment variable, ie. 16 | # 17 | # echo -en "OVH_CONSUMER_KEY=key1 OVH_APP_SECRET=secret1 OVH_APP_KEY=app1 ovh-cli $*" >/usr/local/bin/ovh-ab1234 18 | # echo -en "OVH_CONSUMER_KEY=key2 OVH_APP_SECRET=secret2 OVH_APP_KEY=app2 ovh-cli $*" >/usr/local/bin/ovh-ab1235 19 | # chmod a+rx /usr/local/bin/ovh-ab1234 /usr/local/bin/ovh-ab1235 20 | # export OVH_API_SCRIPTS="/usr/local/bin/ovh-ab1234 /usr/local/bin/ovh-ab1235" 21 | # 22 | # Q: Why it takes so long? 23 | # A: Dunno, ask OVH :) 24 | # You can also increase timeout by setting environment variable DNS_SYNC_TIMEOUT. 25 | # 26 | # Q: Do I need to pass my environment variables all the times? 27 | # A: No, you can place them in /etc/default/acme-ovh or /etc/conf.d/acme-ovh 28 | # 29 | # Q: How to test this script? 30 | # A: Just call "test" action and pass it a domain name (./ovh.hook is hook name here, not ovh-cli): 31 | # 32 | # ./ovh.hook test example.com 33 | # 34 | 35 | set -e 36 | 37 | configure() { 38 | if [ -z "$OVH_API_SCRIPTS" ] ; then 39 | if which ovh-cli >/dev/null 2>&1 ; then 40 | OVH_API_SCRIPTS=ovh-cli 41 | elif which ovh >/dev/null 2>&1 ; then 42 | OVH_API_SCRIPTS=ovh 43 | else 44 | echo "OVH_API_SCRIPTS were not provided and ovh-cli has not been found" >&2 45 | exit 1 46 | fi 47 | if [ -z "OVH_CONSUMER_KEY" ] || [ -z "$OVH_APP_SECRET" ] || [ -z "$OVH_APP_KEY" ] ; then 48 | echo "ovh-cli has been found, but one of configuration variables OVH_CONSUMER_KEY/OVH_APP_SECRET/OVH_APP_KEY is empty" >&2 49 | exit 1 50 | fi 51 | fi 52 | } 53 | 54 | get_apex() { 55 | local name="$1" 56 | if [ -z "$name" ]; then 57 | echo "$0: couldn't get apex for $name" >&2 58 | return 1 59 | fi 60 | if ! echo "$name"|grep -q "\." ; then 61 | echo "$0: \"$name\" does not have a dot, which most likely mean that \"$CH_HOSTNAME\" is not delegated (expired?)" >&2 62 | return 1 63 | fi 64 | if dig +noall +answer SOA "${name}." |grep -q SOA ; then 65 | APEX="$name" 66 | return 67 | fi 68 | local sname="$(echo $name | sed 's/^[^.]\+\.//')" 69 | get_apex "$sname" 70 | } 71 | 72 | get_api_script() { 73 | APEX_SCRIPT=$(for script in $OVH_API_SCRIPTS ; do 74 | if $script domain list --json 2>&1 | grep -q "\"${APEX}\"" ; then 75 | echo $script 76 | fi 77 | done) 78 | if [ -z "$APEX_SCRIPT" ] ; then 79 | echo "$0: it's not possible to manage $APEX with $OVH_API_SCRIPTS" >&2 80 | for script in $OVH_API_SCRIPTS ; do 81 | echo "$script domains list:" >&2 82 | $script domain list 2>&1 | sed -e 's/^/ /' >&2 83 | echo >&2 84 | done 85 | return 1 86 | fi 87 | } 88 | 89 | waitns() { 90 | local ns="$1" 91 | local tick="$2" 92 | for ctr in $(seq $DNS_SYNC_TIMEOUT); do 93 | seq="$ctr/$DNS_SYNC_TIMEOUT" 94 | dig +short "@${ns}" TXT "_acme-challenge.${CH_HOSTNAME}." | grep -q "$CH_TXT_VALUE" && return 0 95 | [ ! -z "$tick" ] && echo -n "$tick" >&2 96 | sleep 1 97 | done 98 | 99 | # Best effort cleanup. 100 | echo $0: timed out waiting ${DNS_SYNC_TIMEOUT}s for nameserver $ns >&2 101 | remove_challenge || echo $0: failed to clean up records after timing out >&2 102 | 103 | return 1 104 | } 105 | 106 | add_challenge() { 107 | [ -z "$CH_TXT_VALUE" ] && return 1 108 | $APEX_SCRIPT domain zone newrecord $APEX --field TXT --target "$CH_TXT_VALUE" --sub _acme-challenge --ttl 60 --json >&2 109 | $APEX_SCRIPT domain zone reload $APEX 110 | } 111 | 112 | remove_challenge() { 113 | [ -z "$CH_TXT_VALUE" ] && return 1 114 | 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) 115 | for id in $recid ; do 116 | $APEX_SCRIPT domain zone delrecord $APEX $id >&2 117 | done 118 | $APEX_SCRIPT domain zone reload $APEX 119 | } 120 | 121 | test() { 122 | echo "Managing \"$APEX\" with script: $APEX_SCRIPT" >&2 123 | [ -z "$CH_TXT_VALUE" ] && CH_TXT_VALUE="foobarszcz" 124 | echo -n "Trying to add record _acme-challenge.${APEX} record: " >&2 125 | add_challenge # will return json, so we don't print anything here 126 | 127 | echo -n "Checking DNSes: " >&2 128 | for ns in $(dig +short NS "${APEX}."); do 129 | echo -n "$ns" >&2 130 | waitns "$ns" "." 131 | echo -n " " >&2 132 | done 133 | echo "done." >&2 134 | 135 | echo -n "Trying to remove _acme-challenge.${APEX} record: " >&2 136 | remove_challenge && echo "done." >&2 137 | 138 | echo -n "Testing if there is any garbage left: " >&2 139 | if $APEX_SCRIPT domain zone getrecords $APEX --json 2>&1 | grep -q "\"_acme-challenge.${APEX}\"" ; then 140 | echo "Oops, it seems there's _acme-challenge.${APEX} listed, please examine domain manually!" >&2 141 | return 1 142 | fi 143 | echo "done." >&2 144 | 145 | echo -en "All good!\n\nDomain contents:$($APEX_SCRIPT domain zone import $APEX 2>&1)\n" >&2 146 | } 147 | 148 | [ -e "/etc/default/acme-ovh" ] && . /etc/default/acme-ovh 149 | [ -e "/etc/conf.d/acme-ovh" ] && . /etc/conf.d/acme-ovh 150 | 151 | EVENT_NAME="$1" 152 | CH_HOSTNAME="$2" 153 | CH_TARGET_FILENAME="$3" 154 | CH_TXT_VALUE="$4" 155 | [ -z "$DNS_SYNC_TIMEOUT" ] && DNS_SYNC_TIMEOUT=180 156 | 157 | case "$EVENT_NAME" in 158 | challenge-dns-start) 159 | configure 160 | get_apex "$CH_HOSTNAME" 161 | get_api_script 162 | remove_challenge 163 | add_challenge 164 | 165 | # Wait for all nameservers to update. 166 | for ns in $(dig +short NS "${APEX}."); do 167 | waitns "$ns" 168 | done 169 | ;; 170 | 171 | challenge-dns-stop) 172 | configure 173 | get_apex "$CH_HOSTNAME" 174 | get_api_script 175 | remove_challenge 176 | ;; 177 | 178 | test) 179 | configure 180 | get_apex "$CH_HOSTNAME" 181 | get_api_script 182 | test 183 | ;; 184 | 185 | *) 186 | exit 42 187 | ;; 188 | esac 189 | -------------------------------------------------------------------------------- /_doc/contrib/perm.example: -------------------------------------------------------------------------------- 1 | # In some rare cases it can be necessary to override the permissions that 2 | # acmetool sets on files. You can override those permissions using the 3 | # permissions configuration file, which should be placed at 4 | # $ACME_STATE_DIR/conf/perm. This is an example of such a file. You should be 5 | # very careful when using this file, and only include the minimum changes that 6 | # you need to make. 7 | # 8 | # Each line has the following syntax: 9 | # path-pattern file-mode dir-mode [uid gid] 10 | # 11 | # For example: 12 | # keys 0640 0750 13 | # or 14 | # keys 0640 0750 root exim 15 | # 16 | # If you specify a UID, you must also specify a GID and vice versa. 17 | # UIDs and GIDs can be specified numerically, and on some platforms 18 | # they may also be specifiable as names. 19 | # 20 | # The special UID/GID value "$r" means the current UID/GID of the running 21 | # acmetool process; you can use this to ensure that the file UID/GID is 22 | # enforced to the user which acmetool runs as. 23 | # 24 | # Not specifying UID/GID values, or specifying both as "-", means that acmetool 25 | # will not pay attention to file ownership. Files will be created with their 26 | # "natural" owner (i.e., the UID/GID under which acmetool is running). 27 | # 28 | # Mode enforcement cannot be disabled. 29 | # 30 | # Nothing acmetool does should affect POSIX ACLs, if you wish to use them. 31 | # 32 | # A path-pattern is a glob pattern. Specifying the same path-pattern as a built 33 | # in permissions rule overrides that rule. You cannot place two entries for 34 | # the same path-pattern in this file. acmetool uses the longest matching pattern 35 | # when deciding what rule to use when enforcing permissions. 36 | # 37 | # The default rules are shown below: 38 | # 39 | # . 0644 0750 # Default for anything without a longer match 40 | # accounts 0600 0700 41 | # desired 0644 0755 42 | # live 0644 0755 43 | # certs 0644 0755 44 | # certs/*/haproxy 0600 0700 # Support for the HAProxy extension; contains private keys 45 | # keys 0600 0700 46 | # conf 0644 0755 47 | # tmp 0600 0700 # Do NOT change this 48 | # 49 | # If you wish to disable a path-pattern rule allowing policy to be inherited 50 | # from a shorter match, you can do this using the special keyword 'inherit': 51 | # 52 | # path-pattern inherit 53 | # 54 | # For example, maybe you want to make the whole directory restricted: 55 | # . 0600 0700 56 | # accounts inherit 57 | # certs inherit 58 | # conf inherit 59 | # desired inherit 60 | # keys inherit 61 | # 62 | # Again, you should rarely ever need to use this file. When you use this file, 63 | # add only the entries that you absolutely need. 64 | -------------------------------------------------------------------------------- /_doc/contrib/response-file.yaml: -------------------------------------------------------------------------------- 1 | # This is a example of a response file, used with --response-file. 2 | # It automatically answers prompts for unattended operation. 3 | # grep for UniqueID in the source code for prompt names. 4 | # Pass --response-file to all invocations, not just quickstart. 5 | # If you don't pass --response-file, it will be looked for at "(state-dir)/conf/responses". 6 | # You will typically want to use --response-file with --stdio or --batch. 7 | # For dialogs not requiring a response, but merely acknowledgement, specify true. 8 | # This file is YAML. Note that JSON is a subset of YAML. 9 | "acme-enter-email": "hostmaster@example.com" 10 | "acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf": true 11 | "acmetool-quickstart-choose-server": https://acme-staging.api.letsencrypt.org/directory 12 | "acmetool-quickstart-choose-method": redirector 13 | # This is only used if "acmetool-quickstart-choose-method" is "webroot". 14 | "acmetool-quickstart-webroot-path": "/var/www/foo/bar/.well-known/acme-challenge" 15 | "acmetool-quickstart-complete": true 16 | "acmetool-quickstart-install-cronjob": true 17 | "acmetool-quickstart-install-haproxy-script": true 18 | "acmetool-quickstart-install-redirector-systemd": true 19 | "acmetool-quickstart-key-type": ecdsa 20 | "acmetool-quickstart-rsa-key-size": 4096 21 | "acmetool-quickstart-ecdsa-curve": nistp256 22 | -------------------------------------------------------------------------------- /_doc/contrib/tinydns.hook: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # This is a DNS hook that updated the tinydns (djbdns/dbndns) database. For a 4 | # small period (default 90 secs), waits for dns propagation. On fail, reverts. 5 | # Uses dig for resolution. 6 | # 7 | # Tries to figure out your tinydns root directory (overwrite if necessary). 8 | # When the root directory contains a Makefile, invokes make(1), else 9 | # tinydns-data(8). That way, you can notify downstream DNS server, eg with 10 | # http://tindyns.org/dnsnotify 11 | # 12 | # Copy, move, or link this script to $ACME_HOOKS_DIR/tinydns 13 | # 14 | # You can test this script with 15 | # ./tinydns.hook challenge-dns-start example.com "" "deadbeef" 16 | # ./tinydns.hook challenge-dns-stop example.com "" "deadbeef" 17 | # 18 | # This script reads /etc/default/acme-tinydns and /etc/conf.d/acme-tinydns 19 | # You can override the following variables there: 20 | # 21 | # DNS_SYNC_MAX Maximum time in seconds to wait for DNS propagation 22 | # (default 90) 23 | # SERVICE_ROOT Directory that contains daemontools(8) services 24 | # (default one of /service /etc/service /etc/sv) 25 | # SERVICE Directory with the tinydns(8) service for 26 | # daemontools(8) (default ${SERVICE_ROOT}/tinydns) 27 | # SERVICE_ENV Directory with the envdir(8) environment for the 28 | # tinydns(8) service, if used. (default ${SERVICE}/en) 29 | # ROOT Directory containing tinydns(8)'s data, especially 30 | # the `data` file. (default: when ${SERVICE_ENV}/ROOT 31 | # is a file, its contents, otherwise ${SERVICE}/root) 32 | # 33 | EXIT_UNKNOWN_EVENT="42" 34 | DATA_MARKER_START='# -- ACMETOOL TINYDNS HOOK START --' 35 | DATA_MARKER_STOP='# -- ACMETOOL TINYDNS HOOK STOP --' 36 | 37 | # return 1 or 0 whether the given command exists 38 | have_command() { command -v "${1}" 2>&1 >/dev/null; } 39 | 40 | # strips everything before second-level-domain. TDLs not supported. 41 | get_domain() { 42 | echo "${1}" | sed -e 's/^\([^.]\{1,\}\.\)\{0,\}\([^.]\{1,\}\.[^.]\{1,\}\.\{0,1\}\)$/\2/' 43 | } 44 | 45 | # get primary dns server, prefer the one we are provisioning 46 | get_ns() { 47 | if [ -e "${SERVICE_ENV}/IP" ]; then 48 | cat "${SERVICE_ENV}/IP" 49 | else 50 | DOMAIN="$(get_domain "${1}")" 51 | dig +short SOA "${DOMAIN}" | cut -d' ' -f1 52 | fi 53 | } 54 | 55 | get_all_ns() { 56 | DOMAIN="$(get_domain "${1}")" 57 | dig +short NS "${DOMAIN}" 58 | } 59 | 60 | # parse dnsq/dnsqr/tinydns-get output (we care for 1st field of data) 61 | #answer: example.com ttl RECORD data 62 | parse_dnsq() { grep '^answer: ' | cut -d' ' -f5; } 63 | 64 | # parse DNS TXT record that still contains length prepended (as dnsq) 65 | parse_dnstxt() { sed -e 's/^\(\\[[:digit:]]\{3\}\)\|.//'; } 66 | 67 | # parse DNS TXT record still with quotes (as dig) 68 | parse_digtxt() { TXT="${1#\"}"; echo "${TXT%\"}"; } 69 | 70 | # Get content of given TXT record via DNS (opt from SERVER) 71 | get_txt() { 72 | TXT_HOST="${1}" 73 | SERVER="${2}" 74 | if [ -z "${SERVER}" ]; then 75 | parse_digtxt "$(dig +short TXT "${TXT_HOST}")" 76 | else 77 | parse_digtxt "$(dig +short "@${SERVER}" TXT "${TXT_HOST}")" 78 | fi 79 | } 80 | 81 | controls_domain() ( 82 | cd "${ROOT}" 83 | tinydns-get soa "${1}" | grep -q '^answer:' 84 | # if no answer, then no control 85 | ) 86 | 87 | # set all variable we need and such 88 | prepare() { 89 | # set reliable path 90 | PATH="$(command -p getconf PATH):${PATH}" 91 | # add /command if available 92 | [ -d /command ] && PATH="/command:${PATH}" 93 | 94 | # make sure we all commands we need 95 | for cmd in tinydns-get dig sleep sed grep cut mv wait echo; do 96 | have_command "${cmd}" 97 | done 98 | 99 | # find tinydns root 100 | for CANDIDATE in "${SERVICE_ROOT}" /service /etc/service /etc/sv; do 101 | if [ -d "${CANDIDATE}" ]; then 102 | SERVICE_ROOT="${CANDIDATE}"; break 103 | fi 104 | done 105 | SERVICE="${SERVICE:-${SERVICE_ROOT}/tinydns}" 106 | SERVICE_ENV="${SERVICE_ENV:-${SERVICE}/env}" 107 | if [ -z "${ROOT}" ]; then 108 | if [ -f "${SERVICE_ENV}/ROOT" ]; then 109 | ROOT="$(cat "${SERVICE_ENV}/ROOT")" 110 | else 111 | ROOT="${SERVICE}/root" 112 | fi 113 | fi 114 | # no tinydns root, no operation 115 | [ -d "${ROOT}" ] || exit 1 116 | } 117 | 118 | # Get content of given TXT record via database 119 | get_txt_record() ( 120 | cd "${ROOT}" 121 | tinydns-get txt "${1}" | parse_dnsq | parse_dnstxt 122 | ) 123 | 124 | # write txt record to database 125 | set_txt_record() ( 126 | cd "${ROOT}" 127 | if grep -q "${DATA_MARKER_START}" data; then :; else 128 | echo "${DATA_MARKER_START}" >> data 129 | echo "${DATA_MARKER_STOP}" >> data 130 | fi 131 | sed -e "/${DATA_MARKER_STOP}/i\'${1}:${2}:300" data > data.acmetmp \ 132 | && mv data.acmetmp data 133 | ) 134 | 135 | # remove txt record from database 136 | del_txt_record() ( 137 | cd "${ROOT}" 138 | sed -e "/^'${1}:${2}/d" data > data.acmetmp \ 139 | && mv data.acmetmp data 140 | ) 141 | 142 | # update tinydns database (aka commit) 143 | update() ( 144 | cd "${ROOT}" 145 | if have_command make && [ -f Makefile ]; then 146 | make 147 | else 148 | tinydns-data 149 | fi 150 | ) 151 | 152 | # reload database and check this worked via DNS 153 | reload() ( 154 | TXT_HOST="${1}" 155 | CHALLENGE="${2}" 156 | update 157 | 158 | index="${DNS_SYNC_MAX:-90}" 159 | export NS_STATUS=1 160 | get_all_ns "${TXT_HOST}" | while read NAMESERVER; do 161 | while [ "${index}" -gt 0 ]; do 162 | sleep 5 & 163 | if [ -z "${CHALLENGE}" ]; then 164 | if [ -z "$(get_txt "${TXT_HOST}" "${NAMESERVER}")" ]; then export NS_STATUS=0; break; fi 165 | else 166 | if [ "$(get_txt "${TXT_HOST}" "${NAMESERVER}")" = "${CHALLENGE}" ]; then NS_STATUS=0; break; fi 167 | fi 168 | index="$((${index} - 5))" 169 | wait 170 | done 171 | [ "${NS_STATUS}" -eq 0 ] || return 1 # reached here because of timeout 172 | done 173 | return 0 174 | ) 175 | 176 | # CALLBACK: insert acme challange 177 | start() { 178 | HOST="${1}"; DOMAIN="${2}"; CHALLENGE="${3}" 179 | TXT_HOST="_acme-challenge.${HOST}" 180 | TXT_RECORD="$(get_txt_record "${TXT_HOST}" )" 181 | [ "${TXT_RECORD}" = "${CHALLENGE}" ] && return 0 # challenge already there 182 | [ -z "${TXT_RECORD}" ] # challenge not empty, doesn't match ours 183 | set_txt_record "${TXT_HOST}" "${CHALLENGE}" 184 | reload "${TXT_HOST}" "${CHALLENGE}" \ 185 | || (del_txt_record "${TXT_HOST}"; update; return 1) 186 | } 187 | 188 | # CALLBACK: remove acme challange 189 | stop() { 190 | HOST="${1}"; DOMAIN="${2}"; CHALLENGE="${3}" 191 | TXT_HOST="_acme-challenge.${HOST}" 192 | [ "$(get_txt_record "${TXT_HOST}" )" = "${CHALLENGE}" ] 193 | del_txt_record "${TXT_HOST}" 194 | reload "${TXT_HOST}" "" \ 195 | || (set_txt_record "${TXT_HOST}" "${CHALLENGE}" ; update; return 1) 196 | } 197 | 198 | # include configuration from known locations 199 | [ -e "/etc/default/acme-tinydns" ] && . /etc/default/acme-tinydns 200 | [ -e "/etc/conf.d/acme-tinydns" ] && . /etc/conf.d/acme-tinydns 201 | 202 | # Contract is: 203 | # ACME_STATE_DIR=/var/lib/acme /usr/lib/acme/hooks/tinydns \ 204 | # challenge-dns-start hostname.example.com target_file challenge 205 | EVENT="${1}" 206 | HOST="${2}" 207 | TARGET_FILE="${3}" 208 | CHALLENGE="${4}" 209 | 210 | case "${EVENT}" in 211 | challenge-dns-*) 212 | prepare 213 | DOMAIN="$(get_domain ${HOST})" 214 | controls_domain "${DOMAIN}" 215 | "${EVENT##challenge-dns-}" "${HOST}" "${DOMAIN}" "${CHALLENGE}" 216 | ;; 217 | *) 218 | exit "${EXIT_UNKNOWN_EVENT}" 219 | ;; 220 | esac 221 | -------------------------------------------------------------------------------- /_doc/guide/ENTER: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -euo pipefail 3 | exec nix-shell -p ruby libxslt libxml2 docbook5_xsl plantuml "$@" 4 | #asciidoctor 5 | -------------------------------------------------------------------------------- /_doc/guide/Makefile: -------------------------------------------------------------------------------- 1 | ifneq ($(NIX_PATH),) 2 | # NixOS 3 | DOCBOOK_XSL := $(shell echo "$(buildInputs)" | tr ' ' '\n' | grep docbook)/xml/xsl/docbook 4 | else 5 | # Alpine 6 | DOCBOOK_XSL := $(shell echo /usr/share/xml/docbook/xsl-stylesheets-*) 7 | endif 8 | 9 | all: out/index.xhtml out/img out/style.css 10 | 11 | clean: 12 | rm -rf tmp out 13 | 14 | deploy: 15 | rsync -rltv --perms=D755,F644 out/ drone@rem.devever.net::doc/acmetool/ 16 | 17 | out/img: img 18 | rsync -a "$<"/ "$@"/ 19 | 20 | out/style.css: style.css out/index.xhtml 21 | cat out/docbook.css style.css > "$@" 22 | 23 | out/index.xhtml: out/index.docbook tmp/acmetool.8.docbook include-man.xsl to-xhtml.xsl 24 | rm -f docbook-xsl 25 | ln -s "$(DOCBOOK_XSL)" docbook-xsl 26 | xsltproc \ 27 | --stringparam html.stylesheet style.css \ 28 | --nonet --output "$@" \ 29 | to-xhtml.xsl "$<" 30 | 31 | out/index.docbook: tmp/index.docbook tmp/acmetool.8.docbook include-man.xsl to-xhtml.xsl 32 | xsltproc --xinclude --xincludestyle --nonet --output "$@" include-man.xsl "$<" 33 | 34 | tmp/%.docbook: %.adoc 35 | mkdir -p tmp 36 | asciidoctor -r asciidoctor-diagram -b docbook5 -o "$@" "$<" 37 | -------------------------------------------------------------------------------- /_doc/guide/acmetool.8.adoc: -------------------------------------------------------------------------------- 1 | ACMETOOL(8) 2 | =========== 3 | Hugo Landau 4 | :doctype: manpage 5 | :manmanual: ACMETOOL 6 | :mansource: ACMETOOL 7 | 8 | NAME 9 | ---- 10 | acmetool - request certificates from ACME servers automatically 11 | 12 | SYNOPSIS 13 | -------- 14 | *acmetool* ['flags'] 'command' ['args'] 15 | 16 | [[description]] 17 | DESCRIPTION 18 | ----------- 19 | 20 | acmetool is a utility for the automated retrieval, management and renewal of 21 | PKI certificates from ACME servers such as those provided by Let's Encrypt. The 22 | tool emphasises automation, idempotency and the minimisation of state. 23 | 24 | You use acmetool by configuring targets (typically using the "want" command). 25 | acmetool then requests certificates as necessary to satisfy the configured 26 | targets. New certificates are requested where existing ones are soon to expire. 27 | 28 | acmetool stores its state in a state directory. It can be specified on 29 | invocation via the *--state* option; otherwise, the path in environment 30 | variable *ACME_STATE_DIR* is used, or, failing that, the path '/var/lib/acme' 31 | (recommended). 32 | 33 | The '--xlog' options control the logging. The '--service' options control 34 | privilege dropping and daemonization and are applicable only to the 35 | 'redirector' subcommand. 36 | 37 | [[global-options]] 38 | GLOBAL OPTIONS 39 | -------------- 40 | 41 | ### FUNDAMENTAL OPTIONS 42 | 43 | *--state=/var/lib/acme*:: 44 | Path to the state directory (defaults to environment variable 45 | *ACME_STATE_DIR*, or, failing that, '/var/lib/acme'.) 46 | *--hooks=/usr/lib/acme/hooks*:: 47 | Path to the notification hooks directory (defaults to environment variable 48 | *ACME_HOOKS_DIR* or, failing that, '/usr/lib/acme/hooks' or 49 | '/usr/libexec/acme/hooks', depending on your system.) You may disable hooks 50 | by setting this to '/var/empty'. 51 | 52 | ### INFORMATION OPTIONS 53 | 54 | *--help*:: 55 | Show context-sensitive help (also try --help-long). 56 | *--version*:: 57 | Print version information. 58 | 59 | ### INTERACTION OPTIONS 60 | 61 | *--batch*:: 62 | Never attempt interaction; useful for cron jobs. If it is impossible to 63 | continue without interaction, exits unsuccessfully. (acmetool can still 64 | obtain responses from a response file, if one was provided.) 65 | *--stdio*:: 66 | Don't attempt to use console dialogs for interaction; fall back to stdio prompts. 67 | *--response-file=RESPONSE-FILE*:: 68 | Read dialog responses from the given YAML file. (Defaults to 69 | '$ACME_STATE_DIR/conf/responses', if it exists.) 70 | 71 | ### LOGGING OPTIONS 72 | 73 | *--xlog.facility=daemon*:: 74 | Syslog facility to use. 75 | *--xlog.syslog*:: 76 | Log to syslog? Defaults to false. 77 | *--xlog.syslogseverity=DEBUG*:: 78 | Syslog severity limit. 79 | *--xlog.journal*:: 80 | Log to systemd journal? Defaults to false. 81 | *--xlog.journalseverity=DEBUG*:: 82 | Systemd journal severity limit. 83 | *--xlog.severity=NOTICE*:: 84 | Log severity (any syslog severity name or number). 85 | *--xlog.file=""*:: 86 | Log to filename. Disabled by default. 87 | *--xlog.fileseverity=TRACE*:: 88 | File logging severity limit. 89 | *--xlog.stderr*:: 90 | Log to stderr? 91 | *--xlog.stderrseverity=TRACE*:: 92 | Stderr logging severity limit. 93 | 94 | ### REDIRECTOR OPTIONS 95 | 96 | *--service.cpuprofile=""*:: 97 | Redirector mode: Write CPU profile to file. 98 | *--service.debugserveraddr=""*:: 99 | Redirector mode: Address for debug server to listen on (insecure, do not 100 | specify a public address). Disabled by default. 101 | *--service.uid=""*:: 102 | Redirector mode: UID to run as. If not specified, doesn't drop privileges. 103 | Note: On Linux, this option is only available if acmetool was built with cgo. 104 | You can find out whether this is the case by running 'acmetool --version'. 105 | Note: Regardless of platform, this value can only be specified by name rather 106 | than numerically if acmetool was built with cgo. 107 | *--service.gid=""*:: 108 | Redirector mode: GID to run as. If not specified, doesn't drop privileges. 109 | See --service.uid for caveats. 110 | *--service.daemon*:: 111 | Redirector mode: Run as a daemon? (Doesn't fork.) 112 | *--service.stderr*:: 113 | Redirector mode: Keep stderr open when daemonizing. 114 | *--service.chroot=""*:: 115 | Redirector mode: Chroot to a directory. If you set this, you must set a UID and GID. Set to '/' to disable. 116 | *--service.pidfile=""*:: 117 | Redirector mode: Write PID to file with given filename and hold a write lock. 118 | *--service.fork*:: 119 | Redirector mode: Fork? Implies --service.daemon. Not recommended. 120 | 121 | [[subcommands]] 122 | SUBCOMMANDS 123 | ----------- 124 | 125 | [[fbquickstart_ltflagsgtfr]] 126 | *quickstart []* 127 | ~~~~~~~~~~~~~~~~~~~~~~ 128 | 129 | Interactively ask some getting started questions and install default hooks 130 | (recommended). 131 | 132 | *--expert*:: 133 | Ask more questions in quickstart wizard 134 | 135 | [[fbreconcilefr]] 136 | *reconcile* 137 | ~~~~~~~~~~~ 138 | 139 | Reconcile ACME state, idempotently requesting and renewing certificates 140 | to satisfy configured targets. 141 | 142 | This is the default command. 143 | 144 | [[fbwant_ltflagsgt_lthostnamegtfr]] 145 | *want [] ...* 146 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 147 | 148 | Add a target with one or more hostnames 149 | 150 | *--no-reconcile*:: 151 | Do not reconcile automatically after adding the target. 152 | 153 | [[fbunwant_lthostnamegtfr]] 154 | *unwant ...* 155 | ~~~~~~~~~~~~~~~~~~~~~~ 156 | 157 | Modify targets to remove any mentions of the given hostnames 158 | 159 | [[fbcull_ltflagsgtfr]] 160 | *cull []* 161 | ~~~~~~~~~~~~~~~~ 162 | 163 | Delete expired, unused certificates 164 | 165 | *-n, --simulate*:: 166 | Show which certificates would be deleted without deleting any. 167 | 168 | [[fbstatusfr]] 169 | *status* 170 | ~~~~~~~~ 171 | 172 | Show active configuration 173 | 174 | [[fbaccountthumbprintfr]] 175 | *account-thumbprint* 176 | ~~~~~~~~~~~~~~~~~~~~ 177 | 178 | Prints account thumbprints. 179 | 180 | [[fbrevoke_ltcertificateidorpathgtfr]] 181 | *revoke []* 182 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 183 | 184 | Revoke a certificate. 185 | 186 | [[fbredirector_ltflagsgtfr]] 187 | *redirector []* 188 | ~~~~~~~~~~~~~~~~~~~~~~ 189 | 190 | HTTP to HTTPS redirector with challenge response support. 191 | 192 | *--path=PATH*:: 193 | Path to serve challenge files from. Defaults to '/var/run/acme/acme-challenge'. 194 | *--challenge-gid=CHALLENGE-GID*:: 195 | GID to chgrp the challenge path to. Optional. 196 | *--read-timeout=10s*:: 197 | Maximum duration before timing out read of the request. Defaults to '10s'. 198 | *--write-timeout=20s*:: 199 | Maximum duration before timing out write of the request. Defaults to '20s'. 200 | *--status-code=308*:: 201 | HTTP status code to use when redirecting. Defaults to '308'. 202 | *--bind=":80"*:: 203 | Bind address for redirector. Defaults to ':80'. 204 | 205 | [[fbtestnotify_lthostnamegtfr]] 206 | *test-notify [...]* 207 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 208 | 209 | Test-execute notification hooks as though given hostnames were updated. 210 | 211 | [[fbimportjwkaccount_ltproviderurlgt_ltpri]] 212 | *import-jwk-account * 213 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 214 | 215 | Import a JWK account key. 216 | 217 | [[fbimportpemaccount_ltproviderurlgt_ltpri]] 218 | *import-pem-account * 219 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 220 | 221 | Import a PEM account key. 222 | 223 | [[fbimportkey_ltprivatekeyfilegtfr]] 224 | *import-key * 225 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 226 | 227 | Import a certificate private key. 228 | 229 | [[fbhelp_ltcommandgtfr]] 230 | *help [...]* 231 | ~~~~~~~~~~~~~~~~~~~~~ 232 | 233 | Show help. 234 | 235 | 236 | [[author]] 237 | AUTHOR 238 | ------ 239 | 240 | © 2015—2018 Hugo Landau MIT License 241 | 242 | [[see_also]] 243 | SEE ALSO 244 | -------- 245 | 246 | Documentation: 247 | 248 | Report bugs at: 249 | -------------------------------------------------------------------------------- /_doc/guide/img/acmetool-logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hlandau/acmetool/d3428cff2614cbd31d91484e7c7bd188db53bcf1/_doc/guide/img/acmetool-logo-black.png -------------------------------------------------------------------------------- /_doc/guide/img/acmetool-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hlandau/acmetool/d3428cff2614cbd31d91484e7c7bd188db53bcf1/_doc/guide/img/acmetool-logo.png -------------------------------------------------------------------------------- /_doc/guide/include-man.xsl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Manual Pages 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /_doc/guide/style.css: -------------------------------------------------------------------------------- 1 | /* RESET */ 2 | a img { border: none; } 3 | 4 | /* LOGO */ 5 | #logo { text-align: center; margin-top: 1em; } 6 | html#index #logo { margin-top: 10em; } 7 | 8 | /* TOP-LEVEL HEADING */ 9 | h1 { text-align: center; margin: 0 auto; } 10 | 11 | /* MID-LEVEL HEADING */ 12 | h2 { margin-top: 2em; } 13 | h2::before { content: "█ "; color: #000; margin-left: 0; } 14 | 15 | /* NAV */ 16 | nav#tnav > ul { list-style: none; text-align: center; } 17 | nav#tnav > ul > li { display: inline-block; padding: 0; margin: 0; } 18 | nav#tnav > ul > li:not(:first-child)::before { content: "·"; padding: 0.2em; } 19 | a:link { color: #00a; text-decoration: none; } 20 | 21 | /* TABLE OF CONTENTS */ 22 | .toc { background-color: #e0e0e0; padding: 0.5em; margin: 0.5em; margin-bottom: 2em; } 23 | .toc > ul { list-style: none; margin: 0; padding: 0; } 24 | .toc-title { font-weight: bold; } 25 | -------------------------------------------------------------------------------- /_doc/guide/to-xhtml.xsl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | book toc 7 | 8 | 9 | 0 10 | 0 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /cli/doc.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "github.com/hlandau/acmetool/storage" 6 | "gopkg.in/alecthomas/kingpin.v2" 7 | ) 8 | 9 | const manPageTemplate = `{{define "FormatFlags"}}\ 10 | {{range .Flags}}\ 11 | {{if not .Hidden}}\ 12 | .TP 13 | \fB{{if .Short}}-{{.Short|Char}}, {{end}}--{{.Name}}{{if not .IsBoolFlag}}={{.FormatPlaceHolder}}{{end}}\\fR 14 | {{.Help}} 15 | {{end}}\ 16 | {{end}}\ 17 | {{end}}\ 18 | {{define "FormatCommand"}}\ 19 | {{if .FlagSummary}} {{.FlagSummary}}{{end}}\ 20 | {{range .Args}} {{if not .Required}}[{{end}}<{{.Name}}{{if .Default}}*{{end}}>{{if .Value|IsCumulative}}...{{end}}{{if not .Required}}]{{end}}{{end}}\ 21 | {{end}}\ 22 | {{define "FormatCommands"}}\ 23 | {{range .FlattenedCommands}}\ 24 | {{if not .Hidden}}\ 25 | .SS 26 | \fB{{.FullCommand}}{{template "FormatCommand" .}}\\fR 27 | .PP 28 | {{.Help}} 29 | {{template "FormatFlags" .}}\ 30 | {{end}}\ 31 | {{end}}\ 32 | {{end}}\ 33 | {{define "FormatUsage"}}\ 34 | {{template "FormatCommand" .}}{{if .Commands}} [ ...]{{end}}\\fR 35 | {{end}}\ 36 | .TH {{.App.Name}} 8 {{.App.Version}} "acmetool" 37 | .SH "NAME" 38 | {{.App.Name}} - request certificates from ACME servers automatically 39 | .SH "SYNOPSIS" 40 | .TP 41 | \fB{{.App.Name}}{{template "FormatUsage" .App}} 42 | .SH "DESCRIPTION" 43 | {{.App.Help}} 44 | .SH "OPTIONS" 45 | {{template "FormatFlags" .App}}\ 46 | {{if .App.Commands}}\ 47 | .SH "SUBCOMMANDS" 48 | {{template "FormatCommands" .App}}\ 49 | {{end}}\ 50 | .SH "AUTHOR" 51 | © 2015 {{.App.Author}} MIT License 52 | .SH "SEE ALSO" 53 | Documentation: 54 | 55 | Report bugs at: 56 | ` 57 | 58 | var helpText = fmt.Sprintf(`acmetool is a utility for the automated retrieval, management and renewal of 59 | certificates from ACME server such as Let's Encrypt. It emphasises automation, 60 | idempotency and the minimisation of state. 61 | 62 | You use acmetool by configuring targets (typically using the "want") command. 63 | acmetool then requests certificates as necessary to satisfy the configured 64 | targets. New certificates are requested where existing ones are soon to expire. 65 | 66 | acmetool stores its state in a state directory. It can be specified on 67 | invocation via the --state option; otherwise, the path in ACME_STATE_DIR is 68 | used, or, failing that, the path "%s" (recommended). 69 | 70 | The --xlog options control the logging. The --service options control privilege 71 | dropping and daemonization and are applicable only to the redirector subcommand. 72 | `, storage.RecommendedPath) 73 | 74 | func init() { 75 | kingpin.CommandLine.Help = helpText 76 | kingpin.CommandLine.Author("Hugo Landau") 77 | kingpin.ManPageTemplate = manPageTemplate 78 | } 79 | -------------------------------------------------------------------------------- /cli/main_ig_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package cli 4 | 5 | import ( 6 | "fmt" 7 | //"gopkg.in/hlandau/acmeapi.v2" 8 | "github.com/hlandau/acmetool/interaction" 9 | "github.com/hlandau/acmetool/responder" 10 | "github.com/hlandau/acmetool/storageops" 11 | "gopkg.in/hlandau/acmeapi.v2/pebbletest" 12 | "io/ioutil" 13 | "path/filepath" 14 | "strings" 15 | "testing" 16 | ) 17 | 18 | type interceptor struct { 19 | } 20 | 21 | func (i *interceptor) Prompt(c *interaction.Challenge) (*interaction.Response, error) { 22 | switch c.UniqueID { 23 | case "acmetool-quickstart-choose-server": 24 | return &interaction.Response{Value: "url"}, nil 25 | case "acmetool-quickstart-enter-directory-url": 26 | return &interaction.Response{Value: "https://127.0.0.1:14000/dir"}, nil 27 | case "acmetool-quickstart-choose-method": 28 | return &interaction.Response{Value: "redirector"}, nil 29 | case "acme-enter-email": 30 | return &interaction.Response{Value: "nobody@example.com"}, nil 31 | case "acmetool-quickstart-complete": 32 | return &interaction.Response{}, nil 33 | case "acmetool-quickstart-install-cronjob", "acmetool-quickstart-install-haproxy-script", "acmetool-quickstart-install-redirector-systemd": 34 | return &interaction.Response{Cancelled: true}, nil 35 | default: 36 | if strings.HasPrefix(c.UniqueID, "acme-agreement:") { 37 | return &interaction.Response{}, nil 38 | } 39 | 40 | return nil, fmt.Errorf("unsupported challenge for interceptor: %v", c) 41 | } 42 | } 43 | 44 | func (i *interceptor) Status(info *interaction.StatusInfo) (interaction.StatusSink, error) { 45 | return nil, fmt.Errorf("status not supported") 46 | } 47 | 48 | func TestCLI(t *testing.T) { 49 | 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) 50 | 51 | //acmeapi.TestingAllowHTTP = true 52 | storageops.InternalHTTPClient = pebbletest.HTTPClient 53 | 54 | interaction.Interceptor = &interceptor{} 55 | 56 | tmpDir, err := ioutil.TempDir("", "acmetool-test") 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | *stateFlag = filepath.Join(tmpDir, "state") 62 | *hooksFlag = []string{filepath.Join(tmpDir, "hooks")} 63 | 64 | responder.InternalHTTPPort = 5002 65 | //responder.InternalTLSSNIPort = 5001 66 | cmdQuickstart() 67 | 68 | *wantArg = []string{"dom1.acmetool-test.devever.net", "dom2.acmetool-test.devever.net"} 69 | 70 | cmdWant() 71 | cmdReconcile() 72 | } 73 | -------------------------------------------------------------------------------- /cli/quickstart-linux.go: -------------------------------------------------------------------------------- 1 | // +build linux 2 | 3 | package cli 4 | 5 | import ( 6 | "fmt" 7 | "github.com/hlandau/acmetool/interaction" 8 | sddbus "github.com/hlandauf/go-systemd/dbus" 9 | sdunit "github.com/hlandauf/go-systemd/unit" 10 | "gopkg.in/hlandau/svcutils.v1/exepath" 11 | "gopkg.in/hlandau/svcutils.v1/systemd" // coreos/go-systemd/util requires cgo 12 | "io" 13 | "os" 14 | ) 15 | 16 | func promptSystemd() { 17 | if !systemd.IsRunningSystemd() { 18 | log.Debugf("not running systemd") 19 | return 20 | } 21 | 22 | log.Debug("connecting to systemd") 23 | conn, err := sddbus.New() 24 | if err != nil { 25 | log.Errore(err, "connect to systemd") 26 | return 27 | } 28 | 29 | defer conn.Close() 30 | log.Debug("connected") 31 | 32 | props, err := conn.GetUnitProperties("acmetool-redirector.service") 33 | if err != nil { 34 | log.Errore(err, "systemd GetUnitProperties") 35 | return 36 | } 37 | 38 | if props["LoadState"].(string) != "not-found" { 39 | log.Info("acmetool-redirector.service unit already installed, skipping") 40 | return 41 | } 42 | 43 | r, err := interaction.Auto.Prompt(&interaction.Challenge{ 44 | Title: "Install Redirector as systemd Service?", 45 | Body: `Would you like acmetool to automatically install the redirector as a systemd service? 46 | 47 | The service name will be acmetool-redirector.`, 48 | ResponseType: interaction.RTYesNo, 49 | UniqueID: "acmetool-quickstart-install-redirector-systemd", 50 | }) 51 | log.Fatale(err, "interaction") 52 | 53 | if r.Cancelled { 54 | return 55 | } 56 | 57 | username, err := determineAppropriateUsername() 58 | if err != nil { 59 | log.Errore(err, "determine appropriate username") 60 | return 61 | } 62 | 63 | f, err := os.OpenFile("/etc/systemd/system/acmetool-redirector.service", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) 64 | if err != nil { 65 | log.Errore(err, "acmetool-redirector.service unit file already exists?") 66 | return 67 | } 68 | defer f.Close() 69 | 70 | rdr := sdunit.Serialize([]*sdunit.UnitOption{ 71 | sdunit.NewUnitOption("Unit", "Description", "acmetool HTTP redirector"), 72 | sdunit.NewUnitOption("Service", "Type", "notify"), 73 | sdunit.NewUnitOption("Service", "ExecStart", exepath.Abs+` redirector --service.uid=`+username), 74 | sdunit.NewUnitOption("Service", "Restart", "always"), 75 | sdunit.NewUnitOption("Service", "RestartSec", "30"), 76 | sdunit.NewUnitOption("Install", "WantedBy", "multi-user.target"), 77 | }) 78 | 79 | _, err = io.Copy(f, rdr) 80 | if err != nil { 81 | log.Errore(err, "cannot write unit file") 82 | return 83 | } 84 | 85 | f.Close() 86 | err = conn.Reload() // softfail 87 | log.Warne(err, "systemctl daemon-reload failed") 88 | 89 | _, _, err = conn.EnableUnitFiles([]string{"acmetool-redirector.service"}, false, false) 90 | log.Errore(err, "failed to enable unit acmetool-redirector.service") 91 | 92 | _, err = conn.StartUnit("acmetool-redirector.service", "replace", nil) 93 | log.Errore(err, "failed to start acmetool-redirector") 94 | resultStr := "The acmetool-redirector service was successfully started." 95 | if err != nil { 96 | 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." 97 | } 98 | 99 | _, err = interaction.Auto.Prompt(&interaction.Challenge{ 100 | Title: "systemd Service Installation Complete", 101 | Body: fmt.Sprintf(`acmetool-redirector has been installed as a systemd service. 102 | 103 | %s`, resultStr), 104 | UniqueID: "acmetool-quickstart-complete", 105 | }) 106 | log.Errore(err, "interaction") 107 | } 108 | -------------------------------------------------------------------------------- /cli/quickstart-nlinux.go: -------------------------------------------------------------------------------- 1 | // +build !linux 2 | 3 | package cli 4 | 5 | func promptSystemd() { 6 | } 7 | -------------------------------------------------------------------------------- /cmd/acmetool/main.go: -------------------------------------------------------------------------------- 1 | // Legacy entrypoint for people using github.com/hlandau/acme/cmd/acmetool. 2 | // Moved to github.com/hlandau/acmetool. 3 | package main 4 | 5 | import "github.com/hlandau/acmetool/cli" 6 | 7 | func main() { 8 | cli.Main() 9 | } 10 | -------------------------------------------------------------------------------- /fdb/fdb_test.go: -------------------------------------------------------------------------------- 1 | package fdb 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | func TestFDB(t *testing.T) { 12 | dir, err := ioutil.TempDir("", "acmefdbtest") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | defer os.RemoveAll(dir) 18 | 19 | const permissionsfile = ` 20 | 21 | # This is an example permissions file 22 | alpha 0604 0705 23 | alpha/foo 0640 0750 24 | ` 25 | err = ioutil.WriteFile(filepath.Join(dir, "Permissionsfile"), []byte(permissionsfile), 0644) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | db, err := Open(Config{ 31 | Path: dir, 32 | Permissions: []Permission{ 33 | {Path: ".", FileMode: 0644, DirMode: 0755}, 34 | {Path: "alpha", FileMode: 0644, DirMode: 0755}, 35 | {Path: "beta", FileMode: 0600, DirMode: 0700}, 36 | {Path: "tmp", FileMode: 0600, DirMode: 0700}, 37 | }, 38 | PermissionsPath: "Permissionsfile", 39 | }) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | defer db.Close() 45 | 46 | err = db.Verify() 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | c := db.Collection("alpha/foo/x") 52 | if c.DB() != db { 53 | panic("...") 54 | } 55 | 56 | if c.Name() != "alpha/foo/x" { 57 | panic(c.Name()) 58 | } 59 | 60 | if c.OSPath("") != filepath.Join(dir, "alpha/foo/x") { 61 | panic(c.OSPath("")) 62 | } 63 | 64 | if c.OSPath("xyz") != filepath.Join(dir, "alpha/foo/x/xyz") { 65 | panic(c.OSPath("xyz")) 66 | } 67 | 68 | cc := db.Collection("alpha").Collection("foo").Collection("x") 69 | if cc.OSPath("xyz") != filepath.Join(dir, "alpha/foo/x/xyz") { 70 | panic(c.OSPath("xyz")) 71 | } 72 | 73 | b := []byte("\r\n\t 42 \n\n") 74 | err = WriteBytes(c, "xyz", b) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | n, err := Uint(c, "xyz", 31) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | if n != 42 { 84 | t.Fatalf("expected 42, got %v", n) 85 | } 86 | 87 | if !Exists(c, "xyz") { 88 | t.Fatalf("expected xyz to exist") 89 | } 90 | 91 | if Exists(c, "xyz1") { 92 | t.Fatalf("did not expect xyz1 to exist") 93 | } 94 | 95 | fi, err := os.Stat(c.OSPath("xyz")) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | if fi.Mode() != 0640 { 101 | t.Fatal("unexpected mode") 102 | } 103 | 104 | err = CreateEmpty(db.Collection("alpha"), "nak") 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | 109 | fi, err = os.Stat(db.Collection("alpha").OSPath("nak")) 110 | if err != nil { 111 | t.Fatal(err) 112 | } 113 | 114 | if fi.Mode() != 0604 { 115 | t.Fatal("unexpected mode") 116 | } 117 | 118 | err = CreateEmpty(c, "xyz1") 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | if !Exists(c, "xyz1") { 124 | t.Fatalf("expected xyz1 to exist") 125 | } 126 | 127 | err = c.Delete("xyz1") 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | 132 | f, err := c.Create("xyz") 133 | if err != nil { 134 | t.Fatal(err) 135 | } 136 | 137 | _, err = f.Write([]byte("blah blah blah.")) 138 | if err != nil { 139 | t.Fatal(err) 140 | } 141 | 142 | err = f.CloseAbort() 143 | if err != nil { 144 | t.Fatal(err) 145 | } 146 | 147 | b2, err := Bytes(c.Open("xyz")) 148 | if err != nil { 149 | t.Fatal(err) 150 | } 151 | 152 | if !reflect.DeepEqual(b, b2) { 153 | t.Fatal("mismatch") 154 | } 155 | 156 | s2, err := String(c.Open("xyz")) 157 | if err != nil { 158 | t.Fatal(err) 159 | } 160 | 161 | if s2 != string(b2) { 162 | t.Fatal("mismatch") 163 | } 164 | 165 | err = c.WriteLink("lnk", Link{Target: "alpha/foo/x/xyz"}) 166 | if err != nil { 167 | t.Fatal(err) 168 | } 169 | 170 | lnk, err := c.ReadLink("lnk") 171 | if err != nil { 172 | t.Fatal(err) 173 | } 174 | 175 | if lnk.Target != "alpha/foo/x/xyz" { 176 | t.Fatal(lnk.Target) 177 | } 178 | 179 | b2, err = Bytes(c.Openl("lnk")) 180 | if err != nil { 181 | t.Fatal(err) 182 | } 183 | 184 | if !reflect.DeepEqual(b, b2) { 185 | t.Fatal("mismatch") 186 | } 187 | 188 | err = db.Verify() 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | names, err := c.List() 194 | if err != nil { 195 | t.Fatal(err) 196 | } 197 | 198 | correctNames := []string{"lnk", "xyz"} 199 | if !reflect.DeepEqual(names, correctNames) { 200 | t.Fatalf("wrong names: %v != %v", names, correctNames) 201 | } 202 | 203 | err = c.Delete("xyz") 204 | if err != nil { 205 | t.Fatal(err) 206 | } 207 | 208 | err = db.Verify() 209 | if err != nil { 210 | t.Fatal(err) 211 | } 212 | 213 | _, err = c.Open("lnk") 214 | if err == nil { 215 | t.Fatal("lnk should have been removed") 216 | } 217 | 218 | err = db.Verify() 219 | if err != nil { 220 | t.Fatal(err) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /fdb/mkdir.go: -------------------------------------------------------------------------------- 1 | package fdb 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | // Like os.MkdirAll but new components created have the given UID and GID. 9 | func mkdirAllWithOwner(absPath string, perm os.FileMode, uid, gid int) error { 10 | // From os/path.go. 11 | // Fast path: if we can tell whether path is a directory or file, stop with success or error. 12 | dir, err := os.Stat(absPath) 13 | if err == nil { 14 | if dir.IsDir() { 15 | return nil 16 | } 17 | return &os.PathError{Op: "mkdir", Path: absPath, Err: syscall.ENOTDIR} 18 | } 19 | 20 | // Slow path: make sure parent exists and then call Mkdir for path. 21 | i := len(absPath) 22 | for i > 0 && os.IsPathSeparator(absPath[i-1]) { // Skip trailing path separator. 23 | i-- 24 | } 25 | 26 | j := i 27 | for j > 0 && !os.IsPathSeparator(absPath[j-1]) { // Scan backward over element. 28 | j-- 29 | } 30 | 31 | if j > 1 { 32 | // Create parent 33 | err = mkdirAllWithOwner(absPath[0:j-1], perm, uid, gid) 34 | if err != nil { 35 | return err 36 | } 37 | } 38 | 39 | // Parent now exists; invoke Mkdir and use its result. 40 | err = os.Mkdir(absPath, perm) 41 | if err != nil { 42 | // Handle arguments like "foo/." by double-checking that directory 43 | // doesn't exist. 44 | dir, err1 := os.Lstat(absPath) 45 | if err1 == nil && dir.IsDir() { 46 | return nil 47 | } 48 | 49 | return err 50 | } 51 | 52 | if uid >= 0 || gid >= 0 { 53 | if uid < 0 { 54 | uid = os.Getuid() 55 | } 56 | if gid < 0 { 57 | gid = os.Getgid() 58 | } 59 | err = os.Lchown(absPath, uid, gid) // ignore errors in case we aren't root 60 | log.Errore(err, "cannot chown ", absPath) 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /fdb/parseperm.go: -------------------------------------------------------------------------------- 1 | package fdb 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "gopkg.in/hlandau/svcutils.v1/passwd" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | 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]+))?)$`) 16 | 17 | func parsePermissions(r io.Reader) (ps []Permission, erasePaths map[string]struct{}, err error) { 18 | br := bufio.NewReader(r) 19 | Lnum := 0 20 | erasePaths = map[string]struct{}{} 21 | seenPaths := map[string]struct{}{} 22 | 23 | for { 24 | Lnum++ 25 | L, err := br.ReadString('\n') 26 | if err == io.EOF { 27 | break 28 | } 29 | if err != nil { 30 | return nil, nil, err 31 | } 32 | 33 | L = strings.TrimSpace(L) 34 | if L == "" || strings.HasPrefix(L, "#") { 35 | continue 36 | } 37 | 38 | // keys/*/privkey 0640 0750 - - 39 | m := rePermissionLine.FindStringSubmatch(L) 40 | if m == nil { 41 | return nil, nil, fmt.Errorf("line %d: badly formatted line: %q", Lnum, L) 42 | } 43 | 44 | path := filepath.Clean(m[1]) 45 | if path == ".." || strings.HasPrefix(path, "../") || filepath.IsAbs(path) { 46 | return nil, nil, fmt.Errorf("line %d: path must remain within the DB root: %q", Lnum, L) 47 | } 48 | 49 | if _, seen := seenPaths[path]; seen { 50 | return nil, nil, fmt.Errorf("line %d: duplicate path entry: %q", Lnum, L) 51 | } 52 | 53 | seenPaths[path] = struct{}{} 54 | if m[2] == "inherit" { 55 | erasePaths[path] = struct{}{} 56 | continue 57 | } 58 | 59 | fileMode, err := strconv.ParseUint(m[3], 8, 12) 60 | if err != nil { 61 | return nil, nil, fmt.Errorf("line %d: invalid file mode: %q", Lnum, m[3]) 62 | } 63 | 64 | dirMode, err := strconv.ParseUint(m[4], 8, 12) 65 | if err != nil { 66 | return nil, nil, fmt.Errorf("line %d: invalid dir mode: %q", Lnum, m[4]) 67 | } 68 | 69 | // Validate UID 70 | uid := m[6] 71 | if uid == "-" { 72 | uid = "" 73 | } 74 | if uid != "" && uid != "$r" { 75 | _, err := passwd.ParseUID(uid) 76 | if err != nil { 77 | return nil, nil, fmt.Errorf("line %d: invalid UID: %q: %v", Lnum, uid, err) 78 | } 79 | } 80 | 81 | // Validate GID 82 | gid := m[7] 83 | if gid == "-" { 84 | gid = "" 85 | } 86 | if gid != "" && gid != "$r" { 87 | _, err = passwd.ParseGID(gid) 88 | if err != nil { 89 | return nil, nil, fmt.Errorf("line %d: invalid GID: %q: %v", Lnum, gid, err) 90 | } 91 | } 92 | 93 | // 94 | p := Permission{ 95 | Path: path, 96 | FileMode: os.FileMode(fileMode), 97 | DirMode: os.FileMode(dirMode), 98 | UID: uid, 99 | GID: gid, 100 | } 101 | 102 | ps = append(ps, p) 103 | } 104 | 105 | return ps, erasePaths, nil 106 | } 107 | -------------------------------------------------------------------------------- /fdb/parseperm_test.go: -------------------------------------------------------------------------------- 1 | // +build cgo 2 | 3 | package fdb 4 | 5 | import ( 6 | "reflect" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestParsePerm(t *testing.T) { 12 | var tests = []struct { 13 | In string 14 | Out []Permission 15 | Erase map[string]struct{} 16 | }{ 17 | {``, nil, map[string]struct{}{}}, 18 | {` 19 | 20 | # this is a comment 21 | foo/bar 0644 0755 22 | foo/*/baz 0640 0750 23 | alpha 0644 0755 root root 24 | beta 0644 0755 42 42 25 | gamma 0644 0755 $r $r 26 | delta inherit 27 | x 0644 0755 root - 28 | y 0644 0755 - root 29 | `, []Permission{ 30 | {Path: "foo/bar", FileMode: 0644, DirMode: 0755}, 31 | {Path: "foo/*/baz", FileMode: 0640, DirMode: 0750}, 32 | {Path: "alpha", FileMode: 0644, DirMode: 0755, UID: "root", GID: "root"}, 33 | {Path: "beta", FileMode: 0644, DirMode: 0755, UID: "42", GID: "42"}, 34 | {Path: "gamma", FileMode: 0644, DirMode: 0755, UID: "$r", GID: "$r"}, 35 | {Path: "x", FileMode: 0644, DirMode: 0755, UID: "root", GID: ""}, 36 | {Path: "y", FileMode: 0644, DirMode: 0755, UID: "", GID: "root"}, 37 | }, map[string]struct{}{"delta": struct{}{}}}, 38 | } 39 | 40 | for _, tst := range tests { 41 | ps, erase, err := parsePermissions(strings.NewReader(tst.In)) 42 | if err != nil { 43 | t.Fatalf("error parsing permissions: %v", err) 44 | } 45 | 46 | if !reflect.DeepEqual(ps, tst.Out) { 47 | t.Fatalf("permissions don't match: got %#v, expected %#v", ps, tst.Out) 48 | } 49 | 50 | if !reflect.DeepEqual(erase, tst.Erase) { 51 | t.Fatalf("erase list doesn't match: got %v, expected %v", erase, tst.Erase) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /fdb/tempsymlink.go: -------------------------------------------------------------------------------- 1 | package fdb 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strconv" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | var rand uint32 12 | var randmu sync.Mutex 13 | 14 | func reseed() uint32 { 15 | return uint32(time.Now().UnixNano() + int64(os.Getpid())) 16 | } 17 | 18 | func nextSuffix() string { 19 | randmu.Lock() 20 | r := rand 21 | if r == 0 { 22 | r = reseed() 23 | } 24 | r = r*1664525 + 1013904223 25 | rand = r 26 | randmu.Unlock() 27 | return strconv.Itoa(int(1e9 + r%1e9))[1:] 28 | } 29 | 30 | func tempSymlink(target string, fromDir string) (tmpName string, err error) { 31 | nconflict := 0 32 | for i := 0; i < 10000; i++ { 33 | tmpName = filepath.Join(fromDir, "symlink."+nextSuffix()) 34 | err = os.Symlink(target, tmpName) 35 | if os.IsExist(err) { 36 | if nconflict++; nconflict > 10 { 37 | randmu.Lock() 38 | rand = reseed() 39 | randmu.Unlock() 40 | } 41 | continue 42 | } 43 | break 44 | } 45 | return 46 | } 47 | -------------------------------------------------------------------------------- /fdb/util.go: -------------------------------------------------------------------------------- 1 | package fdb 2 | 3 | import ( 4 | "io/ioutil" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Read a file as a string. Use like this: 10 | // 11 | // s, err := String(c.Open("file")) 12 | // 13 | func String(rs ReadStream, err error) (string, error) { 14 | if err != nil { 15 | return "", err 16 | } 17 | 18 | defer rs.Close() 19 | b, err := ioutil.ReadAll(rs) 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | return string(b), nil 25 | } 26 | 27 | // Read a file as []byte. Use like this: 28 | // 29 | // s, err := Bytes(c.Open("file")) 30 | // 31 | func Bytes(rs ReadStream, err error) ([]byte, error) { 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | defer rs.Close() 37 | b, err := ioutil.ReadAll(rs) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return b, nil 43 | } 44 | 45 | // Create an empty file, overwriting it if it exists. 46 | func CreateEmpty(c *Collection, name string) error { 47 | f, err := c.Create(name) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | f.Close() 53 | return nil 54 | } 55 | 56 | // Determine whether a file exists. 57 | func Exists(c *Collection, name string) bool { 58 | f, err := c.Open(name) 59 | if err != nil { 60 | return false 61 | } 62 | defer f.Close() 63 | return true 64 | } 65 | 66 | // Write bytes to a file with the given name in the given collection. 67 | // 68 | // The byte arrays are concatenated in the given order. 69 | func WriteBytes(c *Collection, name string, bs ...[]byte) error { 70 | f, err := c.Create(name) 71 | if err != nil { 72 | return err 73 | } 74 | defer f.CloseAbort() 75 | 76 | for _, b := range bs { 77 | _, err = f.Write(b) 78 | if err != nil { 79 | return err 80 | } 81 | } 82 | 83 | f.Close() 84 | return nil 85 | } 86 | 87 | // Retrieve an unsigned integer in decimal form from a file with the given name 88 | // in the given collection. bits is passed to ParseUint. 89 | func Uint(c *Collection, name string, bits int) (uint64, error) { 90 | s, err := String(c.Open(name)) 91 | if err != nil { 92 | return 0, err 93 | } 94 | 95 | s = strings.TrimSpace(s) 96 | return strconv.ParseUint(s, 10, bits) 97 | } 98 | -------------------------------------------------------------------------------- /hooks/hooks.go: -------------------------------------------------------------------------------- 1 | // Package hooks provides functions to invoke a directory of executable hooks, 2 | // used to provide arbitrary handling of significant events. 3 | package hooks 4 | 5 | import ( 6 | "fmt" 7 | deos "github.com/hlandau/goutils/os" 8 | "github.com/hlandau/xlog" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | // Log site. 16 | var log, Log = xlog.New("acme.hooks") 17 | 18 | // The recommended hook paths are the paths at which executable hooks are 19 | // looked for. On POSIX-like systems, this is usually "/usr/lib/acme/hooks" and 20 | // "/usr/libexec/acme/hooks". 21 | var RecommendedPaths []string 22 | 23 | // The default hook paths default to the recommended hook paths but could be 24 | // changed at runtime. 25 | var DefaultPaths []string 26 | 27 | // Do not use. For build-time use by distributions only. If set to a non-empty 28 | // string at build time, DefaultPaths is set to a slice containing only this 29 | // value. 30 | var DefaultPath string 31 | 32 | // Provides contextual configuration information when executing a hook. 33 | type Context struct { 34 | // The hook directories to use. If zero-length, uses DefaultPaths. 35 | HookDirs []string 36 | 37 | // The state directory to report. Required. 38 | StateDir string 39 | 40 | // Arbitrary environment variables to set. 41 | Env map[string]string 42 | } 43 | 44 | func init() { 45 | // Allow overriding at build time. 46 | if DefaultPath != "" { 47 | DefaultPaths = []string{DefaultPath} 48 | RecommendedPaths = DefaultPaths 49 | return 50 | } 51 | 52 | DefaultPaths = []string{"/usr/libexec/acme/hooks", "/usr/lib/acme/hooks"} 53 | 54 | // Put the preferred directory first. 55 | prefDir, err := preferredHookDir(DefaultPaths) 56 | if err == nil { 57 | newDefaultPaths := []string{prefDir} 58 | for _, dp := range DefaultPaths { 59 | if dp != prefDir { 60 | newDefaultPaths = append(newDefaultPaths, dp) 61 | } 62 | } 63 | DefaultPaths = newDefaultPaths 64 | } 65 | 66 | RecommendedPaths = DefaultPaths 67 | } 68 | 69 | // Notifies hook programs that a live symlink has been updated. 70 | // 71 | // If hookDirectory is "", DefaultHookPath is used. stateDirectory and 72 | // hostnames are passed as information to the hooks. 73 | func NotifyLiveUpdated(ctx *Context, hostnames []string) error { 74 | if len(hostnames) == 0 { 75 | return nil 76 | } 77 | 78 | hostnameList := strings.Join(hostnames, "\n") + "\n" 79 | _, err := runParts(ctx, []byte(hostnameList), "live-updated") 80 | if err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | 87 | // Invokes HTTP challenge start hooks. 88 | // 89 | // installed indicates whether at least one hook script indicated success. err 90 | // could still be returned in this case if an error occurs while executing some 91 | // other hook. 92 | func ChallengeHTTPStart(ctx *Context, hostname, targetFileName, token, ka string) (installed bool, err error) { 93 | return runParts(ctx, []byte(ka), 94 | "challenge-http-start", hostname, targetFileName, token) 95 | } 96 | 97 | func ChallengeHTTPStop(ctx *Context, hostname, targetFileName, token, ka string) error { 98 | _, err := runParts(ctx, []byte(ka), 99 | "challenge-http-stop", hostname, targetFileName, token) 100 | return err 101 | } 102 | 103 | func ChallengeTLSSNIStart(ctx *Context, hostname, targetFileName, validationName1, validationName2 string, pem string) (installed bool, err error) { 104 | return runParts(ctx, []byte(pem), 105 | "challenge-tls-sni-start", hostname, targetFileName, validationName1, validationName2) 106 | } 107 | 108 | func ChallengeTLSSNIStop(ctx *Context, hostname, targetFileName, validationName1, validationName2 string, pem string) (installed bool, err error) { 109 | return runParts(ctx, []byte(pem), 110 | "challenge-tls-sni-stop", hostname, targetFileName, validationName1, validationName2) 111 | } 112 | 113 | func challengeDNS(ctx *Context, op, hostname, targetFileName, body string) (installed bool, err error) { 114 | wildcardFlag := "" 115 | if strings.HasPrefix(hostname, "*.") { 116 | hostname = hostname[2:] 117 | wildcardFlag = "wildcard" 118 | } 119 | return runParts(ctx, nil, op, hostname, targetFileName, body, wildcardFlag) 120 | } 121 | 122 | func ChallengeDNSStart(ctx *Context, hostname, targetFileName, body string) (installed bool, err error) { 123 | return challengeDNS(ctx, "challenge-dns-start", hostname, targetFileName, body) 124 | } 125 | 126 | func ChallengeDNSStop(ctx *Context, hostname, targetFileName, body string) (uninstalled bool, err error) { 127 | return challengeDNS(ctx, "challenge-dns-stop", hostname, targetFileName, body) 128 | } 129 | 130 | func mergeEnvMap(m map[string]string, e []string) { 131 | for _, x := range e { 132 | parts := strings.SplitN(x, "=", 2) 133 | if len(parts) < 2 { 134 | continue 135 | } 136 | m[parts[0]] = parts[1] 137 | } 138 | } 139 | 140 | func flattenEnvMap(m map[string]string) []string { 141 | var e []string 142 | for k, v := range m { 143 | e = append(e, k+"="+v) 144 | } 145 | return e 146 | } 147 | 148 | func mergeEnv(envs ...[]string) []string { 149 | m := map[string]string{} 150 | for _, env := range envs { 151 | mergeEnvMap(m, env) 152 | } 153 | return flattenEnvMap(m) 154 | } 155 | 156 | // Implements functionality similar to the "run-parts" command on many distros. 157 | // Implementations vary, so it is reimplemented here. 158 | func runParts(ctx *Context, stdinData []byte, args ...string) (anySucceeded bool, err error) { 159 | dirs := ctx.HookDirs 160 | if len(dirs) == 0 { 161 | dirs = DefaultPaths 162 | } 163 | 164 | var dirs2 []string 165 | for _, directory := range dirs { 166 | fi, err := os.Stat(directory) 167 | if err == nil { 168 | // Do not execute a world-writable directory. 169 | if (fi.Mode() & 02) != 0 { 170 | return false, fmt.Errorf("refusing to execute hooks, directory is world-writable: %s", directory) 171 | } 172 | 173 | dirs2 = append(dirs2, directory) 174 | } else if !os.IsNotExist(err) { 175 | return false, err 176 | } 177 | } 178 | 179 | if len(dirs2) == 0 { 180 | // None of the directories exist; nothing to do. 181 | return false, nil 182 | } 183 | 184 | env := mergeEnv(os.Environ(), flattenEnvMap(ctx.Env), []string{"ACME_STATE_DIR=" + ctx.StateDir}) 185 | 186 | var ms []string 187 | for _, directory := range dirs2 { 188 | m, err := filepath.Glob(filepath.Join(directory, "*")) 189 | if err != nil { 190 | return false, err 191 | } 192 | 193 | ms = append(ms, m...) 194 | } 195 | 196 | for _, m := range ms { 197 | fi, err := os.Stat(m) 198 | if err != nil { 199 | log.Errore(err, "hook: ", m) 200 | continue 201 | } 202 | 203 | // Ignore 'hidden' files. 204 | if strings.HasPrefix(fi.Name(), ".") { 205 | continue 206 | } 207 | 208 | mode := fi.Mode() 209 | mType := mode & os.ModeType 210 | 211 | // Make sure it's not a directory, device, socket, pipe, etc. 212 | if mType != 0 && mType != os.ModeSymlink { 213 | log.Debugf("cannot execute hook, not a file: %s", m) 214 | continue 215 | } 216 | 217 | // Yes, this is vulnerable to race conditions; it's just to stop people 218 | // from shooting themselves in the foot. 219 | if (mode & 02) != 0 { 220 | log.Errorf("refusing to execute world-writable hook: %s", m) 221 | continue 222 | } 223 | 224 | // This doesn't check which mode bit (user,group,world) is applicable to 225 | // us but avoids cluttering the log for non-executable files. 226 | if (mode & 0111) == 0 { 227 | log.Debugf("cannot execute non-executable hook: %s", m) 228 | continue 229 | } 230 | 231 | var cmd *exec.Cmd 232 | if shouldSudoFile(m, fi) { 233 | log.Debugf("calling hook script (with sudo): %s", m) 234 | args2 := []string{"-n", "--", m} 235 | args2 = append(args2, args...) 236 | cmd = exec.Command("sudo", args2...) 237 | } else { 238 | log.Debugf("calling hook script: %s", m) 239 | cmd = exec.Command(m, args...) 240 | } 241 | 242 | cmd.Dir = "/" 243 | cmd.Env = env 244 | 245 | pipeR, pipeW, err := os.Pipe() 246 | if err != nil { 247 | return anySucceeded, err 248 | } 249 | 250 | defer pipeR.Close() 251 | go func() { 252 | defer pipeW.Close() 253 | pipeW.Write([]byte(stdinData)) 254 | }() 255 | 256 | cmd.Stdin = pipeR 257 | cmd.Stdout = os.Stdout 258 | cmd.Stderr = os.Stderr 259 | err = cmd.Run() 260 | logFailedExecution(m, err) 261 | if err == nil { 262 | anySucceeded = true 263 | } 264 | } 265 | 266 | return anySucceeded, nil 267 | } 268 | 269 | func logFailedExecution(hookPath string, err error) { 270 | if err == nil { 271 | return 272 | } 273 | 274 | exitCode, err2 := deos.GetExitCode(err) 275 | if err2 != nil { 276 | // Not an error code. ??? 277 | log.Errore(err2, "hook script: ", hookPath) 278 | return 279 | } 280 | 281 | switch exitCode { 282 | case 42: 283 | // Unsupported event type for this hook. Don't log anything; this is OK. 284 | default: 285 | log.Errore(err, "hook script: ", hookPath) 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /hooks/hooks_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | const fileTpl = `#!/bin/sh 12 | %s 13 | [ -n "$ACME_STATE_DIR" ] || exit 1 14 | echo NOTIFY-%d >> "$ACME_STATE_DIR/log" 15 | while read line; do 16 | echo L-$line >> "$ACME_STATE_DIR/log" 17 | done` 18 | 19 | var answer = []string{ 20 | `NOTIFY-0 21 | L-a.b 22 | L-c.d 23 | L-e.f.g 24 | NOTIFY-1 25 | L-a.b 26 | L-c.d 27 | L-e.f.g 28 | `, 29 | `NOTIFY-0 30 | L-a.b 31 | L-c.d 32 | L-e.f.g 33 | NOTIFY-3 34 | L-a.b 35 | L-c.d 36 | L-e.f.g 37 | `, 38 | } 39 | 40 | func TestNotify(t *testing.T) { 41 | dir, err := ioutil.TempDir("", "acme-notify-test") 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | defer os.RemoveAll(dir) 47 | 48 | notify1 := filepath.Join(dir, "notify1") 49 | notify2 := filepath.Join(dir, "notify2") 50 | notifyDirs := []string{notify1, notify2} 51 | 52 | for i := 0; i < 2; i++ { 53 | err = Replace(notifyDirs, "alpha", fmt.Sprintf(fileTpl, "", i*2+0)) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | err = Replace(notifyDirs, "beta", fmt.Sprintf(fileTpl, "#!acmetool-managed!#", i*2+1)) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | os.Remove(filepath.Join(dir, "log")) 64 | 65 | ctx := &Context{ 66 | HookDirs: notifyDirs, 67 | StateDir: dir, 68 | } 69 | err = NotifyLiveUpdated(ctx, []string{"a.b", "c.d", "e.f.g"}) 70 | if err != nil { 71 | t.Fatal(err) 72 | } 73 | 74 | b, err := ioutil.ReadFile(filepath.Join(dir, "log")) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | s := string(b) 80 | if s != answer[i] { 81 | t.Fatalf("mismatch: %v != %v", s, answer[i]) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /hooks/install.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // Given a set of hook directories, returns whether a hook with the given name exists in any of them. 11 | func Exists(hookDirs []string, hookName string) bool { 12 | for _, hookDir := range hookDirs { 13 | _, err := os.Stat(filepath.Join(hookDir, hookName)) 14 | if err == nil { 15 | return true 16 | } 17 | } 18 | return false 19 | } 20 | 21 | // Installs a hook in the hooks directory. If the file already exists, it is 22 | // not overwritten unless it contains the string "#!acmetool-managed!#" in its 23 | // first 4096 bytes. 24 | func Replace(hookDirs []string, name, data string) error { 25 | if len(hookDirs) == 0 { 26 | hookDirs = DefaultPaths 27 | } 28 | if len(hookDirs) == 0 { 29 | return fmt.Errorf("no hooks directory configured") 30 | } 31 | 32 | // Find the directory in the filesystem which has the most parent components 33 | // of it already created. 34 | hookDirectory, err := preferredHookDir(hookDirs) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | filename := filepath.Join(hookDirectory, name) 40 | 41 | isManaged, err := isManagedFile(filename) 42 | if os.IsNotExist(err) || (err == nil && isManaged) { 43 | return writeHook(filename, data) 44 | } 45 | 46 | return err 47 | } 48 | 49 | func preferredHookDir(hookDirs []string) (hookDirectory string, err error) { 50 | bestLA := 255 51 | for _, dir := range hookDirs { 52 | var la int 53 | la, err = levelsAbsent(dir) 54 | if err != nil { 55 | return 56 | } 57 | 58 | if la < bestLA { 59 | hookDirectory = dir 60 | bestLA = la 61 | } 62 | } 63 | if hookDirectory == "" { 64 | hookDirectory = hookDirs[0] 65 | } 66 | 67 | return 68 | } 69 | 70 | func levelsAbsent(dir string) (int, error) { 71 | for i := 0; dir != "." && dir != "/"; i++ { 72 | _, err := os.Stat(dir) 73 | if err == nil { 74 | return i, nil 75 | } 76 | 77 | dir = filepath.Join(dir, "..") 78 | } 79 | 80 | return 255, fmt.Errorf("cannot find a level which exists") 81 | } 82 | 83 | func writeHook(filename, data string) error { 84 | err := os.MkdirAll(filepath.Dir(filename), 0755) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) 90 | if err != nil { 91 | return nil 92 | } 93 | 94 | defer f.Close() 95 | f.Write([]byte(data)) 96 | 97 | return nil 98 | } 99 | 100 | func isManagedFile(filename string) (bool, error) { 101 | f, err := os.Open(filename) 102 | if err != nil { 103 | return false, err 104 | } 105 | 106 | defer f.Close() 107 | b := make([]byte, 4096) 108 | n, _ := f.Read(b) 109 | b = b[0:n] 110 | return bytes.Index(b, []byte("#!acmetool-managed!#")) >= 0, nil 111 | } 112 | -------------------------------------------------------------------------------- /hooks/os.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | deos "github.com/hlandau/goutils/os" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | func runningAsRoot() bool { 10 | return os.Getuid() == 0 11 | } 12 | 13 | func fileIsScript(fn string) bool { 14 | f, err := os.Open(fn) 15 | if err != nil { 16 | return false 17 | } 18 | defer f.Close() 19 | var b [2]byte 20 | n, _ := f.Read(b[:]) 21 | if n < 2 { 22 | return false 23 | } 24 | 25 | return string(b[:]) == "#!" 26 | } 27 | 28 | // Vulnerable to race conditions, but this is just a check. sudo enforces all 29 | // security properties. 30 | func shouldSudoFile(fn string, fi os.FileInfo) bool { 31 | if runningAsRoot() { 32 | return false 33 | } 34 | 35 | _, err := exec.LookPath("sudo") 36 | if err != nil { 37 | return false 38 | } 39 | 40 | // Only setuid files if the setuid bit is set. 41 | if (fi.Mode() & os.ModeSetuid) == 0 { 42 | return false 43 | } 44 | 45 | // Don't sudo anything which appears to be setuid'd for a non-root user. 46 | // This doesn't really buy us anything security-wise, but it's not what 47 | // we're expecting. 48 | uid, err := deos.GetFileUID(fi) 49 | if err != nil || uid != 0 { 50 | return false 51 | } 52 | 53 | // Make sure the file is a script, otherwise we can just execute it directly. 54 | return fileIsScript(fn) 55 | } 56 | -------------------------------------------------------------------------------- /interaction/auto.go: -------------------------------------------------------------------------------- 1 | package interaction 2 | 3 | import ( 4 | "fmt" 5 | "github.com/hlandau/xlog" 6 | ) 7 | 8 | var log, Log = xlog.New("acme.interactor") 9 | 10 | // Used by Auto. If this is set, only autoresponses can be used. Any challenge 11 | // without an autoresponse fails. --batch. 12 | var NonInteractive = false 13 | 14 | type autoInteractor struct{} 15 | 16 | // Interactor which automatically uses the most suitable challenge method. 17 | var Auto Interactor = autoInteractor{} 18 | 19 | // Used by Auto. If this is non-nil, all challenges are directed to it. There 20 | // is no fallback if the interceptor fails. Autoresponses and NonInteractive 21 | // take precedence over this. 22 | var Interceptor Interactor 23 | 24 | // Used by Auto. Do not use the Dialog mode. --stdio. 25 | var NoDialog = false 26 | 27 | var responsesReceived = map[string]*Response{} 28 | 29 | func (ai autoInteractor) Prompt(c *Challenge) (*Response, error) { 30 | res, err := ai.prompt(c) 31 | if err == nil && c.UniqueID != "" { 32 | responsesReceived[c.UniqueID] = res 33 | } 34 | 35 | return res, err 36 | } 37 | 38 | // Returns a map from challenge UniqueIDs to responses received for those 39 | // UniqueIDs. Do not mutate the returned map. 40 | func ResponsesReceived() map[string]*Response { 41 | return responsesReceived 42 | } 43 | 44 | func (autoInteractor) prompt(c *Challenge) (*Response, error) { 45 | r, err := Responder.Prompt(c) 46 | if err == nil || c.Implicit { 47 | return r, err 48 | } 49 | log.Infoe(err, "interaction auto-responder couldn't give a canned response") 50 | 51 | if NonInteractive { 52 | return nil, fmt.Errorf("cannot prompt the user: currently non-interactive; try running without --batch flag") 53 | } 54 | 55 | if Interceptor != nil { 56 | return Interceptor.Prompt(c) 57 | } 58 | 59 | if !NoDialog { 60 | r, err := Dialog.Prompt(c) 61 | if err == nil { 62 | return r, nil 63 | } 64 | } 65 | 66 | return Stdio.Prompt(c) 67 | } 68 | 69 | type dummySink struct{} 70 | 71 | func (dummySink) Close() error { 72 | return nil 73 | } 74 | 75 | func (dummySink) SetProgress(n, ofM int) { 76 | } 77 | 78 | func (dummySink) SetStatusLine(status string) { 79 | } 80 | 81 | func (autoInteractor) Status(info *StatusInfo) (StatusSink, error) { 82 | if NonInteractive { 83 | return dummySink{}, nil 84 | } 85 | 86 | if Interceptor != nil { 87 | s, err := Interceptor.Status(info) 88 | if err != nil { 89 | return dummySink{}, nil 90 | } 91 | return s, err 92 | } 93 | 94 | if !NoDialog { 95 | r, err := Dialog.Status(info) 96 | if err == nil { 97 | return r, nil 98 | } 99 | } 100 | 101 | return Stdio.Status(info) 102 | } 103 | -------------------------------------------------------------------------------- /interaction/dialog.go: -------------------------------------------------------------------------------- 1 | package interaction 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "sync" 10 | "syscall" 11 | ) 12 | 13 | type dialogInteractor struct{} 14 | 15 | // Invokes a dialog program to create terminal dialog boxes. Fails if no such 16 | // program is available. 17 | var Dialog Interactor = dialogInteractor{} 18 | 19 | type dialogStatusSink struct { 20 | closeChan chan struct{} 21 | closeOnce sync.Once 22 | closedChan chan struct{} 23 | updateChan chan struct{} 24 | pipeW *os.File 25 | infoMutex sync.Mutex 26 | statusLine string 27 | progress int 28 | cmd *exec.Cmd 29 | } 30 | 31 | func (ss *dialogStatusSink) Close() error { 32 | ss.closeOnce.Do(func() { 33 | close(ss.closeChan) 34 | }) 35 | <-ss.closedChan 36 | return nil 37 | } 38 | 39 | func (ss *dialogStatusSink) SetProgress(n, ofM int) { 40 | ss.infoMutex.Lock() 41 | defer ss.infoMutex.Unlock() 42 | ss.progress = int((float64(n) / float64(ofM)) * 100) 43 | ss.notify() 44 | } 45 | 46 | func (ss *dialogStatusSink) SetStatusLine(status string) { 47 | ss.infoMutex.Lock() 48 | defer ss.infoMutex.Unlock() 49 | ss.statusLine = status 50 | ss.notify() 51 | } 52 | 53 | func (ss *dialogStatusSink) notify() { 54 | select { 55 | case ss.updateChan <- struct{}{}: 56 | default: 57 | } 58 | } 59 | 60 | func (ss *dialogStatusSink) loop() { 61 | A: 62 | for { 63 | select { 64 | case <-ss.closeChan: 65 | break A 66 | case <-ss.updateChan: 67 | ss.infoMutex.Lock() 68 | statusLine := ss.statusLine 69 | progress := ss.progress 70 | ss.infoMutex.Unlock() 71 | 72 | fmt.Fprintf(ss.pipeW, "XXX\n%d\n%s\nXXX\n", progress, statusLine) 73 | } 74 | } 75 | 76 | ss.pipeW.Close() 77 | ss.cmd.Wait() 78 | close(ss.closedChan) 79 | } 80 | 81 | func (dialogInteractor) Status(c *StatusInfo) (StatusSink, error) { 82 | cmdName, _ := findDialogCommand() 83 | if cmdName == "" { 84 | return nil, fmt.Errorf("cannot find whiptail or dialog binary in path") 85 | } 86 | 87 | width := "78" 88 | height := fmt.Sprintf("%d", strings.Count(c.StatusLine, "\n")+5) 89 | 90 | var opts []string 91 | if c.Title != "" { 92 | opts = append(opts, "--backtitle", "ACME", "--title", c.Title) 93 | } 94 | 95 | opts = append(opts, "--gauge", c.StatusLine, height, width, "0") 96 | 97 | pipeR, pipeW, err := os.Pipe() 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | defer pipeR.Close() 103 | 104 | cmd := exec.Command(cmdName, opts...) 105 | cmd.Stdin = pipeR 106 | cmd.Stdout = os.Stdout 107 | cmd.Stderr = os.Stderr 108 | 109 | err = cmd.Start() 110 | if err != nil { 111 | pipeW.Close() 112 | return nil, err 113 | } 114 | 115 | ss := &dialogStatusSink{ 116 | closeChan: make(chan struct{}), 117 | closedChan: make(chan struct{}), 118 | updateChan: make(chan struct{}, 10), 119 | pipeW: pipeW, 120 | cmd: cmd, 121 | } 122 | 123 | go ss.loop() 124 | return ss, nil 125 | } 126 | 127 | func (dialogInteractor) Prompt(c *Challenge) (*Response, error) { 128 | cmdName, cmdType := findDialogCommand() 129 | if cmdName == "" { 130 | return nil, fmt.Errorf("cannot find whiptail or dialog binary in path") 131 | } 132 | 133 | width := "78" 134 | height := "49" 135 | yesLabelArg := "--yes-label" 136 | noLabelArg := "--no-label" 137 | noTagsArg := "--no-tags" 138 | if cmdType == "whiptail" { 139 | yesLabelArg = "--yes-button" 140 | noLabelArg = "--no-button" 141 | noTagsArg = "--notags" 142 | } 143 | 144 | var opts []string 145 | if c.Title != "" { 146 | opts = append(opts, "--backtitle", "ACME", "--title", c.Title) 147 | } 148 | 149 | var err error 150 | var pipeR *os.File 151 | var pipeW *os.File 152 | 153 | switch c.ResponseType { 154 | case RTAcknowledge: 155 | opts = append(opts, "--msgbox", c.Body, height, width) 156 | case RTYesNo: 157 | yesLabel := c.YesLabel 158 | if yesLabel == "" { 159 | yesLabel = "Yes" 160 | } 161 | noLabel := c.NoLabel 162 | if noLabel == "" { 163 | noLabel = "No" 164 | } 165 | opts = append(opts, yesLabelArg, yesLabel, noLabelArg, noLabel, "--yesno", c.Body, height, width) 166 | case RTLineString: 167 | pipeR, pipeW, err = os.Pipe() 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | defer pipeR.Close() 173 | defer pipeW.Close() 174 | opts = append(opts, "--output-fd", "3", "--inputbox", c.Body, height, width) 175 | case RTSelect: 176 | pipeR, pipeW, err = os.Pipe() 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | defer pipeR.Close() 182 | defer pipeW.Close() 183 | opts = append(opts, "--output-fd", "3", noTagsArg, "--menu", c.Body, height, width, "5") 184 | for _, o := range c.Options { 185 | opts = append(opts, o.Value, o.Title) 186 | } 187 | } 188 | 189 | cmd := exec.Command(cmdName, opts...) 190 | 191 | cmd.Stdin = os.Stdin 192 | cmd.Stdout = os.Stdout 193 | cmd.Stderr = os.Stderr 194 | if pipeW != nil { 195 | cmd.ExtraFiles = append(cmd.ExtraFiles, pipeW) 196 | } 197 | 198 | rc, xerr, err := runCommand(cmd) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | // If we get error code >1 (particularly 255) the dialog command probably 204 | // doesn't support some option we pass it. Return an error, which should make 205 | // us fall back to stdio. 206 | if rc > 1 { 207 | return nil, xerr 208 | } 209 | 210 | res := &Response{} 211 | if pipeW != nil { 212 | pipeW.Close() 213 | } 214 | 215 | switch c.ResponseType { 216 | case RTLineString, RTSelect: 217 | b, err := ioutil.ReadAll(pipeR) 218 | if err != nil { 219 | return nil, err 220 | } 221 | 222 | res.Value = string(b) 223 | fallthrough 224 | case RTYesNo, RTAcknowledge: 225 | if rc != 0 && rc != 1 { 226 | return nil, xerr 227 | } 228 | res.Cancelled = (rc == 1) 229 | } 230 | 231 | return res, nil 232 | } 233 | 234 | var dialogCommand = "" 235 | var dialogCommandType = "" 236 | 237 | func findDialogCommand() (string, string) { 238 | if dialogCommand != "" { 239 | return dialogCommand, dialogCommandType 240 | } 241 | 242 | // not using whiptail for now, see #18 243 | for _, s := range []string{"dialog"} { 244 | p, err := exec.LookPath(s) 245 | if err == nil { 246 | dialogCommand = p 247 | dialogCommandType = s 248 | return dialogCommand, dialogCommandType 249 | } 250 | } 251 | 252 | return "", "" 253 | } 254 | 255 | func runCommand(cmd *exec.Cmd) (int, error, error) { 256 | err := cmd.Run() 257 | if err == nil { 258 | return 0, nil, nil 259 | } 260 | 261 | if e, ok := err.(*exec.ExitError); ok { 262 | if ws, ok := e.Sys().(syscall.WaitStatus); ok { 263 | return ws.ExitStatus(), err, nil 264 | } 265 | } 266 | 267 | return 255, err, err 268 | } 269 | -------------------------------------------------------------------------------- /interaction/interaction.go: -------------------------------------------------------------------------------- 1 | // Package interaction provides facilities for asking the user questions, via 2 | // dialogs or stdio. 3 | package interaction 4 | 5 | // Interaction mode. Specifies the type of response requested from the user. 6 | type ResponseType int 7 | 8 | const ( 9 | // Acknowledgement only. Show notice and require user to acknowledge before 10 | // continuing. Response fields ignored. 11 | RTAcknowledge ResponseType = iota 12 | 13 | // Show notice and ask user to agree/disagree. Response has Cancelled set 14 | // if user disagreed. 15 | RTYesNo 16 | 17 | // Require user to enter a single-line string, returned as the Value of the 18 | // Response. 19 | RTLineString 20 | 21 | // Require user to select from a number of options. 22 | RTSelect 23 | ) 24 | 25 | // A challenge prompt to be shown to the user. 26 | type Challenge struct { 27 | ResponseType ResponseType // The response type. 28 | 29 | Title string // Title to be used for e.g. a dialog box if shown. 30 | Body string // The text to be shown to the user. May be multiple lines. 31 | 32 | YesLabel string // Label to use for RTYesNo 'Yes' label. 33 | NoLabel string // Label to use for RTYesNo 'No' label. 34 | 35 | // Prompt line used for stdio prompts. For RTAcknowledge, defaults to 'Press 36 | // Return to continue.' or similar. For RTYesNo, defaults to 'Agree? [Yn]' 37 | // or similar. 38 | Prompt string 39 | 40 | // Challenge type unique identifier. This identifies the meaning of the 41 | // dialog and can be used to respond automatically to known dialogs. 42 | // Optional. 43 | UniqueID string 44 | 45 | // Specifies the options for RTSelect. 46 | Options []Option 47 | 48 | // An implicit challenge will never be shown to the user but may be provided 49 | // by a response file. 50 | Implicit bool 51 | } 52 | 53 | // An option in an RTSelect challenge. 54 | type Option struct { 55 | Title string // Option title. 56 | Value string // Internal value that the option represents. 57 | } 58 | 59 | // A user's response to a prompt. 60 | type Response struct { 61 | Cancelled bool // Set this to true if the user cancelled the challenge. 62 | Value string // Value the user entered, if applicable. 63 | Noninteractive bool // Set to true if the response came from a noninteractive source. 64 | } 65 | 66 | // Specifies the initial parameters for a status dialog. 67 | type StatusInfo struct { 68 | Title string // Title to be used for the status dialog. 69 | StatusLine string // The status line. This may contain multiple lines if desired. 70 | } 71 | 72 | // Used to control a status dialog. 73 | type StatusSink interface { 74 | // Close the dialog and wait for it to terminate. 75 | Close() error 76 | 77 | // Set progress = (n/ofM)%. 78 | SetProgress(n, ofM int) 79 | 80 | // Set the status line(s). You cannot specify a number of lines that exceeds 81 | // the number of lines specified in the initial StatusLine. 82 | SetStatusLine(status string) 83 | } 84 | 85 | // An Interactor facilitates interaction with the user. 86 | type Interactor interface { 87 | // Synchronously present a prompt to the user. 88 | Prompt(*Challenge) (*Response, error) 89 | 90 | // Asynchronously present status information to the user. 91 | Status(*StatusInfo) (StatusSink, error) 92 | } 93 | -------------------------------------------------------------------------------- /interaction/responder.go: -------------------------------------------------------------------------------- 1 | package interaction 2 | 3 | import "fmt" 4 | 5 | type responder struct{} 6 | 7 | var responses = map[string]*Response{} 8 | 9 | // Auto-responder. Provides canned responses if they have been set. Otherwise, 10 | // fails. 11 | var Responder Interactor = responder{} 12 | 13 | func (responder) Status(c *StatusInfo) (StatusSink, error) { 14 | return nil, fmt.Errorf("not supported") 15 | } 16 | 17 | func (responder) Prompt(c *Challenge) (*Response, error) { 18 | if c.UniqueID == "" { 19 | return nil, fmt.Errorf("cannot auto-respond to a challenge without a unique ID") 20 | } 21 | 22 | res := responses[c.UniqueID] 23 | if res == nil { 24 | return nil, fmt.Errorf("unknown unique ID, cannot respond: %#v", c.UniqueID) 25 | } 26 | 27 | return res, nil 28 | } 29 | 30 | // Configures a canned response for the given interaction UniqueID. 31 | func SetResponse(uniqueID string, res *Response) { 32 | res.Noninteractive = true 33 | responses[uniqueID] = res 34 | } 35 | -------------------------------------------------------------------------------- /interaction/stdio.go: -------------------------------------------------------------------------------- 1 | package interaction 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/hlandau/goutils/text" 7 | "github.com/mitchellh/go-wordwrap" 8 | "gopkg.in/cheggaaa/pb.v1" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | ) 14 | 15 | type stdioInteractor struct{} 16 | 17 | // Interactor which uses un-fancy stdio prompts. 18 | var Stdio Interactor = stdioInteractor{} 19 | 20 | type stdioStatusSink struct { 21 | closeChan chan struct{} 22 | closeOnce sync.Once 23 | closedChan chan struct{} 24 | updateChan chan struct{} 25 | infoMutex sync.Mutex 26 | statusLine string 27 | progress int 28 | } 29 | 30 | func (ss *stdioStatusSink) Close() error { 31 | ss.closeOnce.Do(func() { 32 | close(ss.closeChan) 33 | }) 34 | <-ss.closedChan 35 | return nil 36 | } 37 | 38 | func (ss *stdioStatusSink) SetProgress(n, ofM int) { 39 | ss.infoMutex.Lock() 40 | defer ss.infoMutex.Unlock() 41 | ss.progress = int((float64(n) / float64(ofM)) * 100) 42 | ss.notify() 43 | } 44 | 45 | func (ss *stdioStatusSink) SetStatusLine(status string) { 46 | ss.infoMutex.Lock() 47 | defer ss.infoMutex.Unlock() 48 | ss.statusLine = status 49 | ss.notify() 50 | } 51 | 52 | func (ss *stdioStatusSink) notify() { 53 | select { 54 | case ss.updateChan <- struct{}{}: 55 | default: 56 | } 57 | } 58 | 59 | func (ss *stdioStatusSink) loop() { 60 | bar := pb.StartNew(100) 61 | bar.ShowSpeed = false 62 | bar.ShowCounters = false 63 | bar.ShowTimeLeft = false 64 | bar.SetMaxWidth(lineLength) 65 | 66 | A: 67 | for { 68 | select { 69 | case <-ss.closeChan: 70 | break A 71 | case <-ss.updateChan: 72 | ss.infoMutex.Lock() 73 | statusLine := ss.statusLine 74 | idx := strings.IndexByte(statusLine, '\n') 75 | if idx >= 0 { 76 | statusLine = statusLine[0:idx] 77 | } 78 | progress := ss.progress 79 | ss.infoMutex.Unlock() 80 | 81 | bar.Set(progress) 82 | bar.Postfix(" " + statusLine) 83 | } 84 | } 85 | 86 | //bar.Update() 87 | bar.Finish() 88 | close(ss.closedChan) 89 | } 90 | 91 | func (stdioInteractor) Status(c *StatusInfo) (StatusSink, error) { 92 | ss := &stdioStatusSink{ 93 | closeChan: make(chan struct{}), 94 | closedChan: make(chan struct{}), 95 | updateChan: make(chan struct{}, 10), 96 | statusLine: c.StatusLine, 97 | } 98 | 99 | ss.updateChan <- struct{}{} 100 | go ss.loop() 101 | return ss, nil 102 | } 103 | 104 | func (stdioInteractor) Prompt(c *Challenge) (*Response, error) { 105 | switch c.ResponseType { 106 | case RTAcknowledge: 107 | return stdioAcknowledge(c) 108 | case RTYesNo: 109 | return stdioYesNo(c) 110 | case RTLineString: 111 | return stdioLineString(c) 112 | case RTSelect: 113 | return stdioSelect(c) 114 | default: 115 | return nil, fmt.Errorf("unsupported challenge type") 116 | } 117 | } 118 | 119 | func stdioAcknowledge(c *Challenge) (*Response, error) { 120 | p := c.Prompt 121 | if p == "" { 122 | p = "Press Return to continue." 123 | } 124 | 125 | PrintStderrMessage(c.Title, fmt.Sprintf("%s\n\n%s", c.Body, p)) 126 | 127 | waitReturn() 128 | return &Response{}, nil 129 | } 130 | 131 | func PrintStderrMessage(title, body string) { 132 | fmt.Fprintf(os.Stderr, "%s\n%s\n", titleLine(title), wordwrap.WrapString(body, lineLength)) 133 | } 134 | 135 | func stdioYesNo(c *Challenge) (*Response, error) { 136 | p := c.Prompt 137 | if p == "" { 138 | p = "Continue?" 139 | } 140 | 141 | fmt.Fprintf(os.Stderr, `%s 142 | %s 143 | 144 | `, titleLine(c.Title), c.Body) 145 | 146 | yes := waitYN(p) 147 | return &Response{Cancelled: !yes}, nil 148 | } 149 | 150 | func stdioLineString(c *Challenge) (*Response, error) { 151 | p := c.Prompt 152 | if p == "" { 153 | p = ">" 154 | } 155 | 156 | PrintStderrMessage(c.Title, fmt.Sprintf("%s\n\n%s", c.Body, p)) 157 | 158 | v := waitLine() 159 | return &Response{Value: v}, nil 160 | } 161 | 162 | func stdioSelect(c *Challenge) (*Response, error) { 163 | 164 | p := c.Prompt 165 | if p == "" { 166 | p = ">" 167 | } 168 | 169 | PrintStderrMessage(c.Title, fmt.Sprintf("%s\n\n", c.Body)) 170 | 171 | for i, o := range c.Options { 172 | t := o.Title 173 | if t == "" { 174 | t = o.Value 175 | } 176 | fmt.Fprintf(os.Stderr, " %v) %s\n", i+1, t) 177 | } 178 | 179 | fmt.Fprintf(os.Stderr, "%s ", p) 180 | v := strings.TrimSpace(waitLine()) 181 | n, err := strconv.ParseUint(v, 10, 31) 182 | if err != nil || n == 0 || int(n-1) >= len(c.Options) { 183 | return stdioSelect(c) 184 | } 185 | 186 | return &Response{Value: c.Options[int(n-1)].Value}, nil 187 | } 188 | 189 | func waitReturn() { 190 | waitLine() 191 | } 192 | 193 | func waitLine() string { 194 | s, _ := bufio.NewReader(os.Stdin).ReadString('\n') 195 | return strings.TrimRight(s, "\r\n") 196 | } 197 | 198 | func waitYN(prompt string) bool { 199 | r := bufio.NewReader(os.Stdin) 200 | for { 201 | fmt.Fprintf(os.Stderr, "%s [Yn] ", prompt) 202 | s, _ := r.ReadString('\n') 203 | if v, ok := text.ParseBoolUserDefaultYes(s); ok { 204 | return v 205 | } 206 | } 207 | } 208 | 209 | const lineLength = 70 210 | 211 | func repeat(n int) string { 212 | return "--------------------------------------------------------------------------------"[80-n:] 213 | } 214 | 215 | func titleLine(title string) string { 216 | if title != "" { 217 | title = " " + title + " " 218 | } 219 | 220 | n := lineLength/2 - len(title)/2 221 | s := "\n\n" + repeat(n) + title 222 | if len(s) < lineLength { 223 | s += repeat(lineLength - len(s)) 224 | } 225 | return s 226 | } 227 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/hlandau/acmetool/cli" 4 | 5 | func main() { 6 | cli.Main() 7 | } 8 | -------------------------------------------------------------------------------- /redirector/redirector.go: -------------------------------------------------------------------------------- 1 | // Package redirector provides a basic HTTP server for redirecting HTTP 2 | // requests to HTTPS requests and serving ACME HTTP challenge values. 3 | package redirector 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | deos "github.com/hlandau/goutils/os" 9 | "github.com/hlandau/xlog" 10 | "gopkg.in/hlandau/svcutils.v1/chroot" 11 | "gopkg.in/hlandau/svcutils.v1/passwd" 12 | "gopkg.in/tylerb/graceful.v1" 13 | "html" 14 | "net" 15 | "net/http" 16 | "os" 17 | "sync/atomic" 18 | "time" 19 | ) 20 | 21 | var log, Log = xlog.New("acme.redirector") 22 | 23 | // Configuration for redirector. 24 | type Config struct { 25 | Bind string `default:":80" usage:"Bind address"` 26 | ChallengePath string `default:"" usage:"Path containing HTTP challenge files"` 27 | ChallengeGID string `default:"" usage:"GID to chgrp the challenge path to (optional)"` 28 | ReadTimeout time.Duration `default:"" usage:"Maximum duration before timing out read of the request"` 29 | WriteTimeout time.Duration `default:"" usage:"Maximum duration before timing out write of the response"` 30 | StatusCode int `default:"308" usage:"HTTP redirect status code"` 31 | } 32 | 33 | // Simple HTTP to HTTPS redirector. 34 | type Redirector struct { 35 | cfg Config 36 | httpServer graceful.Server 37 | httpListener net.Listener 38 | stopping uint32 39 | } 40 | 41 | // Instantiate an HTTP to HTTPS redirector. 42 | func New(cfg Config) (*Redirector, error) { 43 | r := &Redirector{ 44 | cfg: cfg, 45 | httpServer: graceful.Server{ 46 | Timeout: 100 * time.Millisecond, 47 | NoSignalHandling: true, 48 | Server: &http.Server{ 49 | Addr: cfg.Bind, 50 | ReadTimeout: cfg.ReadTimeout, 51 | WriteTimeout: cfg.WriteTimeout, 52 | }, 53 | }, 54 | } 55 | 56 | if r.cfg.StatusCode == 0 { 57 | r.cfg.StatusCode = 308 58 | } 59 | 60 | // Try and make the challenge path if it doesn't exist. 61 | err := os.MkdirAll(r.cfg.ChallengePath, 0755) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | if r.cfg.ChallengeGID != "" { 67 | err := enforceGID(r.cfg.ChallengeGID, r.cfg.ChallengePath) 68 | if err != nil { 69 | return nil, err 70 | } 71 | } 72 | 73 | l, err := net.Listen("tcp", r.httpServer.Server.Addr) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | r.httpListener = l 79 | 80 | return r, nil 81 | } 82 | 83 | func enforceGID(gid, path string) error { 84 | newGID, err := passwd.ParseGID(gid) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | // So this is a surprisingly complicated dance if we want to be free of 90 | // potentially hazardous race conditions. We have a path. We can't assume 91 | // anything about its ownership, or mode, whether it's a symlink, etc. 92 | // 93 | // The big risk is that someone is able to create a symlink pointing to 94 | // something they want to illicitly access. Note that since /var/run will 95 | // commonly be used and because this directory is world-writeable, ala /tmp, 96 | // this is a real risk. 97 | // 98 | // So we have to make sure we don't follow symlinks. Assume we are running 99 | // as root (necessary, since we're chowning), and that nothing running as 100 | // root is malicious. 101 | // 102 | // We open the directory as a file so we can modify it using that reference 103 | // without worrying about the resolution of the path changing under us. But 104 | // we need to make sure we don't follow symlinks. This requires special OS 105 | // support, alas. 106 | dir, err := deos.OpenNoSymlinks(path) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | defer dir.Close() 112 | 113 | fi, err := dir.Stat() 114 | if err != nil { 115 | return err 116 | } 117 | 118 | // Attributes of the directory can still change, but its type certainly 119 | // can't. This guarantee is enough for our purposes. 120 | if (fi.Mode() & os.ModeType) != os.ModeDir { 121 | return fmt.Errorf("challenge path %#v is not a directory", path) 122 | } 123 | 124 | curUID, err := deos.GetFileUID(fi) 125 | if err != nil { 126 | return err 127 | } 128 | 129 | dir.Chmod((fi.Mode() | 0070) & ^os.ModeType) // Ignore errors. 130 | dir.Chown(curUID, newGID) // Ignore errors. 131 | return nil 132 | } 133 | 134 | func (r *Redirector) commonHandler(h http.Handler) http.Handler { 135 | return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 136 | rw.Header().Set("Server", "acmetool-redirector") 137 | rw.Header().Set("Content-Security-Policy", "default-src 'none'") 138 | h.ServeHTTP(rw, req) 139 | }) 140 | } 141 | 142 | // Start the redirector. 143 | func (r *Redirector) Start() error { 144 | serveMux := http.NewServeMux() 145 | r.httpServer.Handler = r.commonHandler(serveMux) 146 | 147 | challengePath, ok := chroot.Rel(r.cfg.ChallengePath) 148 | if !ok { 149 | return fmt.Errorf("challenge path is not addressible inside chroot: %s", r.cfg.ChallengePath) 150 | } 151 | 152 | serveMux.HandleFunc("/", r.handleRedirect) 153 | serveMux.Handle("/.well-known/acme-challenge/", 154 | http.StripPrefix("/.well-known/acme-challenge/", http.FileServer(nolsDir(challengePath)))) 155 | 156 | go func() { 157 | err := r.httpServer.Serve(r.httpListener) 158 | if atomic.LoadUint32(&r.stopping) == 0 { 159 | log.Fatale(err, "serve") 160 | } 161 | }() 162 | 163 | log.Debugf("redirector running") 164 | return nil 165 | } 166 | 167 | // Stop the redirector. 168 | func (r *Redirector) Stop() error { 169 | atomic.StoreUint32(&r.stopping, 1) 170 | r.httpServer.Stop(r.httpServer.Timeout) 171 | <-r.httpServer.StopChan() 172 | return nil 173 | } 174 | 175 | // Respond to a request with a redirect. 176 | func (r *Redirector) handleRedirect(rw http.ResponseWriter, req *http.Request) { 177 | // Redirect. 178 | u := *req.URL 179 | u.Scheme = "https" 180 | if u.Host == "" { 181 | u.Host = req.Host 182 | } 183 | if u.Host == "" { 184 | rw.WriteHeader(400) 185 | return 186 | } 187 | 188 | us := u.String() 189 | 190 | rw.Header().Set("Location", us) 191 | 192 | // If we are receiving any cookies, these must be insecure cookies, ergo 193 | // cookies aren't being set securely properly. This is a security issue. 194 | // Deleting cookies after the fact doesn't change the fact that they were 195 | // sent in cleartext and are thus forever untrustworthy. But it increases 196 | // the probability of somebody noticing something is up. 197 | // 198 | // ... However, the HTTP specification makes it impossible to delete a cookie 199 | // unless we know its domain and path, which aren't transmitted in requests. 200 | 201 | if req.Method == "GET" { 202 | rw.Header().Set("Cache-Control", "public; max-age=31536000") 203 | rw.Header().Set("Content-Type", "text/html; charset=utf-8") 204 | } 205 | 206 | // This is a permanent redirect and the request method should be preserved. 207 | // It's unfortunate if the client has transmitted information in cleartext 208 | // via POST, etc., but there's nothing we can do about it at this stage. 209 | rw.WriteHeader(r.cfg.StatusCode) 210 | 211 | if req.Method == "GET" { 212 | // Redirects issued in response to GET SHOULD have a body pointing to the 213 | // new URL for clients which don't support redirects. (Whether the set of 214 | // clients supporting acceptably modern versions of TLS and not supporting 215 | // HTTP redirects is non-empty is another matter.) 216 | ue := html.EscapeString(us) 217 | rw.Write([]byte(fmt.Sprintf(redirBody, ue, ue))) 218 | } 219 | } 220 | 221 | const redirBody = ` 222 | 223 | Permanently Moved 224 |

Permanently Moved

225 |

This resource has moved permanently to 226 | %s.

227 | ` 228 | 229 | // Like http.Dir, but doesn't allow directory listings. 230 | type nolsDir string 231 | 232 | var errNoListing = errors.New("http: directory listing not allowed") 233 | 234 | func (d nolsDir) Open(name string) (http.File, error) { 235 | f, err := http.Dir(d).Open(name) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | fi, err := f.Stat() 241 | if err != nil { 242 | f.Close() 243 | return nil, err 244 | } 245 | 246 | if fi.IsDir() { 247 | f.Close() 248 | return nil, os.ErrNotExist 249 | } 250 | 251 | return f, nil 252 | } 253 | -------------------------------------------------------------------------------- /redirector/redirector_test.go: -------------------------------------------------------------------------------- 1 | package redirector 2 | 3 | import ( 4 | denet "github.com/hlandau/goutils/net" 5 | "io/ioutil" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | ) 11 | 12 | func TestRedirector(t *testing.T) { 13 | dir, err := ioutil.TempDir("", "acme-redirector-test") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | 18 | defer os.RemoveAll(dir) 19 | 20 | r, err := New(Config{ 21 | Bind: ":9847", 22 | ChallengePath: dir, 23 | }) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | err = r.Start() 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | defer r.Stop() 34 | 35 | req, err := http.NewRequest("FROBNICATE", "http://127.0.0.1:9847/foo/bar?alpha=beta", nil) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | res, err := http.DefaultTransport.RoundTrip(req) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | defer res.Body.Close() 46 | loc := res.Header.Get("Location") 47 | if loc != "https://127.0.0.1:9847/foo/bar?alpha=beta" { 48 | t.Fatalf("wrong Location: %v", loc) 49 | } 50 | 51 | err = ioutil.WriteFile(filepath.Join(dir, "foo"), []byte("bar"), 0644) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | req, err = http.NewRequest("GET", "http://127.0.0.1:9847/.well-known/acme-challenge/foo", nil) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | res, err = http.DefaultTransport.RoundTrip(req) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | defer res.Body.Close() 67 | b, err := ioutil.ReadAll(denet.LimitReader(res.Body, 1*1024*1024)) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | if string(b) != "bar" { 73 | t.Fatal("wrong response") 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /responder/dns.go: -------------------------------------------------------------------------------- 1 | package responder 2 | 3 | import ( 4 | "crypto" 5 | "encoding/json" 6 | "fmt" 7 | "gopkg.in/hlandau/acmeapi.v2/acmeutils" 8 | ) 9 | 10 | type DNSChallengeInfo struct { 11 | Hostname string 12 | Body string 13 | } 14 | 15 | type dnsResponder struct { 16 | rcfg Config 17 | validation []byte 18 | dnsString string 19 | } 20 | 21 | func newDNSResponder(rcfg Config) (Responder, error) { 22 | s := &dnsResponder{ 23 | rcfg: rcfg, 24 | validation: []byte("{}"), 25 | } 26 | 27 | if rcfg.Hostname == "" { 28 | return nil, fmt.Errorf("must provide a hostname") 29 | } 30 | 31 | var err error 32 | s.dnsString, err = acmeutils.DNSKeyAuthorization(rcfg.AccountKey, rcfg.Token) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return s, nil 38 | } 39 | 40 | // Start is a no-op for the DNS method. 41 | func (s *dnsResponder) Start() error { 42 | // Try hooks. 43 | if startFunc := s.rcfg.ChallengeConfig.StartHookFunc; startFunc != nil { 44 | err := startFunc(&DNSChallengeInfo{ 45 | Hostname: s.rcfg.Hostname, 46 | Body: s.dnsString, 47 | }) 48 | return err 49 | } 50 | 51 | return fmt.Errorf("DNS challenge not supported") 52 | } 53 | 54 | // Stop is a no-op for the DNS method. 55 | func (s *dnsResponder) Stop() error { 56 | // Try hooks. 57 | if stopFunc := s.rcfg.ChallengeConfig.StopHookFunc; stopFunc != nil { 58 | err := stopFunc(&DNSChallengeInfo{ 59 | Hostname: s.rcfg.Hostname, 60 | Body: s.dnsString, 61 | }) 62 | log.Warne(err, "failed to uninstall DNS challenge via hook (ignoring)") 63 | return nil 64 | } 65 | 66 | return fmt.Errorf("DNS challenge not supported") 67 | } 68 | 69 | func (s *dnsResponder) RequestDetectedChan() <-chan struct{} { 70 | return nil 71 | } 72 | 73 | func (s *dnsResponder) Validation() json.RawMessage { 74 | return json.RawMessage(s.validation) 75 | } 76 | 77 | func (s *dnsResponder) ValidationSigningKey() crypto.PrivateKey { 78 | return nil 79 | } 80 | 81 | func init() { 82 | RegisterResponder("dns-01", newDNSResponder) 83 | } 84 | -------------------------------------------------------------------------------- /responder/http.go: -------------------------------------------------------------------------------- 1 | package responder 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/tls" 7 | "encoding/json" 8 | "fmt" 9 | "github.com/hlandau/acmetool/responder/reshttp" 10 | denet "github.com/hlandau/goutils/net" 11 | deos "github.com/hlandau/goutils/os" 12 | "gopkg.in/hlandau/acmeapi.v2/acmeutils" 13 | "io/ioutil" 14 | "net" 15 | "net/http" 16 | "net/url" 17 | "os" 18 | "path/filepath" 19 | "sort" 20 | "strconv" 21 | "strings" 22 | "time" 23 | ) 24 | 25 | // For testing use only. Determines the HTTP port which is listened on. This is 26 | // used because Pebble tries to talk to the client's HTTP responder on a 27 | // different HTTP port than the standard one. This use of non-privileged ports 28 | // eases testing. 29 | var InternalHTTPPort = 80 30 | 31 | type HTTPChallengeInfo struct { 32 | Hostname string 33 | Filename string 34 | Body string 35 | } 36 | 37 | type httpResponder struct { 38 | rcfg Config 39 | 40 | response []byte 41 | requestDetectedChan chan struct{} 42 | portClaims []reshttp.PortClaim 43 | ka []byte 44 | validation []byte 45 | filePath string 46 | notifySupported bool // is notify supported? 47 | listening bool 48 | } 49 | 50 | func newHTTP(rcfg Config) (Responder, error) { 51 | s := &httpResponder{ 52 | rcfg: rcfg, 53 | requestDetectedChan: make(chan struct{}, 1), 54 | notifySupported: true, 55 | validation: []byte("{}"), 56 | } 57 | 58 | if rcfg.Hostname == "" { 59 | return nil, fmt.Errorf("must provide a hostname") 60 | } 61 | 62 | ka, err := acmeutils.KeyAuthorization(rcfg.AccountKey, rcfg.Token) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | s.ka = []byte(ka) 68 | return s, nil 69 | } 70 | 71 | func (s *httpResponder) notify() { 72 | // Notify callers that a request has been detected. 73 | select { 74 | case s.requestDetectedChan <- struct{}{}: 75 | default: 76 | } 77 | } 78 | 79 | // Start handling HTTP requests. 80 | func (s *httpResponder) Start() error { 81 | err := s.startActual() 82 | if err != nil { 83 | return err 84 | } 85 | 86 | if !s.rcfg.ChallengeConfig.HTTPNoSelfTest { 87 | log.Debugf("http-01 self test for %q", s.rcfg.Hostname) 88 | err = s.selfTest() 89 | if err != nil { 90 | log.Infoe(err, "http-01 self test failed: ", s.rcfg.Hostname) 91 | s.Stop() 92 | return err 93 | } 94 | } 95 | 96 | log.Debug("http-01 started") 97 | return nil 98 | } 99 | 100 | // This is currently the validation timeout used by Let's Encrypt, so let's 101 | // use the same value here. 102 | var selfTestTimeout = 5 * time.Second 103 | 104 | // Test that the challenge is reachable at the given hostname. If a hostname 105 | // was not provided, this test is skipped. 106 | func (s *httpResponder) selfTest() error { 107 | if s.rcfg.Hostname == "" { 108 | return nil 109 | } 110 | 111 | u := url.URL{ 112 | Scheme: "http", 113 | Host: s.rcfg.Hostname, 114 | Path: "/.well-known/acme-challenge/" + s.rcfg.Token, 115 | } 116 | if InternalHTTPPort != 80 { 117 | u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", InternalHTTPPort)) 118 | } 119 | 120 | trans := &http.Transport{ 121 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 122 | DisableKeepAlives: true, 123 | } 124 | 125 | client := &http.Client{ 126 | Transport: trans, 127 | Timeout: selfTestTimeout, 128 | } 129 | 130 | res, err := client.Get(u.String()) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | defer res.Body.Close() 136 | if res.StatusCode != 200 { 137 | return fmt.Errorf("hostname %q: non-200 status code when doing self-test", s.rcfg.Hostname) 138 | } 139 | 140 | b, err := ioutil.ReadAll(denet.LimitReader(res.Body, 1*1024*1024)) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | b = bytes.TrimSpace(b) 146 | if !bytes.Equal(b, s.ka) { 147 | return fmt.Errorf("hostname %q: got 200 response when doing self-test, but with the wrong data", s.rcfg.Hostname) 148 | } 149 | 150 | // If we detected a request, we support notifications, otherwise we don't. 151 | select { 152 | case <-s.requestDetectedChan: 153 | default: 154 | s.notifySupported = false 155 | } 156 | 157 | // Drain the notification channel in case we somehow made several requests. 158 | L: 159 | for { 160 | select { 161 | case <-s.requestDetectedChan: 162 | default: 163 | break L 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | 170 | // Tries to write a challenge file to each of the directories. 171 | func webrootWriteChallenge(webroots map[string]struct{}, token string, ka []byte) { 172 | log.Debugf("writing %d webroot challenge files", len(webroots)) 173 | 174 | for wr := range webroots { 175 | os.MkdirAll(wr, 0755) // ignore errors 176 | fn := filepath.Join(wr, token) 177 | log.Debugf("writing webroot file %s", fn) 178 | 179 | // Because /var/run/acme/acme-challenge may not exist due to /var/run 180 | // possibly being a tmpfs, and because that tmpfs is likely to be world 181 | // writable, there is a risk of following a maliciously crafted symlink to 182 | // cause a file to be overwritten as root. Open the file using a 183 | // no-symlinks flag if the OS supports it, but only for /var/run paths; we 184 | // want to support symlinks for other paths, which are presumably properly 185 | // controlled. 186 | // 187 | // Unfortunately earlier components in the pathname will still be followed 188 | // if they are symlinks, but it looks like this is the best we can do. 189 | var f *os.File 190 | var err error 191 | if strings.HasPrefix(wr, "/var/run/") { 192 | f, err = deos.OpenFileNoSymlinks(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 193 | } else { 194 | f, err = os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 195 | } 196 | if err != nil { 197 | log.Infoe(err, "failed to open webroot file ", fn) 198 | continue 199 | } 200 | 201 | f.Write(ka) 202 | f.Close() 203 | } 204 | } 205 | 206 | // Tries to remove a challenge file from each of the directories. 207 | func webrootRemoveChallenge(webroots map[string]struct{}, token string) { 208 | for wr := range webroots { 209 | fn := filepath.Join(wr, token) 210 | 211 | log.Debugf("removing webroot file %s", fn) 212 | os.Remove(fn) // ignore errors 213 | } 214 | } 215 | 216 | // The standard webroot path, into which the responder always tries to install 217 | // challenges, not necessarily successfully. This is intended to be a standard, 218 | // system-wide path to look for challenges at. On POSIX-like systems, it is 219 | // usually "/var/run/acme/acme-challenge". 220 | var StandardWebrootPath string 221 | 222 | func init() { 223 | if StandardWebrootPath == "" { 224 | StandardWebrootPath = "/var/run/acme/acme-challenge" 225 | } 226 | } 227 | 228 | func (s *httpResponder) getWebroots() map[string]struct{} { 229 | webroots := map[string]struct{}{} 230 | for _, p := range s.rcfg.ChallengeConfig.WebPaths { 231 | if p != "" { 232 | webroots[strings.TrimRight(p, "/")] = struct{}{} 233 | } 234 | } 235 | 236 | // The webroot and redirector models both require us to drop the challenge at 237 | // a given path. If a webroot is not specified in the configuration, use an 238 | // ephemeral default that the redirector might be using anyway. 239 | webroots[StandardWebrootPath] = struct{}{} 240 | return webroots 241 | } 242 | 243 | func parseListenAddrs(addrs []string) map[string]struct{} { 244 | m := map[string]struct{}{} 245 | 246 | for _, s := range addrs { 247 | n, err := strconv.ParseUint(s, 10, 16) 248 | if err == nil { 249 | m[fmt.Sprintf("[::1]:%d", n)] = struct{}{} 250 | m[fmt.Sprintf("127.0.0.1:%d", n)] = struct{}{} 251 | continue 252 | } 253 | 254 | ta, err := net.ResolveTCPAddr("tcp", s) 255 | if err != nil { 256 | log.Warnf("invalid listen addr: %q: %v", s, err) 257 | continue 258 | } 259 | 260 | m[ta.String()] = struct{}{} 261 | } 262 | 263 | return m 264 | } 265 | 266 | func addrWeight(x string) int { 267 | host, _, err := net.SplitHostPort(x) 268 | if err != nil { 269 | return 0 270 | } 271 | 272 | if host == "" { 273 | return -1 274 | } 275 | 276 | ip := net.ParseIP(host) 277 | if ip != nil && ip.IsUnspecified() { 278 | if ip.To4() != nil { 279 | return -1 280 | } 281 | return -2 282 | } 283 | 284 | return 0 285 | } 286 | 287 | type addrSorter []string 288 | 289 | func (a addrSorter) Len() int { return len(a) } 290 | func (a addrSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 291 | func (a addrSorter) Less(i, j int) bool { 292 | return addrWeight(a[i]) < addrWeight(a[j]) 293 | } 294 | 295 | func determineListenAddrs(userAddrs []string) []string { 296 | // Here's our brute force method: listen on everything that might work. 297 | addrs := parseListenAddrs(userAddrs) 298 | addrs[fmt.Sprintf("[::]:%d", InternalHTTPPort)] = struct{}{} // OpenBSD 299 | addrs[fmt.Sprintf(":%d", InternalHTTPPort)] = struct{}{} 300 | addrs["[::1]:402"] = struct{}{} 301 | addrs["127.0.0.1:402"] = struct{}{} 302 | addrs["[::1]:4402"] = struct{}{} 303 | addrs["127.0.0.1:4402"] = struct{}{} 304 | 305 | // Sort the strings so that 'all interfaces' addresses appear first, so that 306 | // they are not blocked by more specific entries such as the ones above, 307 | // which are always attempted. 308 | var addrsl []string 309 | for k := range addrs { 310 | addrsl = append(addrsl, k) 311 | } 312 | 313 | sort.Stable(addrSorter(addrsl)) 314 | return addrsl 315 | } 316 | 317 | func (s *httpResponder) startActual() error { 318 | // Determine and listen on sorted list of addresses. 319 | addrs := determineListenAddrs(s.rcfg.ChallengeConfig.HTTPPorts) 320 | 321 | for _, a := range addrs { 322 | pc, err := reshttp.AcquirePort(a, s.rcfg.Token, s.ka, s.notify) 323 | if err == nil { 324 | s.portClaims = append(s.portClaims, pc) 325 | } 326 | } 327 | 328 | // Even if none of the listeners managed to start, the webroot or redirector 329 | // methods might work. 330 | webrootWriteChallenge(s.getWebroots(), s.rcfg.Token, s.ka) 331 | 332 | // Try hooks. 333 | if startFunc := s.rcfg.ChallengeConfig.StartHookFunc; startFunc != nil { 334 | err := startFunc(&HTTPChallengeInfo{ 335 | Hostname: s.rcfg.Hostname, 336 | Filename: s.rcfg.Token, 337 | Body: string(s.ka), 338 | }) 339 | log.Errore(err, "start challenge hook") 340 | } 341 | 342 | return nil 343 | } 344 | 345 | // Stop handling HTTP requests. 346 | func (s *httpResponder) Stop() error { 347 | for _, pc := range s.portClaims { 348 | pc.Close() 349 | } 350 | s.portClaims = nil 351 | 352 | // Try and remove challenges. 353 | webrootRemoveChallenge(s.getWebroots(), s.rcfg.Token) 354 | 355 | // Try and stop hooks. 356 | if stopFunc := s.rcfg.ChallengeConfig.StopHookFunc; stopFunc != nil { 357 | err := stopFunc(&HTTPChallengeInfo{ 358 | Hostname: s.rcfg.Hostname, 359 | Filename: s.rcfg.Token, 360 | Body: string(s.ka), 361 | }) 362 | log.Errore(err, "stop challenge hook") 363 | } 364 | 365 | return nil 366 | } 367 | 368 | func (s *httpResponder) RequestDetectedChan() <-chan struct{} { 369 | if !s.notifySupported { 370 | return nil 371 | } 372 | 373 | return s.requestDetectedChan 374 | } 375 | 376 | func (s *httpResponder) Validation() json.RawMessage { 377 | return json.RawMessage(s.validation) 378 | } 379 | 380 | func (s *httpResponder) ValidationSigningKey() crypto.PrivateKey { 381 | return nil 382 | } 383 | 384 | func init() { 385 | RegisterResponder("http-01", newHTTP) 386 | } 387 | -------------------------------------------------------------------------------- /responder/reshttp/reshttp.go: -------------------------------------------------------------------------------- 1 | // Package reshttp allows multiple goroutines to register challenge responses 2 | // on an HTTP server concurrently. 3 | package reshttp 4 | 5 | import ( 6 | "github.com/hlandau/xlog" 7 | "gopkg.in/tylerb/graceful.v1" 8 | "net" 9 | "net/http" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | var log, Log = xlog.New("acmetool.reshttp") 16 | 17 | type PortClaim interface { 18 | Close() error 19 | } 20 | 21 | type portClaim struct { 22 | port *port 23 | released bool 24 | filename string 25 | body []byte 26 | notifyFunc func() 27 | } 28 | 29 | func (pc *portClaim) Close() error { 30 | mutex.Lock() 31 | defer mutex.Unlock() 32 | 33 | if pc.released { 34 | return nil 35 | } 36 | 37 | delete(pc.port.claims, pc.filename) 38 | 39 | pc.port.refcount-- 40 | if pc.port.refcount == 0 { 41 | pc.port.Destroy() 42 | } 43 | 44 | pc.released = true 45 | return nil 46 | } 47 | 48 | type port struct { 49 | addr string 50 | refcount int 51 | server *graceful.Server 52 | claims map[string]*portClaim 53 | } 54 | 55 | func (p *port) Init() error { 56 | p.claims = map[string]*portClaim{} 57 | 58 | p.server = &graceful.Server{ 59 | NoSignalHandling: true, 60 | Server: &http.Server{ 61 | Addr: p.addr, 62 | Handler: p, 63 | }, 64 | } 65 | 66 | l, err := net.Listen("tcp", p.addr) 67 | if err != nil { 68 | log.Debuge(err, "failed to listen on ", p.addr) 69 | return err 70 | } 71 | 72 | log.Debugf("listening on %v", p.addr) 73 | 74 | go func() { 75 | defer l.Close() 76 | p.server.Serve(l) 77 | }() 78 | 79 | return nil 80 | } 81 | 82 | func (p *port) Destroy() { 83 | delete(ports, p.addr) 84 | p.server.Stop(10 * time.Millisecond) 85 | <-p.server.StopChan() 86 | } 87 | 88 | func (p *port) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 89 | if !strings.HasPrefix(req.URL.Path, "/.well-known/acme-challenge/") { 90 | http.NotFound(rw, req) 91 | return 92 | } 93 | 94 | fn := req.URL.Path[28:] 95 | body, notifyFunc := p.getClaim(fn) 96 | if body == nil { 97 | http.NotFound(rw, req) 98 | return 99 | } 100 | 101 | rw.Header().Set("Content-Type", "text/plain") 102 | rw.Write(body) 103 | 104 | if notifyFunc != nil { 105 | notifyFunc() 106 | } 107 | } 108 | 109 | func (p *port) getClaim(filename string) (body []byte, notifyFunc func()) { 110 | mutex.Lock() 111 | defer mutex.Unlock() 112 | 113 | pc, ok := p.claims[filename] 114 | if !ok { 115 | return nil, nil 116 | } 117 | 118 | return pc.body, pc.notifyFunc 119 | } 120 | 121 | var mutex sync.Mutex 122 | var ports = map[string]*port{} 123 | 124 | func AcquirePort(bindAddr, filename string, body []byte, notifyFunc func()) (PortClaim, error) { 125 | log.Debugf("acquire port %q %q", bindAddr, filename) 126 | mutex.Lock() 127 | defer mutex.Unlock() 128 | 129 | p, ok := ports[bindAddr] 130 | if !ok { 131 | p = &port{ 132 | addr: bindAddr, 133 | refcount: 0, 134 | } 135 | err := p.Init() 136 | if err != nil { 137 | return nil, err 138 | } 139 | ports[bindAddr] = p 140 | } 141 | 142 | p.refcount++ 143 | pc := &portClaim{ 144 | port: p, 145 | filename: filename, 146 | body: body, 147 | notifyFunc: notifyFunc, 148 | } 149 | p.claims[filename] = pc 150 | return pc, nil 151 | } 152 | -------------------------------------------------------------------------------- /responder/responder.go: -------------------------------------------------------------------------------- 1 | // Package responder implements the various ACME challenge types. 2 | package responder 3 | 4 | import ( 5 | "crypto" 6 | "encoding/json" 7 | "fmt" 8 | "github.com/hlandau/xlog" 9 | ) 10 | 11 | // Log site. 12 | var log, Log = xlog.New("acme.responder") 13 | 14 | // A Responder implements a challenge type. 15 | // 16 | // After successfully instantiating a responder, you should call Start. 17 | // 18 | // You should then use the return values of Validation() and 19 | // ValidationSigningKey() to submit the challenge response. 20 | // 21 | // Once the challenge has been completed, as determined by polling, you must 22 | // call Stop. If RequestDetectedChan() is non-nil, it provides a hint as to 23 | // when polling may be fruitful. 24 | type Responder interface { 25 | // Become ready to be interrogated by the ACME server. 26 | Start() error 27 | 28 | // Stop responding to any queries by the ACME server. 29 | Stop() error 30 | 31 | // This channel is sent to when a request to the responder is detected, 32 | // which may indicates completion of the challenge is imminent. 33 | // 34 | // Returning nil indicates that request detection is not supported. 35 | RequestDetectedChan() <-chan struct{} 36 | 37 | // Return the validation object the signature for which was delivered. If 38 | // nil is returned, no validation object is submitted. 39 | Validation() json.RawMessage 40 | 41 | // Key which must sign validation object. If nil, account key is used. 42 | ValidationSigningKey() crypto.PrivateKey 43 | } 44 | 45 | // Used to instantiate a responder. 46 | type Config struct { 47 | // Information about the challenge to be completed. 48 | 49 | Type string // The responder type to be used. e.g. "http-01". 50 | AccountKey crypto.PrivateKey // The account private key. 51 | Token string // The challenge token. 52 | 53 | // "http-01", "dns-01": The hostname being verified. May be used for 54 | // pre-initiation self-testing. Required. 55 | Hostname string 56 | 57 | ChallengeConfig ChallengeConfig 58 | } 59 | 60 | // Information used to complete challenges, other than information provided by 61 | // the ACME server. 62 | type ChallengeConfig struct { 63 | // "http-01": The http responder may attempt to place challenges in these 64 | // locations. Optional. 65 | WebPaths []string 66 | 67 | // "http-01": The http responder may attempt to listen on these addresses. 68 | // Optional. 69 | HTTPPorts []string 70 | 71 | // Do not perform self test, but assume challenge is completable. 72 | HTTPNoSelfTest bool 73 | 74 | StartHookFunc HookFunc 75 | StopHookFunc HookFunc 76 | } 77 | 78 | // Returns the private key corresponding to the given public key, if it can be 79 | // found. If a corresponding private key cannot be found, return nil; do not 80 | // return an error. Returning an error short circuits. 81 | type PriorKeyFunc func(crypto.PublicKey) (crypto.PrivateKey, error) 82 | 83 | type HookFunc func(challengeInfo interface{}) error 84 | 85 | var responderTypes = map[string]func(Config) (Responder, error){} 86 | 87 | // Try and instantiate a responder using the given configuration. 88 | func New(rcfg Config) (Responder, error) { 89 | f, ok := responderTypes[rcfg.Type] 90 | if !ok { 91 | return nil, fmt.Errorf("challenge type not supported") 92 | } 93 | 94 | return f(rcfg) 95 | } 96 | 97 | // Register a responder type. Allows types other than those innately supported 98 | // by this package to be supported. Overrides any previously registered 99 | // responder of the same type. 100 | func RegisterResponder(typeName string, createFunc func(Config) (Responder, error)) { 101 | responderTypes[typeName] = createFunc 102 | } 103 | -------------------------------------------------------------------------------- /solver/preference.go: -------------------------------------------------------------------------------- 1 | package solver 2 | 3 | import ( 4 | "gopkg.in/hlandau/acmeapi.v2" 5 | "sort" 6 | ) 7 | 8 | // Any challenge having a preference at or below this value will never be used. 9 | const NonviableThreshold int32 = -1000000 10 | 11 | type sorter struct { 12 | authz *acmeapi.Authorization 13 | order []int 14 | preferencer Preferencer 15 | } 16 | 17 | func (s *sorter) Len() int { 18 | return len(s.order) 19 | } 20 | 21 | func (s *sorter) Swap(i, j int) { 22 | s.order[i], s.order[j] = s.order[j], s.order[i] 23 | } 24 | 25 | func (s *sorter) Less(i, j int) bool { 26 | pi := s.preference(&s.authz.Challenges[i]) 27 | pj := s.preference(&s.authz.Challenges[j]) 28 | return pi < pj 29 | } 30 | 31 | func (s *sorter) preference(ch *acmeapi.Challenge) int32 { 32 | v := s.preferencer.Preference(ch) 33 | if v <= NonviableThreshold { 34 | return NonviableThreshold 35 | } 36 | 37 | return v 38 | } 39 | 40 | // Returns a list of indices to authz.Challenges, sorted by preference, most 41 | // preferred first. 42 | func SortChallenges(authz *acmeapi.Authorization, preferencer Preferencer) (preferenceOrder []int) { 43 | preferenceOrder = make([]int, len(authz.Challenges)) 44 | for i := 0; i < len(authz.Challenges); i++ { 45 | preferenceOrder[i] = i 46 | } 47 | 48 | s := sorter{ 49 | authz: authz, 50 | order: preferenceOrder, 51 | preferencer: preferencer, 52 | } 53 | sort.Stable(sort.Reverse(&s)) 54 | return 55 | } 56 | 57 | // TypePreferencer returns a preference according to the type of the challenge. 58 | // 59 | // Unknown challenge types are nonviable. 60 | type TypePreferencer map[string]int32 61 | 62 | // Implements Preferencer. 63 | func (p TypePreferencer) Preference(ch *acmeapi.Challenge) int32 { 64 | v, ok := p[ch.Type] 65 | if !ok { 66 | return NonviableThreshold 67 | } 68 | return v 69 | } 70 | 71 | // Returns a copy of TypePreferencer, so that it can be mutated without 72 | // changing the original. 73 | func (p TypePreferencer) Copy() TypePreferencer { 74 | tp := TypePreferencer{} 75 | for k, v := range p { 76 | tp[k] = v 77 | } 78 | return tp 79 | } 80 | 81 | // PreferFast prefers fast types. 82 | var PreferFast = TypePreferencer{ 83 | "tls-sni-02": 2, 84 | "tls-sni-01": 1, 85 | "http-01": 0, 86 | 87 | // Disable DNS challenges for now. They're practically unusable and the Let's 88 | // Encrypt live server doesn't support them at this time anyway. 89 | "dns-01": -10, 90 | } 91 | 92 | // Determines the degree to which a challenge is preferred. Higher values are 93 | // more preferred. Any value <= NonviableThreshold will never be used. 94 | type Preferencer interface { 95 | // Get the preference for the given challenge. 96 | Preference(ch *acmeapi.Challenge) int32 97 | } 98 | -------------------------------------------------------------------------------- /solver/register.go: -------------------------------------------------------------------------------- 1 | package solver 2 | 3 | import ( 4 | "fmt" 5 | "github.com/hlandau/acmetool/interaction" 6 | "golang.org/x/net/context" 7 | "gopkg.in/hlandau/acmeapi.v2" 8 | "net/mail" 9 | ) 10 | 11 | // Using the given client, account and interactor (or interaction.Auto if nil), 12 | // register the client account if it does not already exist. Does not do anything 13 | // and does NOT update the registration if the account is already registered. 14 | // 15 | // The interactor is used to prompt for terms of service agreement, if 16 | // agreement has not already been obtained. An e. mail address is prompted for. 17 | func AssistedRegistration(ctx context.Context, cl *acmeapi.RealmClient, acct *acmeapi.Account, interactor interaction.Interactor) error { 18 | interactor = defaultInteraction(interactor) 19 | 20 | // We know for a fact the account has already been registered because we know 21 | // its URL. Don't do anything. 22 | if acct.URL != "" { 23 | return nil 24 | } 25 | 26 | // See if the account has already been registered. If so, the URL gets stored 27 | // in acct.URL and we're done. 28 | err := cl.LocateAccount(ctx, acct) 29 | if err == nil { 30 | return nil 31 | } 32 | 33 | // Check that the error that occured was a not found error. 34 | he, ok := err.(*acmeapi.HTTPError) 35 | if !ok { 36 | return err 37 | } 38 | if he.Problem == nil || he.Problem.Type != "urn:ietf:params:acme:error:accountDoesNotExist" { 39 | return err 40 | } 41 | 42 | // Get the directory metadata so we can get the terms of service URL. 43 | meta, err := cl.GetMeta(ctx) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | // Prompt for ToS agreement if required. 49 | acct.TermsOfServiceAgreed = false 50 | if meta.TermsOfServiceURL != "" { 51 | res, err := interactor.Prompt(&interaction.Challenge{ 52 | Title: "Terms of Service Agreement Required", 53 | YesLabel: "I Agree", 54 | NoLabel: "Cancel", 55 | ResponseType: interaction.RTYesNo, 56 | UniqueID: "acme-agreement:" + meta.TermsOfServiceURL, 57 | Prompt: "Do you agree to the Terms of Service?", 58 | Body: fmt.Sprintf(`You must agree to the terms of service at the following URL to continue: 59 | 60 | %s 61 | 62 | Do you agree to the terms of service set out in the above document?`, meta.TermsOfServiceURL), 63 | }) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if res.Cancelled { 69 | return fmt.Errorf("terms of service agreement is required, but user declined") 70 | } 71 | 72 | acct.TermsOfServiceAgreed = true 73 | } 74 | 75 | // Get e. mail. 76 | email, err := getEmail(interactor) 77 | if err != nil { 78 | return err 79 | } 80 | if email == "-" { 81 | return fmt.Errorf("e. mail input cancelled") 82 | } 83 | 84 | if email != "" { 85 | acct.ContactURIs = []string{"mailto:" + email} 86 | } 87 | 88 | // Do the registration. 89 | err = cl.RegisterAccount(ctx, acct) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func getEmail(interactor interaction.Interactor) (string, error) { 98 | for { 99 | res, err := interactor.Prompt(&interaction.Challenge{ 100 | Title: "E. Mail Address Required", 101 | ResponseType: interaction.RTLineString, 102 | Prompt: "E. mail address: ", 103 | Body: `Please enter an e. mail address where you can be reached. Although entering an e. mail address is optional, it is highly recommended.`, 104 | UniqueID: "acme-enter-email", 105 | }) 106 | if err != nil { 107 | return "", err 108 | } 109 | 110 | if res.Value == "" { 111 | return "", nil 112 | } 113 | 114 | if res.Cancelled { 115 | return "-", nil 116 | } 117 | 118 | addr, err := mail.ParseAddress(res.Value) 119 | if err != nil { 120 | if res.Noninteractive { 121 | // If the e. mail address specified was invalid but we received it from 122 | // a noninteractive source, don't loop or we will loop forever. Instead 123 | // just act like one wasn't specified. 124 | return "", nil 125 | } 126 | 127 | continue 128 | } 129 | 130 | return addr.Address, nil 131 | } 132 | } 133 | 134 | func defaultInteraction(interactor interaction.Interactor) interaction.Interactor { 135 | if interactor == nil { 136 | return interaction.Auto 137 | } 138 | return interactor 139 | } 140 | -------------------------------------------------------------------------------- /storage/abs.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "crypto" 5 | "errors" 6 | ) 7 | 8 | // Abstract storage interface. 9 | type Store interface { 10 | Close() error // Closes the database. 11 | Reload() error // Reloads the database from disk. 12 | Path() string // ACME state directory path. 13 | 14 | // These methods find an object by its identifier. Returns nil if the object 15 | // is not found. 16 | AccountByID(accountID string) *Account 17 | AccountByDirectoryURL(directoryURL string) *Account 18 | CertificateByID(certificateID string) *Certificate 19 | KeyByID(keyID string) *Key 20 | TargetByFilename(filename string) *Target 21 | 22 | DefaultTarget() *Target // Returns the default target. 23 | PreferredCertificateForHostname(hostname string) (*Certificate, error) 24 | VisitPreferredCertificates(func(hostname string, c *Certificate) error) error 25 | 26 | // The Visit methods call the given function for each known object of the 27 | // given type. Returning an error short-circuits. 28 | VisitAccounts(func(*Account) error) error 29 | VisitCertificates(func(*Certificate) error) error 30 | VisitKeys(func(*Key) error) error 31 | VisitTargets(func(*Target) error) error 32 | 33 | // Mutators. 34 | SaveTarget(*Target) error // Saves a target. 35 | RemoveTarget(filename string) error // Remove a target from the database. 36 | 37 | SaveCertificate(*Certificate) error // Saves certificate information. 38 | SaveAccount(*Account) error // Save account information. 39 | 40 | // Erase a whole certificate directory including URL, certificates, etc. 41 | RemoveCertificate(certificateID string) error 42 | // Erase a private key directory. 43 | RemoveKey(keyID string) error 44 | 45 | ImportKey(privateKey crypto.PrivateKey) (*Key, error) // Imports the key if it isn't already imported. 46 | ImportAccount(directoryURL string, privateKey crypto.PrivateKey) (*Account, error) // Imports an account key if it isn't already imported. 47 | ImportCertificate(acct *Account, url string) (*Certificate, error) // Imports a certificate if it isn't already imported. 48 | 49 | SetPreferredCertificateForHostname(hostname string, c *Certificate) error 50 | 51 | WriteMiscellaneousConfFile(filename string, data []byte) error 52 | } 53 | 54 | // Return this sentinel value to stop visitation. 55 | var StopVisiting = errors.New("[stop visiting]") 56 | -------------------------------------------------------------------------------- /storage/config.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "crypto/elliptic" 5 | "github.com/hlandau/acmetool/fdb" 6 | "strings" 7 | ) 8 | 9 | // Legacy Configuration 10 | 11 | func (s *fdbStore) loadWebrootPaths() { 12 | if len(s.defaultTarget.Request.Challenge.WebrootPaths) != 0 { 13 | // Path list in default target file takes precedence. 14 | return 15 | } 16 | 17 | webrootPath, _ := fdb.String(s.db.Collection("conf").Open("webroot-path")) // ignore errors 18 | webrootPath = strings.TrimSpace(webrootPath) 19 | webrootPaths := strings.Split(webrootPath, "\n") 20 | for i := range webrootPaths { 21 | webrootPaths[i] = strings.TrimSpace(webrootPaths[i]) 22 | } 23 | 24 | if len(webrootPaths) == 1 && webrootPaths[0] == "" { 25 | webrootPaths = nil 26 | } 27 | 28 | s.defaultTarget.Request.Challenge.WebrootPaths = webrootPaths 29 | } 30 | 31 | func (s *fdbStore) loadRSAKeySize() { 32 | if s.defaultTarget.Request.Key.RSASize != 0 { 33 | // setting in default target file takes precedence 34 | return 35 | } 36 | 37 | n, err := fdb.Uint(s.db.Collection("conf"), "rsa-key-size", 31) 38 | if err != nil { 39 | return 40 | } 41 | 42 | s.defaultTarget.Request.Key.RSASize = int(n) 43 | 44 | if nn := clampRSAKeySize(int(n)); nn != int(n) { 45 | log.Warnf("An RSA key size of %d is not supported; must have %d <= size <= %d; clamping at %d", n, minRSASize, maxRSASize, nn) 46 | } 47 | } 48 | 49 | // Key Parameters 50 | 51 | const ( 52 | minRSASize = 2048 53 | defaultRSASize = 2048 54 | maxRSASize = 4096 55 | ) 56 | 57 | func clampRSAKeySize(sz int) int { 58 | if sz == 0 { 59 | return defaultRSASize 60 | } 61 | if sz < minRSASize { 62 | return minRSASize 63 | } 64 | if sz > maxRSASize { 65 | return maxRSASize 66 | } 67 | return sz 68 | } 69 | 70 | const defaultCurve = "nistp256" 71 | 72 | // Make sure the curve name is valid and use a default curve name. "clamp" is 73 | // not the sanest name here but is consistent with clampRSAKeySize. 74 | func clampECDSACurve(curveName string) string { 75 | switch curveName { 76 | case "nistp256", "nistp384", "nistp521": 77 | return curveName 78 | default: 79 | return defaultCurve 80 | } 81 | } 82 | 83 | func getECDSACurve(curveName string) elliptic.Curve { 84 | switch clampECDSACurve(curveName) { 85 | case "nistp256": 86 | return elliptic.P256() 87 | case "nistp384": 88 | return elliptic.P384() 89 | case "nistp521": 90 | return elliptic.P521() 91 | default: 92 | return nil 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /storage/neuter.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | // In some cases it is desirable to load configuration information such as the 4 | // default target file, but very undesirable to load sensitive information such 5 | // as private keys. For example, the HTTP to HTTPS redirector is a public-facing 6 | // service and as such, is run privilege-dropped and chrooted for mitigation 7 | // purposes in the unlikely event that a vulnerability is identified in this 8 | // program or its dependencies, each written in a memory-safe language. 9 | // However, this could all be for nought if extremely valuable data such as 10 | // private keys is kept in process memory after dropping privileges. It is 11 | // therefore essential that private keys NEVER touch the memory of an acmetool 12 | // process launched to serve as a redirector. 13 | // 14 | // Hence this function. Calling this function neuters the storage package. 15 | // Neuter does two things: 16 | // 17 | // - It panics if a storage instance has ever been created in this process 18 | // before the first call to Neuter. 19 | // 20 | // - It changes the behaviour of the storage package so that all future loads 21 | // of state directories load configuration information, but no private keys. 22 | // 23 | // Thus, once Neuter has returned, this is essentially a guarantee that no 24 | // private keys ever have been or ever will be loaded into the process. A call 25 | // to Neuter cannot be reversed except by starting a new process. 26 | func Neuter() { 27 | if hasTouchedSensitiveData { 28 | panic("cannot neuter storage package after it has already been used") 29 | } 30 | 31 | isNeutered = true 32 | } 33 | 34 | var isNeutered = false 35 | var hasTouchedSensitiveData = false 36 | -------------------------------------------------------------------------------- /storage/types.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/rsa" 7 | "encoding/base32" 8 | "fmt" 9 | "github.com/gofrs/uuid" 10 | "gopkg.in/hlandau/acmeapi.v2" 11 | "strings" 12 | ) 13 | 14 | // Represents stored account data. 15 | type Account struct { 16 | // N. Account private key. 17 | PrivateKey crypto.PrivateKey 18 | 19 | // N. Server directory URL. 20 | DirectoryURL string 21 | 22 | // ID: determined from DirectoryURL and PrivateKey. 23 | // Path: formed from ID. 24 | // Registration URL: can be recovered automatically. 25 | } 26 | 27 | // Returns the account ID (server URL/key ID). 28 | func (a *Account) ID() string { 29 | accountID, err := determineAccountID(a.DirectoryURL, a.PrivateKey) 30 | log.Panice(err) 31 | 32 | return accountID 33 | } 34 | 35 | // Returns true iff the account is for a given provider URL. 36 | func (a *Account) MatchesURL(p string) bool { 37 | return p == a.DirectoryURL 38 | } 39 | 40 | func (a *Account) String() string { 41 | return fmt.Sprintf("Account(%v)", a.ID()) 42 | } 43 | 44 | // Convert storage Account object to a new acmeapi.Account suitable for making 45 | // requests. 46 | func (a *Account) ToAPI() *acmeapi.Account { 47 | return &acmeapi.Account{ 48 | PrivateKey: a.PrivateKey, 49 | } 50 | } 51 | 52 | // Represents the "satisfy" section of a target file. 53 | type TargetSatisfy struct { 54 | // N. List of SANs required to satisfy this target. May include hostnames 55 | // (and maybe one day SRV-IDs). May include wildcard hostnames, but ACME 56 | // doesn't support those yet. 57 | Names []string `yaml:"names,omitempty"` 58 | 59 | // N. Renewal margin in days. Defaults to 30. 60 | Margin int `yaml:"margin,omitempty"` 61 | 62 | // D. Reduced name set, after disjunction operation. Derived from Names for 63 | // each label (or label ""). 64 | //ReducedNamesByLabel map[string][]string `yaml:"-"` 65 | 66 | // N. Key configuration items which are required to satisfy a target. 67 | Key TargetSatisfyKey `yaml:"key,omitempty"` 68 | } 69 | 70 | // Represents the "satisfy": "key" section of a target file. 71 | type TargetSatisfyKey struct { 72 | // N. Type of key to require. "" means do not require any specific type of 73 | // key. 74 | Type string `yaml:"type,omitempty"` 75 | } 76 | 77 | // Represents the "request" section of a target file. 78 | type TargetRequest struct { 79 | // N/d. List of SANs to place on any obtained certificate. Defaults to the 80 | // names in the satisfy section. 81 | Names []string `yaml:"names,omitempty"` 82 | 83 | // Used to track whether Names was explicitly specified, for reserialization purposes. 84 | implicitNames bool 85 | 86 | // N. Currently, this is the provider directory URL. An account matching it 87 | // will be used. At some point, a way to specify a particular account should 88 | // probably be added. 89 | Provider string `yaml:"provider,omitempty"` 90 | 91 | // D. Account to use. The storage package does not set this; it is for the 92 | // convenience of consuming code. To be determined via Provider string. 93 | Account *Account `yaml:"-"` 94 | 95 | // Settings relating to the creation of new keys used to request 96 | // corresponding certificates. 97 | Key TargetRequestKey `yaml:"key,omitempty"` 98 | 99 | // Settings relating to the completion of challenges. 100 | Challenge TargetRequestChallenge `yaml:"challenge,omitempty"` 101 | 102 | // N. Request OCSP Must Staple in CSRs? 103 | OCSPMustStaple bool `yaml:"ocsp-must-staple,omitempty"` 104 | } 105 | 106 | // Settings for keys generated as part of certificate requests. 107 | type TargetRequestKey struct { 108 | // N. Key type to use in making a request. "rsa" or "ecdsa". Default "rsa". 109 | Type string `yaml:"type,omitempty"` 110 | 111 | // N. RSA key size to use for new RSA keys. Defaults to 2048 bits. 112 | RSASize int `yaml:"rsa-size,omitempty"` 113 | 114 | // N. ECDSA curve. "nistp256" (default), "nistp384" or "nistp521". 115 | ECDSACurve string `yaml:"ecdsa-curve,omitempty"` 116 | 117 | // N. The key ID of an existing key to use for the purposes of making 118 | // requests. If not set, always generate a new key. 119 | ID string `yaml:"id,omitempty"` 120 | } 121 | 122 | func (k *TargetRequestKey) String() string { 123 | switch k.Type { 124 | case "", "rsa": 125 | return fmt.Sprintf("rsa-%d", clampRSAKeySize(k.RSASize)) 126 | case "ecdsa": 127 | return fmt.Sprintf("ecdsa-%s", clampECDSACurve(k.ECDSACurve)) 128 | default: 129 | return k.Type // ... 130 | } 131 | } 132 | 133 | // Settings relating to the completion of challenges. 134 | type TargetRequestChallenge struct { 135 | // N. Webroot paths to use when completing challenges. 136 | WebrootPaths []string `yaml:"webroot-paths,omitempty"` 137 | 138 | // N. Ports to listen on when completing challenges. 139 | HTTPPorts []string `yaml:"http-ports,omitempty"` 140 | 141 | // N. Perform HTTP self-test? Defaults to true. Rarely needed. If disabled, 142 | // HTTP challenges will be performed without self-testing. 143 | HTTPSelfTest *bool `yaml:"http-self-test,omitempty"` 144 | 145 | // N. Environment variables to pass to hooks. 146 | Env map[string]string `yaml:"env,omitempty"` 147 | // N. Inherited environment variables. Used internally. 148 | InheritedEnv map[string]string `yaml:"-"` 149 | } 150 | 151 | // Represents a stored target descriptor. 152 | type Target struct { 153 | // Specifies conditions which must be met. 154 | Satisfy TargetSatisfy `yaml:"satisfy,omitempty"` 155 | 156 | // Specifies parameters used when requesting certificates. 157 | Request TargetRequest `yaml:"request,omitempty"` 158 | 159 | // N. Priority. Controls symlink generation. See state storage specification. 160 | Priority int `yaml:"priority,omitempty"` 161 | 162 | // N. Label. Controls symlink generation. See state storage specification. 163 | Label string `yaml:"label,omitempty"` 164 | 165 | // LEGACY. Names to be satisfied. Moved to Satisfy.Names. 166 | LegacyNames []string `yaml:"names,omitempty"` 167 | 168 | // LEGACY. Provider URL to used. Moved to Request.Provider. 169 | LegacyProvider string `yaml:"provider,omitempty"` 170 | 171 | // Internal use. The filename under which the target is stored. 172 | Filename string `yaml:"-"` 173 | } 174 | 175 | func (t *Target) String() string { 176 | return fmt.Sprintf("Target(%s;%s;%d)", strings.Join(t.Satisfy.Names, ","), t.Request.Provider, t.Priority) 177 | } 178 | 179 | // Validates a target for basic sanity. Returns the first error found or nil. 180 | func (t *Target) Validate() error { 181 | if t.Request.Provider != "" && !acmeapi.ValidURL(t.Request.Provider) { 182 | return fmt.Errorf("invalid provider URL: %q", t.Request.Provider) 183 | } 184 | 185 | return nil 186 | } 187 | 188 | func (t *Target) ensureFilename() { 189 | if t.Filename != "" { 190 | return 191 | } 192 | 193 | // Unfortunately we can't really check if the first hostname exists as a filename 194 | // and use another name instead as this would create all sorts of race conditions. 195 | // We have to use a random name. 196 | 197 | nprefix := "" 198 | if len(t.Satisfy.Names) > 0 { 199 | nprefix = t.Satisfy.Names[0] + "-" 200 | } 201 | 202 | b := uuid.Must(uuid.NewV4()).Bytes() 203 | str := strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(b), "=")) 204 | 205 | t.Filename = nprefix + str 206 | } 207 | 208 | // Returns a copy of the target. 209 | func (t *Target) Copy() *Target { 210 | // A Target contains no pointers to part of the target which should be copied. 211 | // i.e. all pointers point to other things not part of the copy. Thus we can 212 | // just copy the value. If Target is ever changed to reference any component 213 | // of itself via pointer, this must be changed! 214 | tt := *t 215 | tt.Request.Challenge.InheritedEnv = map[string]string{} 216 | for k, v := range t.Request.Challenge.InheritedEnv { 217 | tt.Request.Challenge.InheritedEnv[k] = v 218 | } 219 | for k, v := range t.Request.Challenge.Env { 220 | tt.Request.Challenge.InheritedEnv[k] = v 221 | } 222 | tt.Request.Challenge.Env = nil 223 | return &tt 224 | } 225 | 226 | // Returns a copy of the target, but zeroes any very specific fields 227 | // like names. 228 | func (t *Target) CopyGeneric() *Target { 229 | tt := t.Copy() 230 | tt.genericise() 231 | return tt 232 | } 233 | 234 | func (t *Target) genericise() { 235 | t.Satisfy.Names = nil 236 | //t.Satisfy.ReducedNamesByLabel = nil 237 | t.Request.Names = nil 238 | t.LegacyNames = nil 239 | } 240 | 241 | // Represents stored certificate information. 242 | type Certificate struct { 243 | // N. URL to the order used to obtain the certificate. Not a direct URL to 244 | // the certificate blob. 245 | URL string 246 | 247 | // N. Whether this certificate should be revoked. 248 | RevocationDesired bool 249 | 250 | // N (for now). Whether this certificate has been revoked. 251 | Revoked bool 252 | 253 | // N. Now required due to need to support POST-as-GET. The account under 254 | // which the certificate was requested. nil if this is unknown due to being a 255 | // legacy certificate directory. 256 | Account *Account 257 | 258 | // D. Certificate data retrieved from URL, plus chained certificates. 259 | // The end certificate comes first, the root last, etc. 260 | Certificates [][]byte 261 | 262 | // D. True if the certificate has been downloaded. 263 | Cached bool 264 | 265 | // D. The private key for the certificate. 266 | Key *Key 267 | 268 | // D. ID: formed from hash of certificate URL. 269 | // D. Path: formed from ID. 270 | } 271 | 272 | // Returns a string summary of the certificate. 273 | func (c *Certificate) String() string { 274 | return fmt.Sprintf("Certificate(%v)", c.ID()) 275 | } 276 | 277 | // Returns the certificate ID. 278 | func (c *Certificate) ID() string { 279 | return determineCertificateID(c.URL) 280 | } 281 | 282 | // Represents a stored key. 283 | type Key struct { 284 | // N. The key. 285 | PrivateKey crypto.PrivateKey 286 | 287 | // D. ID: Derived from the key itself. 288 | ID string 289 | 290 | // D. Path: formed from ID. 291 | } 292 | 293 | // Returns a string summary of the key. 294 | func (k *Key) String() string { 295 | return fmt.Sprintf("Key(%v)", k.ID) 296 | } 297 | 298 | // Returns the type name of the key ("rsa" or "ecdsa"). 299 | func (k *Key) Type() string { 300 | switch k.PrivateKey.(type) { 301 | case *rsa.PrivateKey: 302 | return "rsa" 303 | case *ecdsa.PrivateKey: 304 | return "ecdsa" 305 | default: 306 | return "" 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /storage/util.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/sha256" 9 | "crypto/x509" 10 | "encoding/base32" 11 | "fmt" 12 | "gopkg.in/hlandau/acmeapi.v2/acmeutils" 13 | "io" 14 | "math/big" 15 | "net/url" 16 | "path/filepath" 17 | "regexp" 18 | "strings" 19 | ) 20 | 21 | func decodeAccountURLPart(part string) (string, error) { 22 | scheme := "https" 23 | if strings.HasPrefix(part, "http:") { 24 | scheme = "http" 25 | part = part[5:] 26 | } 27 | 28 | unesc, err := url.QueryUnescape(part) 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | p := scheme + "://" + unesc 34 | u, err := url.Parse(p) 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | if u.Path == "" { 40 | u.Path = "/" 41 | } 42 | 43 | return u.String(), nil 44 | } 45 | 46 | func accountURLPart(directoryURL string) (string, error) { 47 | u, err := url.Parse(directoryURL) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | if u.Scheme != "https" && u.Scheme != "http" { 53 | return "", fmt.Errorf("scheme must be HTTPS (or HTTP)") 54 | } 55 | 56 | directoryURL = u.String() 57 | s := directoryURL[strings.IndexByte(directoryURL, ':')+3:] 58 | if u.Path == "/" { 59 | s = s[0 : len(s)-1] 60 | } 61 | 62 | s = lowerEscapes(url.QueryEscape(s)) 63 | if u.Scheme == "http" { 64 | s = "http:" + s 65 | } 66 | 67 | return s, nil 68 | } 69 | 70 | func lowerEscapes(s string) string { 71 | b := []byte(s) 72 | state := 0 73 | for i := 0; i < len(b); i++ { 74 | switch state { 75 | case 0: 76 | if b[i] == '%' { 77 | state = 1 78 | } 79 | case 1: 80 | if b[i] == '%' { 81 | state = 0 82 | } else { 83 | state = 2 84 | } 85 | b[i] = lowerChar(b[i]) 86 | case 2: 87 | state = 0 88 | b[i] = lowerChar(b[i]) 89 | } 90 | } 91 | return string(b) 92 | } 93 | 94 | func lowerChar(c byte) byte { 95 | if c >= 'A' && c <= 'F' { 96 | return c - 'A' + 'a' 97 | } 98 | return c 99 | } 100 | 101 | // 'root' must be an absolute path. 102 | func pathIsWithin(subject, root string) (bool, error) { 103 | os := subject 104 | subject, err := filepath.EvalSymlinks(subject) 105 | if err != nil { 106 | log.Errore(err, "eval symlinks: ", os, " ", root) 107 | return false, err 108 | } 109 | 110 | subject, err = filepath.Abs(subject) 111 | if err != nil { 112 | return false, err 113 | } 114 | 115 | return strings.HasPrefix(subject, ensureSeparator(root)), nil 116 | } 117 | 118 | func ensureSeparator(p string) string { 119 | if !strings.HasSuffix(p, string(filepath.Separator)) { 120 | return p + string(filepath.Separator) 121 | } 122 | 123 | return p 124 | } 125 | 126 | func determineKeyIDFromCert(c *x509.Certificate) string { 127 | h := sha256.New() 128 | h.Write(c.RawSubjectPublicKeyInfo) 129 | return strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(h.Sum(nil)), "=")) 130 | } 131 | 132 | func getPublicKey(pk crypto.PrivateKey) crypto.PublicKey { 133 | switch pkv := pk.(type) { 134 | case *rsa.PrivateKey: 135 | return &pkv.PublicKey 136 | case *ecdsa.PrivateKey: 137 | return &pkv.PublicKey 138 | default: 139 | panic("unsupported key type") 140 | } 141 | } 142 | 143 | func determineKeyIDFromKey(pk crypto.PrivateKey) (string, error) { 144 | return determineKeyIDFromKeyIntl(getPublicKey(pk), pk) 145 | } 146 | 147 | func determineKeyIDFromKeyIntl(pubk crypto.PublicKey, pk crypto.PrivateKey) (string, error) { 148 | cc := &x509.Certificate{ 149 | SerialNumber: big.NewInt(1), 150 | } 151 | cb, err := x509.CreateCertificate(rand.Reader, cc, cc, pubk, pk) 152 | if err != nil { 153 | return "", err 154 | } 155 | 156 | c, err := x509.ParseCertificate(cb) 157 | if err != nil { 158 | return "", err 159 | } 160 | 161 | return determineKeyIDFromCert(c), nil 162 | } 163 | 164 | type psuedoPrivateKey struct { 165 | pk crypto.PublicKey 166 | } 167 | 168 | func (ppk *psuedoPrivateKey) Public() crypto.PublicKey { 169 | return ppk.pk 170 | } 171 | 172 | func (ppk *psuedoPrivateKey) Sign(io.Reader, []byte, crypto.SignerOpts) ([]byte, error) { 173 | return []byte{0}, nil 174 | } 175 | 176 | // Given a public key, returns the key ID. 177 | func DetermineKeyIDFromPublicKey(pubk crypto.PublicKey) (string, error) { 178 | // Trick crypto/x509 into creating a certificate so we can grab the 179 | // subjectPublicKeyInfo by giving it a fake private key generating an invalid 180 | // signature. ParseCertificate doesn't verify the signature so this will 181 | // work. 182 | // 183 | // Yes, this is very hacky, but avoids having to duplicate code in crypto/x509. 184 | 185 | determineKeyIDFromKeyIntl(pubk, psuedoPrivateKey{}) 186 | 187 | cc := &x509.Certificate{ 188 | SerialNumber: big.NewInt(1), 189 | } 190 | cb, err := x509.CreateCertificate(rand.Reader, cc, cc, pubk, &psuedoPrivateKey{pubk}) 191 | if err != nil { 192 | return "", err 193 | } 194 | 195 | c, err := x509.ParseCertificate(cb) 196 | if err != nil { 197 | return "", err 198 | } 199 | 200 | return determineKeyIDFromCert(c), nil 201 | } 202 | 203 | func determineAccountID(providerURL string, privateKey interface{}) (string, error) { 204 | u, err := accountURLPart(providerURL) 205 | if err != nil { 206 | return "", err 207 | } 208 | 209 | keyID, err := determineKeyIDFromKey(privateKey) 210 | if err != nil { 211 | return "", err 212 | } 213 | 214 | return u + "/" + keyID, nil 215 | } 216 | 217 | func determineCertificateID(url string) string { 218 | h := sha256.New() 219 | h.Write([]byte(url)) 220 | b := h.Sum(nil) 221 | return strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(b), "=")) 222 | } 223 | 224 | var reCertID = regexp.MustCompile(`^[a-z0-9]{52}$`) 225 | 226 | // Returns true iff the given string could (possibly) be a valid certificate 227 | // (or key) ID. 228 | func IsWellFormattedCertificateOrKeyID(certificateID string) bool { 229 | return reCertID.MatchString(certificateID) 230 | } 231 | 232 | func targetGt(a *Target, b *Target) bool { 233 | if a == nil && b == nil { 234 | return false // equal 235 | } else if b == nil { 236 | return true // a > nil 237 | } else if a == nil { 238 | return false // nil < a 239 | } 240 | 241 | if a.Priority > b.Priority { 242 | return true 243 | } else if a.Priority < b.Priority { 244 | return false 245 | } 246 | 247 | return len(a.Satisfy.Names) > len(b.Satisfy.Names) 248 | } 249 | 250 | func containsName(names []string, name string) bool { 251 | for _, n := range names { 252 | if n == name { 253 | return true 254 | } 255 | } 256 | return false 257 | } 258 | 259 | func normalizeNames(names []string) error { 260 | for i := range names { 261 | n, err := acmeutils.NormalizeHostname(names[i]) 262 | if err != nil { 263 | return err 264 | } 265 | 266 | names[i] = n 267 | } 268 | 269 | return nil 270 | } 271 | -------------------------------------------------------------------------------- /storage/util_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "testing" 7 | ) 8 | 9 | // Make sure the determineKeyIDFromKey and determineKeyIDFromPublicKey 10 | // functions produce the same result. 11 | func TestKeyID(t *testing.T) { 12 | pk, err := rsa.GenerateKey(rand.Reader, 2048) 13 | if err != nil { 14 | t.Fatalf("error: %v", err) 15 | } 16 | 17 | keyID, err := determineKeyIDFromKey(pk) 18 | if err != nil { 19 | t.Fatalf("error: %v", err) 20 | } 21 | 22 | keyID2, err := DetermineKeyIDFromPublicKey(&pk.PublicKey) 23 | if err != nil { 24 | t.Fatalf("error: %v", err) 25 | } 26 | 27 | if keyID != keyID2 { 28 | t.Fatalf("key ID mismatch: %#v != %#v", keyID, keyID2) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /storageops/config.go: -------------------------------------------------------------------------------- 1 | package storageops 2 | 3 | import "github.com/hlandau/acmetool/storage" 4 | 5 | // Update targets to remove any mention of hostname from all targets. The 6 | // targets are resaved to disk. 7 | func RemoveTargetHostname(s storage.Store, hostname string) error { 8 | return s.VisitTargets(func(t *storage.Target) error { 9 | if !containsName(t.Satisfy.Names, hostname) { 10 | return nil // continue 11 | } 12 | 13 | t.Satisfy.Names = removeStringFromList(t.Satisfy.Names, hostname) 14 | t.Request.Names = removeStringFromList(t.Request.Names, hostname) 15 | 16 | if len(t.Satisfy.Names) == 0 { 17 | return s.RemoveTarget(t.Filename) 18 | } 19 | 20 | return s.SaveTarget(t) 21 | }) 22 | } 23 | 24 | func containsName(names []string, name string) bool { 25 | for _, n := range names { 26 | if n == name { 27 | return true 28 | } 29 | } 30 | return false 31 | } 32 | 33 | func removeStringFromList(ss []string, s string) []string { 34 | var r []string 35 | for _, x := range ss { 36 | if x != s { 37 | r = append(r, x) 38 | } 39 | } 40 | return r 41 | } 42 | -------------------------------------------------------------------------------- /storageops/cull.go: -------------------------------------------------------------------------------- 1 | package storageops 2 | 3 | import "github.com/hlandau/acmetool/storage" 4 | 5 | func Cull(s storage.Store, simulate bool) error { 6 | certificatesToCull := map[string]*storage.Certificate{} 7 | 8 | // Relink before culling. 9 | err := Relink(s) 10 | if err != nil { 11 | return err 12 | } 13 | 14 | // Select all certificates. 15 | s.VisitCertificates(func(c *storage.Certificate) error { 16 | certificatesToCull[c.ID()] = c 17 | return nil 18 | }) 19 | 20 | // Unselect any certificate which is currently referenced. 21 | s.VisitPreferredCertificates(func(hostname string, c *storage.Certificate) error { 22 | delete(certificatesToCull, c.ID()) 23 | return nil 24 | }) 25 | 26 | // Now delete any certificate which is not generally valid. 27 | for certID, c := range certificatesToCull { 28 | if CertificateGenerallyValid(c) { 29 | continue 30 | } 31 | 32 | if simulate { 33 | log.Noticef("would delete certificate %s", certID) 34 | } else { 35 | log.Noticef("deleting certificate %s", certID) 36 | err := s.RemoveCertificate(certID) 37 | log.Errore(err, "failed to delete certificate ", certID) 38 | } 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /storageops/keysize.go: -------------------------------------------------------------------------------- 1 | package storageops 2 | 3 | import "crypto/elliptic" 4 | 5 | const ( 6 | minRSASize = 2048 7 | defaultRSASize = 2048 8 | maxRSASize = 4096 9 | ) 10 | 11 | func clampRSAKeySize(sz int) int { 12 | if sz == 0 { 13 | return defaultRSASize 14 | } 15 | if sz < minRSASize { 16 | return minRSASize 17 | } 18 | if sz > maxRSASize { 19 | return maxRSASize 20 | } 21 | return sz 22 | } 23 | 24 | const defaultCurve = "nistp256" 25 | 26 | // Make sure the curve name is valid and use a default curve name. "clamp" is 27 | // not the sanest name here but is consistent with clampRSAKeySize. 28 | func clampECDSACurve(curveName string) string { 29 | switch curveName { 30 | case "nistp256", "nistp384", "nistp521": 31 | return curveName 32 | default: 33 | return defaultCurve 34 | } 35 | } 36 | 37 | func getECDSACurve(curveName string) elliptic.Curve { 38 | switch clampECDSACurve(curveName) { 39 | case "nistp256": 40 | return elliptic.P256() 41 | case "nistp384": 42 | return elliptic.P384() 43 | case "nistp521": 44 | return elliptic.P521() 45 | default: 46 | return nil 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /storageops/reconcile-util.go: -------------------------------------------------------------------------------- 1 | package storageops 2 | 3 | import ( 4 | "crypto/x509" 5 | "fmt" 6 | "github.com/hlandau/acmetool/storage" 7 | ) 8 | 9 | func HaveUncachedCertificates(s storage.Store) bool { 10 | haveUncached := false 11 | 12 | s.VisitCertificates(func(c *storage.Certificate) error { 13 | if !c.Cached { 14 | haveUncached = true 15 | } 16 | 17 | return nil 18 | }) 19 | 20 | return haveUncached 21 | } 22 | 23 | // Returns the strings in ys not contained in xs. 24 | func stringsNotIn(xs, ys []string) []string { 25 | m := map[string]struct{}{} 26 | for _, x := range xs { 27 | m[x] = struct{}{} 28 | } 29 | var zs []string 30 | for _, y := range ys { 31 | _, ok := m[y] 32 | if !ok { 33 | zs = append(zs, y) 34 | } 35 | } 36 | return zs 37 | } 38 | 39 | func ensureConceivablySatisfiable(t *storage.Target) { 40 | // We ensure that every stipulation in the satisfy section can be met by the request 41 | // parameters. 42 | excludedNames := stringsNotIn(t.Request.Names, t.Satisfy.Names) 43 | if len(excludedNames) > 0 { 44 | 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) 45 | } 46 | 47 | for _, n := range excludedNames { 48 | t.Request.Names = append(t.Request.Names, n) 49 | } 50 | 51 | if t.Satisfy.Key.Type != "" { 52 | t.Request.Key.Type = t.Satisfy.Key.Type 53 | } 54 | } 55 | 56 | func DoesCertificateSatisfy(c *storage.Certificate, t *storage.Target) bool { 57 | if c.Revoked { 58 | log.Debugf("%v cannot satisfy %v because it is revoked", c, t) 59 | return false 60 | } 61 | 62 | if len(c.Certificates) == 0 { 63 | log.Debugf("%v cannot satisfy %v because it has no actual certificates", c, t) 64 | return false 65 | } 66 | 67 | if c.Key == nil { 68 | // A certificate we don't have the key for is unusable. 69 | log.Debugf("%v cannot satisfy %v because we do not have a key for it", c, t) 70 | return false 71 | } 72 | 73 | cc, err := x509.ParseCertificate(c.Certificates[0]) 74 | if err != nil { 75 | log.Debugf("%v cannot satisfy %v because we cannot parse it: %v", c, t, err) 76 | return false 77 | } 78 | 79 | names := map[string]struct{}{} 80 | for _, name := range cc.DNSNames { 81 | names[name] = struct{}{} 82 | } 83 | 84 | for _, name := range t.Satisfy.Names { 85 | _, ok := names[name] 86 | if !ok { 87 | log.Debugf("%v cannot satisfy %v because required hostname %q is not listed on it: %#v", c, t, name, cc.DNSNames) 88 | return false 89 | } 90 | } 91 | 92 | if t.Satisfy.Key.Type != "" && t.Satisfy.Key.Type != c.Key.Type() { 93 | log.Debugf("%v cannot satisfy %v because required key type (%q) does not match (%q)", c, t, t.Satisfy.Key.Type, c.Key.Type()) 94 | return false 95 | } 96 | 97 | log.Debugf("%v satisfies %v", c, t) 98 | return true 99 | } 100 | 101 | func FindBestCertificateSatisfying(s storage.Store, t *storage.Target) (*storage.Certificate, error) { 102 | var bestCert *storage.Certificate 103 | 104 | err := s.VisitCertificates(func(c *storage.Certificate) error { 105 | if DoesCertificateSatisfy(c, t) { 106 | isBetterThan, err := CertificateBetterThan(c, bestCert) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if isBetterThan { 112 | log.Tracef("findBestCertificateSatisfying: %v > %v", c, bestCert) 113 | bestCert = c 114 | } else { 115 | log.Tracef("findBestCertificateSatisfying: %v <= %v", c, bestCert) 116 | } 117 | } 118 | 119 | return nil 120 | }) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | if bestCert == nil { 126 | return nil, fmt.Errorf("%v: no certificate satisfies this target", t) 127 | } 128 | 129 | return bestCert, nil 130 | } 131 | 132 | func CertificateBetterThan(a, b *storage.Certificate) (bool, error) { 133 | if b == nil || a == nil { 134 | return (b == nil && a != nil), nil 135 | } 136 | 137 | if len(a.Certificates) == 0 || len(b.Certificates) == 0 { 138 | return false, fmt.Errorf("need two certificates to compare") 139 | } 140 | 141 | ac, err := x509.ParseCertificate(a.Certificates[0]) 142 | bc, err2 := x509.ParseCertificate(b.Certificates[0]) 143 | if err != nil || err2 != nil { 144 | if err == nil && err2 != nil { 145 | log.Tracef("certBetterThan: parseable certificate is better than unparseable certificate") 146 | return true, nil 147 | } 148 | 149 | return false, nil 150 | } 151 | 152 | isAfter := ac.NotAfter.After(bc.NotAfter) 153 | log.Tracef("certBetterThan: (%v > %v)=%v", ac.NotAfter, bc.NotAfter, isAfter) 154 | return isAfter, nil 155 | } 156 | 157 | func CertificateNeedsRenewing(c *storage.Certificate, t *storage.Target) bool { 158 | if len(c.Certificates) == 0 { 159 | log.Debugf("%v: not renewing because it has no actual certificates (???)", c) 160 | return false 161 | } 162 | 163 | cc, err := x509.ParseCertificate(c.Certificates[0]) 164 | if err != nil { 165 | log.Debugf("%v: not renewing because its end certificate is unparseable", c) 166 | return false 167 | } 168 | 169 | renewTime := renewTime(cc.NotBefore, cc.NotAfter, t) 170 | needsRenewing := !InternalClock.Now().Before(renewTime) 171 | 172 | log.Debugf("%v needsRenewing=%v notAfter=%v", c, needsRenewing, cc.NotAfter) 173 | return needsRenewing 174 | } 175 | 176 | // This is used to detertmine whether to cull certificates. 177 | func CertificateGenerallyValid(c *storage.Certificate) bool { 178 | // This function is very conservative because if we return false 179 | // the certificate will get deleted. Revocation and expiry are 180 | // good reasons to delete. We already know the certificate is 181 | // unreferenced. 182 | 183 | if c.Revoked { 184 | log.Debugf("%v not generally valid because it is revoked", c) 185 | return false 186 | } 187 | 188 | if len(c.Certificates) == 0 { 189 | // If we have no actual certificates, give the benefit of the doubt. 190 | // Maybe the certificate is undownloaded. 191 | log.Debugf("%v has no actual certificates, assuming valid", c) 192 | return true 193 | } 194 | 195 | cc, err := x509.ParseCertificate(c.Certificates[0]) 196 | if err != nil { 197 | log.Debugf("%v cannot be parsed, assuming valid", c) 198 | return false 199 | } 200 | 201 | if !InternalClock.Now().Before(cc.NotAfter) { 202 | log.Debugf("%v not generally valid because it is expired", c) 203 | return false 204 | } 205 | 206 | return true 207 | } 208 | -------------------------------------------------------------------------------- /storageops/revoke.go: -------------------------------------------------------------------------------- 1 | package storageops 2 | 3 | import ( 4 | "fmt" 5 | "github.com/hlandau/acmetool/storage" 6 | "github.com/hlandau/acmetool/util" 7 | ) 8 | 9 | func RevokeByCertificateOrKeyID(s storage.Store, id string) error { 10 | c := s.CertificateByID(id) 11 | if c == nil { 12 | return revokeByKeyID(s, id) 13 | } 14 | 15 | if c.Revoked { 16 | log.Warnf("%v already revoked", c) 17 | return nil 18 | } 19 | 20 | c.RevocationDesired = true 21 | return s.SaveCertificate(c) 22 | } 23 | 24 | func revokeByKeyID(s storage.Store, keyID string) error { 25 | k := s.KeyByID(keyID) 26 | if k == nil { 27 | return fmt.Errorf("cannot find certificate or key with given ID: %q", keyID) 28 | } 29 | 30 | var merr util.MultiError 31 | s.VisitCertificates(func(c *storage.Certificate) error { 32 | if c.Key != k { 33 | return nil // continue 34 | } 35 | 36 | err := RevokeByCertificateOrKeyID(s, c.ID()) 37 | if err != nil { 38 | merr = append(merr, fmt.Errorf("failed to mark %v for revocation: %v", c, err)) 39 | } 40 | 41 | return nil 42 | }) 43 | 44 | if len(merr) > 0 { 45 | return merr 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /storageops/util.go: -------------------------------------------------------------------------------- 1 | package storageops 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "fmt" 10 | "github.com/hlandau/acmetool/storage" 11 | "time" 12 | ) 13 | 14 | type targetSorter []*storage.Target 15 | 16 | func (ts targetSorter) Len() int { 17 | return len(ts) 18 | } 19 | 20 | func (ts targetSorter) Swap(i, j int) { 21 | ts[i], ts[j] = ts[j], ts[i] 22 | } 23 | 24 | func (ts targetSorter) Less(i, j int) bool { 25 | return targetGt(ts[j], ts[i]) 26 | } 27 | 28 | func targetGt(a *storage.Target, b *storage.Target) bool { 29 | if a == nil && b == nil { 30 | return false // equal 31 | } else if b == nil { 32 | return true // a > nil 33 | } else if a == nil { 34 | return false // nil < a 35 | } 36 | 37 | if a.Priority > b.Priority { 38 | return true 39 | } else if a.Priority < b.Priority { 40 | return false 41 | } 42 | 43 | return len(a.Satisfy.Names) > len(b.Satisfy.Names) 44 | } 45 | 46 | // This is 30 days, which is a bit high, but Let's Encrypt sends expiration 47 | // emails at 19 days, so... 48 | const defaultRenewalMarginDays = 30 49 | 50 | func renewTime(notBefore, notAfter time.Time, t *storage.Target) time.Time { 51 | renewalMarginDays := defaultRenewalMarginDays 52 | if t.Satisfy.Margin > 0 { 53 | renewalMarginDays = t.Satisfy.Margin 54 | } 55 | 56 | renewalMargin := time.Duration(renewalMarginDays) * 24 * time.Hour 57 | 58 | validityPeriod := notAfter.Sub(notBefore) 59 | renewSpan := validityPeriod / 3 60 | if renewSpan > renewalMargin { 61 | renewSpan = renewalMargin 62 | } 63 | 64 | return notAfter.Add(-renewSpan) 65 | } 66 | 67 | func signatureAlgorithmFromKey(pk crypto.PrivateKey) (x509.SignatureAlgorithm, error) { 68 | switch pk.(type) { 69 | case *rsa.PrivateKey: 70 | return x509.SHA256WithRSA, nil 71 | case *ecdsa.PrivateKey: 72 | return x509.ECDSAWithSHA256, nil 73 | default: 74 | return x509.UnknownSignatureAlgorithm, fmt.Errorf("unknown key type %T", pk) 75 | } 76 | } 77 | 78 | func generateKey(trk *storage.TargetRequestKey) (pk crypto.PrivateKey, err error) { 79 | switch trk.Type { 80 | default: 81 | fallthrough // ... 82 | case "", "rsa": 83 | pk, err = rsa.GenerateKey(rand.Reader, clampRSAKeySize(trk.RSASize)) 84 | case "ecdsa": 85 | pk, err = ecdsa.GenerateKey(getECDSACurve(trk.ECDSACurve), rand.Reader) 86 | } 87 | 88 | return 89 | } 90 | 91 | // Error associated with a specific target, for clarity of error messages. 92 | type TargetSpecificError struct { 93 | Target *storage.Target 94 | Err error 95 | } 96 | 97 | func (tse *TargetSpecificError) Error() string { 98 | return fmt.Sprintf("error satisfying %v: %v", tse.Target, tse.Err) 99 | } 100 | -------------------------------------------------------------------------------- /util/multierror.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "fmt" 4 | 5 | // Used to return multiple errors, for example when several targets cannot be 6 | // reconciled. This prevents one failing target from blocking others. 7 | type MultiError []error 8 | 9 | func (merr MultiError) Error() string { 10 | s := "" 11 | for _, e := range merr { 12 | if s != "" { 13 | s += "; \n" 14 | } 15 | s += e.Error() 16 | } 17 | return "the following errors occurred:\n" + s 18 | } 19 | 20 | // Used to return an error that wraps another error. 21 | type WrapError struct { 22 | Msg string 23 | Sub error 24 | } 25 | 26 | // Create a new error that wraps another error. 27 | func NewWrapError(sub error, msg string, args ...interface{}) *WrapError { 28 | return &WrapError{ 29 | Msg: fmt.Sprintf(msg, args...), 30 | Sub: sub, 31 | } 32 | } 33 | 34 | func (werr *WrapError) Error() string { 35 | return fmt.Sprintf("%s [due to inner error: %v]", werr.Msg, werr.Sub) 36 | } 37 | 38 | // PertError knows whether it's temporary or not. 39 | type PertError struct { 40 | error 41 | temporary bool 42 | } 43 | 44 | // Create an error that knows whether it's temporary or not. 45 | func NewPertError(isTemporary bool, sub error) error { 46 | return &PertError{sub, isTemporary} 47 | } 48 | 49 | // Returns true iff the error is temporary. Compatible with the Temporary 50 | // method of the "net" package's OpError type. 51 | func (e *PertError) Temporary() bool { 52 | return e.temporary 53 | } 54 | 55 | type tmp interface { 56 | Temporary() bool 57 | } 58 | 59 | // Returns whether an error is temporary or not. An error is temporary if it 60 | // implements the interface { Temporary() bool } and that method returns true. 61 | // Errors which don't implement this interface aren't temporary. 62 | func IsTemporary(err error) bool { 63 | x, ok := err.(tmp) 64 | if !ok { 65 | return false 66 | } 67 | 68 | return x.Temporary() 69 | } 70 | --------------------------------------------------------------------------------