From 424232c5af23378c0aa767a4c274f2b7a97812e0 Mon Sep 17 00:00:00 2001 From: apple Date: Sat, 14 Nov 2026 14:51:34 +0800 Subject: [PATCH] chore: update letsencrypt plugin to 0.25.2 Vendored copy refreshed from dokku/dokku-letsencrypt master (0.20.4 -> 0.25.2), keeping the same set of files the fork vendors (no tests, Makefile, or CI workflow). --- .../.github/workflows/bump-version.yaml | 9 +- .../.github/workflows/tagged-release.yaml | 2 +- plugins/letsencrypt/.gitignore | 2 + plugins/letsencrypt/Dockerfile | 2 +- plugins/letsencrypt/README.md | 551 +++++++++++++----- plugins/letsencrypt/command-functions | 94 ++- plugins/letsencrypt/config | 2 + plugins/letsencrypt/cron-entries | 2 +- plugins/letsencrypt/help-functions | 5 +- plugins/letsencrypt/install | 40 +- plugins/letsencrypt/internal-functions | 531 +++++++++++++++-- plugins/letsencrypt/plugin.toml | 2 +- plugins/letsencrypt/report | 2 +- 13 files changed, 1011 insertions(+), 233 deletions(-) diff --git a/plugins/letsencrypt/.github/workflows/bump-version.yaml b/plugins/letsencrypt/.github/workflows/bump-version.yaml index ab6ba5c22..e369ce0cb 100644 --- a/plugins/letsencrypt/.github/workflows/bump-version.yaml +++ b/plugins/letsencrypt/.github/workflows/bump-version.yaml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 0 token: ${{ env.GITHUB_ACCESS_TOKEN }} @@ -42,11 +42,16 @@ jobs: bump: ${{ github.event.inputs.bump_type }} input: ${{ steps.latest-tag.outputs.GIT_LATEST_TAG }} - - name: Create and Push Tag + - name: Bump plugin.toml and Push Release run: | git config --global user.name 'Dokku Bot' git config --global user.email no-reply@dokku.com + VERSION="${GIT_NEXT_TAG#v}" + sed -i "s/^version = .*/version = \"$VERSION\"/" plugin.toml + git add plugin.toml + git commit -m "Release $VERSION" git tag "$GIT_NEXT_TAG" + git push origin "HEAD:$GITHUB_REF_NAME" git push origin "$GIT_NEXT_TAG" env: GIT_NEXT_TAG: ${{ steps.next-tag.outputs.version }} diff --git a/plugins/letsencrypt/.github/workflows/tagged-release.yaml b/plugins/letsencrypt/.github/workflows/tagged-release.yaml index 1805bd2be..805a82598 100644 --- a/plugins/letsencrypt/.github/workflows/tagged-release.yaml +++ b/plugins/letsencrypt/.github/workflows/tagged-release.yaml @@ -14,7 +14,7 @@ jobs: steps: - name: Release - uses: softprops/action-gh-release@v2.6.1 + uses: softprops/action-gh-release@v3.0.4 with: generate_release_notes: true make_latest: "true" diff --git a/plugins/letsencrypt/.gitignore b/plugins/letsencrypt/.gitignore index 5ead12e94..739a65e04 100644 --- a/plugins/letsencrypt/.gitignore +++ b/plugins/letsencrypt/.gitignore @@ -8,3 +8,5 @@ Session.vim .netrwhist *~ +# test scratch space +tmp/ diff --git a/plugins/letsencrypt/Dockerfile b/plugins/letsencrypt/Dockerfile index 5e4cecefa..61d514bdd 100644 --- a/plugins/letsencrypt/Dockerfile +++ b/plugins/letsencrypt/Dockerfile @@ -1 +1 @@ -FROM goacme/lego:v4.33.0 +FROM goacme/lego:v4.35.2 diff --git a/plugins/letsencrypt/README.md b/plugins/letsencrypt/README.md index 2c67796dc..0fc5cf550 100644 --- a/plugins/letsencrypt/README.md +++ b/plugins/letsencrypt/README.md @@ -1,217 +1,494 @@ # dokku-letsencrypt -dokku-letsencrypt is the official plugin for [dokku][dokku] that gives the ability to automatically retrieve and install TLS certificates from [letsencrypt.org](https://letsencrypt.org). During ACME validation, your app will stay available at any time. +Official dokku plugin that retrieves and installs free TLS certificates from [Let's Encrypt](https://letsencrypt.org). The app stays online throughout ACME validation, and certificates are renewed automatically once a cron job is enabled. -> By running this plugin, you agree to the Let's Encrypt Subscriber Agreement automatically (because prompting you whether you agree might break running the plugin as part of a cronjob). -> -> If you like Let's Encrypt, please consider [donating to Let's Encrypt](https://letsencrypt.org/donate). +> Running this plugin counts as accepting the Let's Encrypt Subscriber Agreement on your behalf. The plugin passes `--accept-tos` to `lego` so that unattended cron-based renewal does not block on a prompt. ## Installation ```shell sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git -sudo dokku letsencrypt:cron-job --add # <- To enable auto-renew +sudo dokku letsencrypt:cron-job --add ``` -### Upgrading from previous versions +The first command installs the plugin and pulls the `dokku/letsencrypt` Docker image used to run the [lego](https://github.com/go-acme/lego) ACME client. The second command schedules daily auto-renewals, so existing certificates renew themselves before they expire. -```shell -sudo dokku plugin:update letsencrypt -``` +To upgrade later, run `sudo dokku plugin:update letsencrypt`. The update step also migrates legacy `DOKKU_LETSENCRYPT_*` environment variables (`_EMAIL`, `_GRACEPERIOD`, `_ARGS`, `_SERVER`) and the older `lego-docker-args` property to their current property names, so no manual reconfiguration is needed. -## Commands +You can influence what the install and update commands do via the following host-level environment variables: -``` -$ dokku letsencrypt:help - letsencrypt:active Verify if letsencrypt is active for an app - letsencrypt:auto-renew Auto-renew all apps secured by letsencrypt if renewal is necessary - letsencrypt:auto-renew Auto-renew app if renewal is necessary - letsencrypt:cleanup Cleanup stale certificates and configurations - letsencrypt:cron-job <--add|--remove> Add or remove an auto-renewal cronjob - letsencrypt:disable Disable letsencrypt for an app - letsencrypt:enable Enable or renew letsencrypt for an app - letsencrypt:list List letsencrypt-secured apps with certificate expiry - letsencrypt:revoke Revoke letsencrypt certificate for app -``` +| Variable | Default | Description | +|---|---|---| +| `LETSENCRYPT_IMAGE` | `dokku/letsencrypt` | Docker image used for the lego container. Override this if you maintain a fork or a mirror. | +| `LETSENCRYPT_IMAGE_VERSION` | image tag from this repo's `Dockerfile` | Tag of the lego image to pull and run. | +| `LETSENCRYPT_DISABLE_PULL` | (unset) | Set to `true` to skip `docker pull` during install and update. Useful on air-gapped hosts where the image is already loaded. | ## Usage -> If using this plugin with Cloudflare: -> -> - The domain dns should be setup in "Proxied" mode -> - SSL/TLS mode must be in "Full" mode -> - Using letsencrypt in "Flexible" mode will cause Cloudflare to detect your server as down -> - Using "Full" mode will require disabling SSL/TLS in cloudflare in order to renew the certificate. -> -> If using "Flexible" SSL/TLS mode, avoid using this plugin. -> -> See these two links for more details: -> -> - https://community.cloudflare.com/t/lets-encrypt-ssl-cannot-renew-with-cloudflare/257666 -> - https://support.cloudflare.com/hc/en-us/articles/214820528-Validating-a-Let-s-Encrypt-Certificate-on-a-Site-Already-Active-on-Cloudflare +Help for any command can be displayed by running `dokku letsencrypt:help`. Plugin help output together with this README is used to generate the public documentation; the per-command subsections below cover each command in depth. -The app which is obtaining a letsencrypt certificate must already be deployed and accessible over the internet (i.e. in the browser) in order to add letsencrypt to your app. This plugin will fail to apply for an app that has otherwise only been created. - -Obtain a Let's encrypt TLS certificate for app `myapp` (you can also run this command to renew the certificate): +### Commands ``` -$ dokku letsencrypt:set myapp email your@email.tld ------> Setting email to your@email.tld -$ dokku letsencrypt:enable myapp -=====> Let's Encrypt myapp... ------> Updating letsencrypt docker image... -latest: Pulling from dokku/letsencrypt +letsencrypt:active Verify if letsencrypt is active for an app +letsencrypt:auto-renew [] Auto-renew app if renewal is necessary +letsencrypt:cleanup Remove stale certificate directories for app +letsencrypt:cron-job [--add|--remove] Add, remove, or display the status of the auto-renewal cron job. +letsencrypt:disable Disable letsencrypt for an app +letsencrypt:enable [--force] Enable or renew letsencrypt for an app (skipped when a valid certificate already exists unless --force is set) +letsencrypt:help Display letsencrypt help +letsencrypt:list List letsencrypt-secured apps with certificate expiry times +letsencrypt:report [|--global] [] [--format json] Display a letsencrypt report for one or more apps +letsencrypt:revoke Revoke letsencrypt certificate for app +letsencrypt:set () Set or clear a letsencrypt property for an app +``` -Digest: sha256:20f2a619795c1a3252db6508f77d6d3648ad5b336e67caaf801126367dbdfa22 -Status: Image is up to date for dokku/letsencrypt:latest - done +### Basic usage + +The app needs to already be deployed and reachable on the public internet over HTTP before a certificate can be issued. Let's Encrypt fetches a challenge file from your app's domain to prove you control it, so an app that has only been created (no successful deploy yet) cannot be enabled. + +Set an email address, then enable the plugin for the app: + +```shell +dokku letsencrypt:set myapp email your@email.tld +dokku letsencrypt:enable myapp +``` + +A successful run looks like this: + +``` +=====> Let's Encrypt myapp... -----> Enabling letsencrypt proxy for myapp... ------> Getting letsencrypt certificate for myapp... +-----> Getting letsencrypt certificate for myapp via HTTP-01 - Domain 'myapp.mydomain.com' [ removed various log messages for brevity ] -----> Certificate retrieved successfully. ------> Symlinking let's encrypt certificates +-----> Installing let's encrypt certificates -----> Configuring SSL for myapp.mydomain.com...(using /var/lib/dokku/plugins/available/nginx-vhosts/templates/nginx.ssl.conf.template) ------> Creating https nginx.conf ------> Running nginx-pre-reload - Reloading nginx -----> Disabling letsencrypt proxy for myapp... - done + Done ``` -Once the certificate is installed, you can use the `certs:*` built-in commands to edit and query your certificate. - -You could also use the following command to set an email address for global. So you don't need to type the email address for different application. +Once the certificate is installed, the regular `dokku certs:*` commands can read, replace, or remove it. Setting an email once at the `--global` scope spares you from repeating it for every app: ```shell dokku letsencrypt:set --global email your@email.tld ``` -## Automatic certificate renewal +When you add or change an app's domains afterwards, the plugin logs a warning reminding you to run `letsencrypt:enable` again so the certificate covers the new SAN list. -To enable the automatic renewal of certificates, a cronjob needs to be defined for -the `dokku` user which will run daily and renew any certificates that are due to -be renewed. +### Verify if letsencrypt is active for an app -This can be done using the following command: +```shell +# usage +dokku letsencrypt:active +``` + +Prints `true` if the certificate currently installed on the app was issued by this plugin, `false` otherwise. Useful in scripts that need to decide whether to call `letsencrypt:enable`. + +```shell +dokku letsencrypt:active myapp +``` + +### Enable letsencrypt for an app + +```shell +# usage +dokku letsencrypt:enable [--force] +dokku letsencrypt:enable --all [--force] +``` + +flags: + +- `--force` / `-f`: request a new certificate even if the existing one is still valid. See [Idempotent enable](#idempotent-enable) for what triggers a real ACME call by default. +- `--all`: run the enable flow against every app on the host. Useful after a bulk configuration change such as updating the global `email` or `server`. + +Issues a certificate via the configured challenge type (HTTP-01 by default, DNS-01 when `dns-provider` is set), installs it on the app, and reloads nginx. The command is safe to call on every deploy; it only contacts the ACME server when something has actually changed. + +Enable a single app: + +```shell +dokku letsencrypt:enable myapp +``` + +Force a fresh certificate request for a single app: + +```shell +dokku letsencrypt:enable myapp --force +``` + +Enable every app on the host: + +```shell +dokku letsencrypt:enable --all +``` + +### Auto-renew certificates if necessary + +```shell +# usage +dokku letsencrypt:auto-renew [] +``` + +Renews any certificate whose remaining lifetime has dropped below the configured `graceperiod`. With no app argument the plugin scans every app on the host, sorted by ascending time-to-renewal so the most urgent renewals run first (this matters when an ACME rate limit is in play). With an app argument it only renews that one app. + +The auto-renewal cron job invokes this command, but you can also run it manually: + +```shell +dokku letsencrypt:auto-renew +dokku letsencrypt:auto-renew myapp +``` + +### Manage the auto-renewal cron job + +```shell +# usage +dokku letsencrypt:cron-job [--add|--remove] +``` + +flags: + +- `--add`: install the auto-renew cron entry. +- `--remove`: remove the auto-renew cron entry. +- no flag: print whether the cron job is currently installed. + +Renewal can only happen if something runs `letsencrypt:auto-renew` on a schedule. This command writes (or removes) that cron entry on your behalf. When the dokku `cron` plugin is installed, the entry is `24 6 * * *` (daily at 06:24) and its output is appended to `/var/log/dokku/letsencrypt.log`. Otherwise the plugin falls back to writing `@daily` directly to the dokku user's crontab. + +```shell +dokku letsencrypt:cron-job --add +dokku letsencrypt:cron-job +dokku letsencrypt:cron-job --remove +``` + +### Disable letsencrypt for an app + +```shell +# usage +dokku letsencrypt:disable +``` + +Removes the certificate and the plugin's per-app state from `$DOKKU_ROOT//letsencrypt` and `$DOKKU_ROOT//tls`, then triggers nginx to drop the HTTPS configuration. The app stays running on plain HTTP. Run this when you want to take an app off Let's Encrypt without revoking the certificate at the ACME server. + +```shell +dokku letsencrypt:disable myapp +``` + +### Remove stale certificate directories + +```shell +# usage +dokku letsencrypt:cleanup +``` + +Every certificate request lands in a hash-keyed directory under `$DOKKU_ROOT//letsencrypt/certs/`, and `current` symlinks to the active one. Old hash directories accumulate when the lego config changes (new domain, different `lego-args`, etc.). `letsencrypt:cleanup` removes everything except the currently-active hash directory. + +```shell +dokku letsencrypt:cleanup myapp +``` + +### List letsencrypt-secured apps + +```shell +# usage +dokku letsencrypt:list +``` + +Prints a table of every app with a Let's Encrypt certificate installed: expiry timestamp, time remaining on the certificate, and time remaining before auto-renewal kicks in. The list is sorted by expiry date so the most urgent entries appear first. + +```shell +dokku letsencrypt:list +``` + +### Display a letsencrypt report + +```shell +# usage +dokku letsencrypt:report [|--global] [] [--format json] +``` + +flags: + +- `--global`: report only global properties. +- `--format json`: emit a JSON object instead of the default stdout layout. Cannot be combined with an info flag. +- `--letsencrypt-` (such as `--letsencrypt-email` or `--letsencrypt-computed-server`): print the value for that single property. + +Exposes the plugin's configuration and state for tooling and diagnostics. With no arguments it prints a report for every app; with an app name it scopes to that one app; with `--global` it shows only the global values. See [Reports](#reports) for the redaction behavior in the top-level `dokku report` command. + +```shell +dokku letsencrypt:report +dokku letsencrypt:report myapp +dokku letsencrypt:report --global +dokku letsencrypt:report myapp --format json +dokku letsencrypt:report myapp --letsencrypt-email +``` + +### Revoke letsencrypt certificate for app + +```shell +# usage +dokku letsencrypt:revoke +``` + +Tells the ACME server to revoke the certificate currently installed on the app. The local files remain in place so the next `letsencrypt:enable` can reuse them or replace them. Use this when a private key has leaked or you no longer want the certificate to be considered valid by browsers. + +```shell +dokku letsencrypt:revoke myapp +``` + +### Set or clear a letsencrypt property + +```shell +# usage +dokku letsencrypt:set |--global [] +``` + +Sets the property to the given value, or clears it when no value is provided. The first argument can be an app name (sets the property for that app) or `--global` (sets the default for every app). When the plugin needs a property's value it first checks the app, then falls back to the global setting. + +Valid properties are `dns-provider`, `email`, `graceperiod`, `server`, `lego-args`, `lego-docker-options`, and any `dns-provider-*` key (used for DNS-01 credentials, see [DNS-01 challenge](#dns-01-challenge)). + +Set a per-app value: + +```shell +dokku letsencrypt:set myapp email myapp-admin@example.com +``` + +Set a global default: + +```shell +dokku letsencrypt:set --global email admin@example.com +``` + +Clear an app-level value (falls back to the global value, if any): + +```shell +dokku letsencrypt:set myapp email +``` + +## Configuration + +All non-secret configuration is stored via `letsencrypt:set`. App-level values take precedence over global values, so it is common to set safe defaults at `--global` and only override per app when needed. + +| Property | Default | Description | +|---|---|---| +| `dns-provider` | (none) | Name of a [valid lego DNS provider](https://go-acme.github.io/lego/dns/). Setting this switches the app from HTTP-01 to DNS-01, which is what enables wildcard certificates. | +| `email` | (none) | **Required.** Address used to register the ACME account. Let's Encrypt sends expiry reminders here. | +| `graceperiod` | `2592000` (30 days) | Seconds remaining on a certificate before auto-renew tries to replace it. The Let's Encrypt certificate lifetime is 90 days, so 30 days of grace is the common default. | +| `lego-args` | (none) | Extra arguments appended to the `lego` CLI invocation. See the [lego CLI docs](https://go-acme.github.io/lego/usage/cli/). | +| `lego-docker-options` | (none) | Extra flags appended to `docker run` when starting the lego container. Use this for volume mounts, env files, or custom networks. This is distinct from `lego-args`, which goes to the `lego` binary itself. | +| `server` | `default` | ACME directory URL. `default` resolves to `https://acme-v02.api.letsencrypt.org/directory`. `staging` resolves to `https://acme-staging-v02.api.letsencrypt.org/directory`. Any other value is used as a literal URL. | + +Set a single property: + +```shell +dokku letsencrypt:set myapp email myapp-admin@example.com +dokku letsencrypt:set --global graceperiod 1209600 +``` + +`dns-provider-*` properties (DNS-01 credentials) live in the same property store and follow the same precedence rule. They are covered in [DNS-01 challenge](#dns-01-challenge). + +## Automatic renewal + +Let's Encrypt certificates expire after 90 days, so any production app needs unattended renewal. The plugin's renewal mechanism is a cron job that runs `letsencrypt:auto-renew`, which iterates over every Let's Encrypt-secured app and re-issues certificates whose remaining lifetime has dropped below `graceperiod` (default: 30 days). + +Install the cron job once during initial setup: ```shell dokku letsencrypt:cron-job --add ``` -## Configuration +When the dokku `cron` plugin is installed, the entry is `24 6 * * *` (daily at 06:24) and its output is appended to `/var/log/dokku/letsencrypt.log`. Otherwise the plugin falls back to writing `@daily` to the dokku user's crontab. Running `letsencrypt:cron-job` without a flag reports whether the entry is installed. -`dokku-letsencrypt` uses the [Dokku environment variable manager](https://dokku.com/docs/configuration/environment-variables/) for all configuration. The important environment variables are: +## Idempotent enable -Variable | Default | Description ----------------------|-------------------|------------------------------------------------------------------------- -`dns-provider` | (none) | The name of a [valid lego dns-provider](https://go-acme.github.io/lego/dns/) -`email` | (none) | **REQUIRED:** E-mail address to use for registering with Let's Encrypt. -`graceperiod` | 2592000 (30 days) | Time in seconds left on a certificate before it should get renewed -`lego-docker-args` | (none) | Extra arguments to pass via `docker run`. See the [lego CLI documentation](https://go-acme.github.io/lego/usage/cli/) for available options. -`server` | default | Which ACME server to use. Can be 'default', 'staging' or a URL +`letsencrypt:enable ` is safe to call on every deploy. It only contacts the ACME server when one of the following is true: -You can set a setting using `dokku letsencrypt:set $APP $SETTING_NAME $SETTING_VALUE`. When looking for a setting, the plugin will first look if it was defined for the current app and fall back to settings defined by `--global`. +- the app does not currently have a Let's Encrypt certificate installed, +- the app's domains, email, server, `lego-args`, `lego-docker-options`, or `dns-provider` have changed since the certificate was issued, +- the certificate is within its renewal grace period (see `graceperiod`). -> Note: See "DNS-01 Challenge" for more information on configuration a dns-provider for DNS-01 based challenges and wildcard support. +In every other case the command exits successfully without touching nginx, the lego container, or the ACME server. This is what makes the command safe to wire into CI-driven review-app deploys without burning through Let's Encrypt's rate limits. -## Redirecting from HTTP to HTTPS +To force a new certificate request even when the existing certificate is still valid, pass `--force`: -Dokku's default nginx template will automatically redirect HTTP requests to HTTPS when a certificate is present. - -You can [customize the nginx template](https://dokku.com/docs/networking/proxies/nginx/) if you want different behaviour. - -## Design - -`dokku-letsencrypt` gets around having to disable your web server using the following workflow: - - 1. Temporarily add a reverse proxy for the `/.well-known/` path of your app to `https://127.0.0.1:$ACMEPORT` - 2. Run [the acme/lego Let's Encrypt client](https://github.com/go-acme/lego) in a [Docker container](https://hub.docker.com/r/goacme/lego/) binding to `$ACMEPORT` to complete the ACME challenge and retrieve the TLS certificates - 3. Install the TLS certificates - 4. Remove the reverse proxy and reload nginx - -For a more in-depth explanation, see [this blog post](https://blog.semicolonsoftware.de/securing-dokku-with-lets-encrypt-tls-certificates/) - -## Dockerfile and Image-based Deploys - -When securing Dockerfile and Image-based deploys with dokku-letsencrypt, be aware of the [proxy mechanism for dokku 0.6+](https://dokku.com/docs/networking/port-management/#dockerfile). - -For Dockerfile deploys - as well as those via `git:from-image` - Dokku will determine which ports a container exposes (using `EXPOSE`) and will proxy them on the same port numbers on the host. If the Dockerfile exposes another port than 443, then HTTPS port 443 **needs to be manually configured** using the `dokku ports:*` commands in order for certificate validation and browsing to the app via HTTPS to work. - -A full workflow for creating a new Dockerfile/Image-based deployment (assuming the app is listening/exposed on port 5555) with `dokku-letsencrypt` would be: - -1. Create a new app `myapp` in dokku and push to the `dokku@myhost.com` remote. -2. On the dokku host, use `dokku letsencrypt:enable myapp` to retrieve HTTPS certificates. -3. On the dokku host, use `dokku ports:add myapp https:443:5555` to proxy HTTPS port 443 to port 5555 on the Docker image - -After these steps, the output of `dokku ports:report myapp` should look like this: - -``` -=====> myapp ports information - Ports map: https:443:5555 - Ports map detected: https:5555:5555 +```shell +dokku letsencrypt:enable myapp --force ``` -Replace the container port (`5555` in the above example) with the port your app is listening on. +This is useful when copying certificates between servers (the new host has no ACME-account record of the existing cert) or when manually rotating a certificate. -## Dealing with rate limit +## Reports -Be aware that Let's Encrypt is subject to [rate limiting](https://letsencrypt.org/docs/rate-limits/). The limit about the number of certificates you can add on a domain per week is a concern for dokku because of the default domain added to your new applications, named like `.`: using `dokku-letsencrypt` on all your applications would create a certificate for each application subdomain on ``. +`letsencrypt:report` exposes per-app and global plugin properties for tooling and diagnostics. Without arguments it prints a human-readable report for every app, with an app name it reports on that app, and with `--global` it limits output to global properties. Because global properties are app-independent, `--global` works on a host that has no apps deployed yet, and an argument-less invocation on such a host warns instead of failing. -As a workaround, if you want to encrypt many applications, make sure to add a proper domain for each one and remove their default domain before running `dokku-letsencrypt`. For example, if your dokku domain is `dokku.example.com` and you want to encrypt your `foo` app: - -```sh -dokku domains:add foo foo.com -dokku domains:remove foo foo.dokku.example.com -dokku letsencrypt:enable foo +```shell +dokku letsencrypt:report +dokku letsencrypt:report myapp +dokku letsencrypt:report --global ``` -While playing around with this plugin, you might want to switch to the let's encrypt staging server by running `dokku letsencrypt:set myapp server staging` to enjoy much higher rate limits and switching back to the real server by running `dokku letsencrypt:set myapp server` once you are ready. +Pass `--format json` to emit a JSON object instead of the default stdout layout. The flag works with app, global, and "all apps" invocations: -## Generating a Cert for multiple domains +```shell +dokku letsencrypt:report myapp --format json +dokku letsencrypt:report --global --format json +``` -Your [default dokku app](https://dokku.com/docs/networking/proxies/nginx/?h=default+site#default-site) is accessible under the root domain too. So if you have an application `00-default` that is running under `00-default.mydomain.com` it is accessible under `mydomain.com` too. Now if you enable letsencrypt for your `00-default` application, it is not accessible anymore on `mydomain.com`. You can add the root domain to your dokku domains by typing: +Specifying a single property flag (such as `--letsencrypt-email`) prints just that value. Combining `--format json` with a single property flag is rejected. + +```shell +dokku letsencrypt:report myapp --letsencrypt-email +``` + +Any `dns-provider-*` properties set globally or for the app appear in the report alongside the fixed fields. For each set property, the report emits the scopes that actually have a value: a `--letsencrypt-dns-provider-` row when set on the app, a `--letsencrypt-global-dns-provider-` row when set globally, and a `--letsencrypt-computed-dns-provider-` row that resolves to the app value (falling back to the global value). + +The top-level `dokku report ` command aggregates output from every plugin and is commonly used in support contexts. When the letsencrypt section is rendered through that aggregate command, every `dns-provider-*` credential value is redacted to `****` to avoid leaking API keys or token paths into shared output. The provider name and every other property remain unredacted. `dokku letsencrypt:report` is unaffected and continues to show raw values, so operators can verify what they have configured. + +## Challenge types + +ACME (the Let's Encrypt protocol) supports two challenge types, and this plugin can use either of them: + +- **HTTP-01** is the default. Let's Encrypt fetches `http:///.well-known/acme-challenge/` and expects to see a specific response. This requires your app to be reachable on port 80 from the public internet, but needs no special DNS access. +- **DNS-01** proves control of a domain by writing a TXT record at `_acme-challenge.`. The plugin invokes a [lego DNS provider](https://go-acme.github.io/lego/dns/) (Cloudflare, Route53, Namecheap, etc.) to create and remove that record. DNS-01 is the only way to obtain wildcard certificates (`*.example.com`) and is useful when port 80 is firewalled. + +Pick HTTP-01 unless you need wildcard certificates or your app cannot accept inbound connections on port 80. To use DNS-01, see [DNS-01 challenge](#dns-01-challenge). + +## DNS-01 challenge + +> DNS-01 support is sponsored by [Orca Scan Ltd](https://orcascan.com/). + +To enable DNS-01, set `dns-provider` to a supported lego provider, then set the per-provider environment variables that lego reads. Provider-specific env vars are stored as letsencrypt properties prefixed with `dns-provider-`, scoped either globally or per app. Before you run `letsencrypt:enable`, make sure every domain you plan to cover (including any wildcard records) actually resolves to your server. + +```shell +dokku letsencrypt:set --global dns-provider namecheap +dokku letsencrypt:set --global dns-provider-NAMECHEAP_API_USER user +dokku letsencrypt:set --global dns-provider-NAMECHEAP_API_KEY key +``` + +If a DNS provider supports `_FILE`-suffixed environment variables for reading secrets from files, mount the secret file into the lego container with `lego-docker-options` and set the corresponding `dns-provider-*_FILE` property to the in-container path: + +```shell +dokku letsencrypt:set --global lego-docker-options "-v /etc/dokku-letsencrypt/cloudflare-token:/secrets/cf-token:ro" +dokku letsencrypt:set --global dns-provider-CLOUDFLARE_DNS_API_TOKEN_FILE /secrets/cf-token +``` + +### Using the exec DNS provider + +The lego [`exec` DNS provider](https://go-acme.github.io/lego/dns/exec/) shells out to a user-supplied script for creating and removing TXT records. The script must be reachable from inside the lego container at the path stored in `EXEC_PATH`. Use `lego-docker-options` to mount it from the host: + +```shell +sudo install -m 0755 /path/on/host/dns.sh /var/lib/dokku/data/letsencrypt/--global/exec-dns.sh + +dokku letsencrypt:set --global dns-provider exec +dokku letsencrypt:set --global lego-docker-options "-v /var/lib/dokku/data/letsencrypt/--global/exec-dns.sh:/scripts/dns.sh:ro" +dokku letsencrypt:set --global dns-provider-EXEC_PATH /scripts/dns.sh +``` + +Consult the lego documentation for your specific DNS provider for the full set of credentials it needs. + +### Disabling DNS-01 for a single app + +When a global `dns-provider` is set but a particular app's domain is not managed by that provider, set the app's `dns-provider` to `none` to force HTTP-01 for that app only: + +```shell +dokku letsencrypt:set myapp dns-provider none +``` + +Setting the property to an empty string (`dokku letsencrypt:set myapp dns-provider ""`) deletes the app-level value and falls back to the global setting; `none` is the explicit opt-out. The `none` sentinel is also accepted at `--global` scope, where it behaves identically to leaving the property unset. + +## Generating a cert for multiple domains + +Your [default dokku app](https://dokku.com/docs/networking/proxies/nginx/?h=default+site#default-site) is also served at the root domain. If your app `00-default` runs at `00-default.mydomain.com`, it is reachable at `mydomain.com` too. Once you enable letsencrypt for the app, the bare-root URL stops working unless that domain is on the certificate. Add it to the app's domains first: ```shell dokku domains:add 00-default mydomain.com dokku letsencrypt:enable 00-default ``` -## DNS-01 Challenge +The same pattern applies whenever you add or change domains on a Let's Encrypt-secured app: domain changes invalidate the certificate's SAN list, so re-run `letsencrypt:enable`. The plugin emits a warning prompting you to do this whenever `dokku domains:add` or `dokku domains:set` touches the app. -> Functionality sponsored by [Orca Scan Ltd](https://orcascan.com/). +## Default-vhost (`_`) domain -In order to provide a Letsencrypt certificate for a wildcard domain, a DNS-01 challenge must be used. To configure, the `dns-provider` property must be set to a [supported Lego provider](https://go-acme.github.io/lego/dns/). Additionally, the environment variables used by the DNS provider must be set as letsencrypt properties with the prefix `dns-provider-`. Both global and app-specific properties are supported. +Dokku 0.30.4 added support for the literal `_` domain as an nginx default catch-all vhost. Let's Encrypt rejects `_` as an invalid identifier, so the plugin silently drops it from the certificate's SAN list and requests a certificate for the remaining domains. An app whose only domain is `_` cannot be enabled and falls into the existing "no domains detected" error path. -> Warning: Before using a DNS-based challenge, ensure all DNS records - including wildcard records - are pointing at your server. +## Dockerfile and image-based deploys -```shell -# set the provider to namecheap -dokku letsencrypt:set --global dns-provider namecheap +When securing Dockerfile or image-based deploys, the plugin needs port 80 reachable for the HTTP-01 challenge and port 443 mapped to your app's container port for HTTPS traffic. For Dockerfile deploys, dokku proxies whichever ports the image declares with `EXPOSE` on the same port numbers on the host. Dokku also adds a matching `https:443:` mapping for every `http:80:` whenever a certificate is installed, so the standard case requires no manual `dokku ports:*` calls. -# set the properties necessary for namecheap usage -dokku letsencrypt:set --global dns-provider-NAMECHEAP_API_USER user -dokku letsencrypt:set --global dns-provider-NAMECHEAP_API_KEY key +To keep the HTTP-01 challenge working, `letsencrypt:enable` looks for an `http:80:*` entry in the app's proxy port map. If none exists, the plugin injects one using the container port from the first existing `http:*:*` (or `https:*:*`) mapping, including detected mappings from `EXPOSE`. The new mapping is persisted so subsequent renewals do not need to re-add it. Once the certificate is installed, dokku's `post-certs-update` trigger fills in the matching `https:443` mapping, completing the proxy setup. If the app has no `http` or `https` mapping at all, `letsencrypt:enable` exits with an error pointing at `dokku ports:add` rather than issuing a request that the ACME server would reject. DNS-01 deployments skip the port-80 check entirely because they do not use port 80 for validation. + +A full workflow for a Dockerfile or image-based app listening on port 5555: + +1. Create the app and push it to dokku. +2. On the dokku host, run `dokku letsencrypt:enable myapp` to retrieve and install the certificate. + +After this, `dokku ports:report myapp` should show: + +``` +=====> myapp ports information + Ports map: http:80:5555 https:443:5555 + Ports map detected: https:5555:5555 ``` -Due to limitations in how certain DNS providers work, environment variables _must not_ use the `_FILE` based method for referring to values in files. +Replace `5555` with whatever container port your app actually listens on. -Please see the Lego documentation for your DNS provider for more information on what configuration is necessary to utilize DNS-01 challenges. +## HTTP to HTTPS redirect -## Conditional enabling +Dokku's default nginx template automatically redirects HTTP requests to HTTPS once a certificate is installed. To change this behavior, [customize the nginx template](https://dokku.com/docs/networking/proxies/nginx/). -`dokku letsencrypt:enable ` enables letsencrypt for an application or renews the certificate. This may lead to hitting rate limits with letsencrypt. +## Rate limits -To avoid renewals, for example in a continuous deployment scenario, you could first check if letsencrypt has already been enabled for the app: +Let's Encrypt enforces [public rate limits](https://letsencrypt.org/docs/rate-limits/). The most relevant one for dokku is the per-week cap on certificates per registered domain: because dokku gives every new app a default subdomain like `.`, running this plugin on many apps that all share `` will hit that cap quickly. + +The workaround is to set a real domain per app and remove the default subdomain before enabling. For example, if your dokku domain is `dokku.example.com` and you want to secure your `foo` app at `foo.com`: ```shell -dokku letsencrypt:active || dokku letsencrypt:enable +dokku domains:add foo foo.com +dokku domains:remove foo foo.dokku.example.com +dokku letsencrypt:enable foo ``` +While iterating on configuration, point the plugin at Let's Encrypt's staging environment, which has much higher rate limits and issues untrusted certificates: + +```shell +dokku letsencrypt:set myapp server staging +dokku letsencrypt:enable myapp +``` + +Once you are ready for a production certificate, clear the override and re-enable: + +```shell +dokku letsencrypt:set myapp server +dokku letsencrypt:enable myapp --force +``` + +When the ACME server returns a rate-limit response, `letsencrypt:enable` exits non-zero and prints a dedicated warning pointing at the Let's Encrypt rate-limits documentation and suggesting the staging server. The full lego output is still printed above the warning, so the specific limit that was hit ("too many certificates already issued", "too many failed authorizations", etc.) remains visible. + +## Shared ACME account + +Let's Encrypt also limits new accounts per IP to 10 per 3 hours, which would be easy to trip on a busy dokku host. To stay clear of this, the plugin stores a single ACME account in `${DOKKU_LIB_ROOT}/data/letsencrypt/--global/accounts` and mounts it into every lego invocation. Apps sharing the same `email` and `server` reuse one account regardless of how many apps are enabled, matching Let's Encrypt's [recommendation](https://letsencrypt.org/docs/integration-guide/#one-account-or-many) for hosting providers. Apps configured with a distinct `email` or `server` get their own entry under the shared directory, keyed by `(server, email)`. + +When upgrading from a previous version of this plugin, the first `letsencrypt:enable` after the upgrade registers exactly one new account in the shared directory. Existing per-app account material under `$DOKKU_ROOT//letsencrypt/certs//accounts/` is left in place so that `letsencrypt:revoke` for certificates issued before the upgrade can still find the original account. + +## App cloning and renaming + +The plugin clears the destination app's `letsencrypt` directory whenever an app is cloned or renamed. Certificates are bound to specific domains and specific config hashes, so reusing the source app's directory would leave the new app pointing at the wrong cert. After cloning or renaming, run `letsencrypt:enable` on the new app to issue a fresh certificate. + +```shell +dokku apps:clone myapp myapp-staging +dokku letsencrypt:enable myapp-staging +``` + +## Cloudflare + +If your domain is proxied through Cloudflare, set Cloudflare's DNS records to "Proxied" mode and configure SSL/TLS to "Full" mode before enabling this plugin. Cloudflare's "Flexible" mode terminates HTTPS at Cloudflare's edge and connects to your origin over plain HTTP, which means Let's Encrypt's renewal request is served back as HTTP rather than reaching your origin's HTTPS listener. Symptoms range from validation failures to Cloudflare reporting the origin as down. + +If you are committed to "Flexible" SSL/TLS mode, do not use this plugin. For background, see: + +- +- + ## License -This plugin is released under the MIT license. See the file [LICENSE](LICENSE). - -[dokku]: https://github.com/dokku/dokku +[MIT](LICENSE) diff --git a/plugins/letsencrypt/command-functions b/plugins/letsencrypt/command-functions index 492fd8348..47875609c 100755 --- a/plugins/letsencrypt/command-functions +++ b/plugins/letsencrypt/command-functions @@ -40,12 +40,12 @@ cmd-letsencrypt-auto-renew() { # if we should hit a rate limit along the way. # Store the list in a temporary file to avoid subshell issues with pipelines local temp_file=$(mktemp) - fn-letsencrypt-list-apps-with-expiry | sort -nk5 > "$temp_file" + fn-letsencrypt-list-apps-with-expiry --quiet | sort -nk5 > "$temp_file" while IFS=$'\t' read -r -a appExpiry; do if [[ ${appExpiry[4]} -lt 0 ]]; then dokku_log_info1 "${appExpiry[0]} needs renewal" - if ! dokku letsencrypt:enable "${appExpiry[0]}"; then + if ! (fn-letsencrypt-enable "${appExpiry[0]}"); then EXIT_CODE=1 fi else @@ -74,7 +74,7 @@ cmd-letsencrypt-auto-renew() { if [[ $time_to_renewal -lt 0 ]]; then dokku_log_info2 "Auto-renew ${APP}..." - dokku letsencrypt:enable "$APP" + fn-letsencrypt-enable "$APP" else days_left=$(fn-letsencrypt-format-timediff $time_to_renewal) dokku_log_verbose "$APP still has $days_left left before renewal" @@ -128,8 +128,16 @@ cmd-letsencrypt-cron-job() { fn-letsencrypt-cron-job-add elif [[ "$FLAG" == "--remove" ]]; then fn-letsencrypt-cron-job-remove + elif [[ -z "$FLAG" ]]; then + if [[ "$(fn-letsencrypt-is-autorenew-enabled)" == "true" ]]; then + dokku_log_info2_quiet "Auto-renew cron job is enabled" + dokku_log_verbose "Run 'dokku letsencrypt:cron-job --remove' to disable." + else + dokku_log_info2_quiet "Auto-renew cron job is not enabled" + dokku_log_verbose "Run 'dokku letsencrypt:cron-job --add' to enable." + fi else - dokku_log_verbose "Specify --add or --remove to modify the cron-job" + dokku_log_fail "Invalid flag '${FLAG}', specify --add, --remove, or no flag to display status." fi } @@ -164,16 +172,31 @@ cmd-letsencrypt-enable() { declare cmd="letsencrypt:enable" [[ "$1" == "$cmd" ]] && shift 1 + local FORCE=false + local positional=() + local arg + for arg in "$@"; do + case "$arg" in + --force | -f) + FORCE=true + ;; + *) + positional+=("$arg") + ;; + esac + done + set -- "${positional[@]}" + # Support --app/$DOKKU_APP_NAME flag by reordering args into "$cmd $DOKKU_APP_NAME $@" [[ -n "$DOKKU_APP_NAME" ]] && set -- $DOKKU_APP_NAME $@ declare APP="$1" if [[ "$APP" == "--all" ]]; then for app in $(dokku_apps); do - fn-letsencrypt-enable "$app" + fn-letsencrypt-enable "$app" "$FORCE" done else - fn-letsencrypt-enable "$APP" + fn-letsencrypt-enable "$APP" "$FORCE" fi } @@ -198,34 +221,43 @@ cmd-letsencrypt-report() { declare desc="displays a letsencrypt report for one or more apps" declare cmd="letsencrypt:report" [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" INFO_FLAG="$2" - local INSTALLED_APPS - INSTALLED_APPS=$(dokku_apps) + fn-letsencrypt-report-parse-args "$@" + set -- "${REPORT_ARGS[@]}" + + declare APP="${1:-}" INFO_FLAG="${2:-}" if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" APP="" fi + if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then + APP="--global" + fi + if [[ -z "$APP" ]] && [[ -z "$INFO_FLAG" ]]; then INFO_FLAG="true" fi - if [[ -z "$APP" ]]; then - for app in $INSTALLED_APPS; do - cmd-letsencrypt-report-single "$app" "$INFO_FLAG" | tee || true + if [[ "$APP" == "--global" ]]; then + cmd-letsencrypt-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" + elif [[ -z "$APP" ]]; then + for app in $(dokku_apps); do + cmd-letsencrypt-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-letsencrypt-report-single "$APP" "$INFO_FLAG" + cmd-letsencrypt-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } cmd-letsencrypt-report-single() { - declare APP="$1" INFO_FLAG="$2" + declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}" if [[ "$INFO_FLAG" == "true" ]]; then INFO_FLAG="" fi - verify_app_name "$APP" + if [[ "$APP" != "--global" ]]; then + verify_app_name "$APP" + fi local flag_map=( "--letsencrypt-active: $(fn-letsencrypt-is-active "$APP")" "--letsencrypt-autorenew: $(fn-letsencrypt-is-autorenew-enabled "$APP")" @@ -239,16 +271,36 @@ cmd-letsencrypt-report-single() { "--letsencrypt-computed-graceperiod: $(fn-letsencrypt-computed-graceperiod "$APP")" "--letsencrypt-global-graceperiod: $(fn-letsencrypt-global-graceperiod)" "--letsencrypt-graceperiod: $(fn-letsencrypt-graceperiod "$APP")" - "--letsencrypt-computed-lego-docker-args: $(fn-letsencrypt-computed-lego-docker-args "$APP")" - "--letsencrypt-global-lego-docker-args: $(fn-letsencrypt-global-lego-docker-args)" - "--letsencrypt-lego-docker-args: $(fn-letsencrypt-lego-docker-args "$APP")" + "--letsencrypt-computed-lego-args: $(fn-letsencrypt-computed-lego-args "$APP")" + "--letsencrypt-global-lego-args: $(fn-letsencrypt-global-lego-args)" + "--letsencrypt-lego-args: $(fn-letsencrypt-lego-args "$APP")" + "--letsencrypt-computed-lego-docker-options: $(fn-letsencrypt-computed-lego-docker-options "$APP")" + "--letsencrypt-global-lego-docker-options: $(fn-letsencrypt-global-lego-docker-options)" + "--letsencrypt-lego-docker-options: $(fn-letsencrypt-lego-docker-options "$APP")" "--letsencrypt-computed-server: $(fn-letsencrypt-computed-server "$APP")" "--letsencrypt-global-server: $(fn-letsencrypt-global-server)" "--letsencrypt-server: $(fn-letsencrypt-server "$APP")" ) + fn-letsencrypt-report-add-dns-provider-keys flag_map "$APP" + + if [[ "$APP" == "--global" ]]; then + fn-letsencrypt-report-filter-global flag_map + fi + + fn-letsencrypt-report-validate-format "$FORMAT" "$INFO_FLAG" + + if [[ "$FORMAT" == "json" ]]; then + fn-letsencrypt-report-emit-json flag_map + return + fi + if [[ -z "$INFO_FLAG" ]]; then - dokku_log_info2_quiet "${APP} letsencrypt information" + if [[ "$APP" == "--global" ]]; then + dokku_log_info2_quiet "global letsencrypt information" + else + dokku_log_info2_quiet "${APP} letsencrypt information" + fi for flag in "${flag_map[@]}"; do key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')" dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")" @@ -297,13 +349,13 @@ cmd-letsencrypt-set() { declare cmd="letsencrypt:set" [[ "$1" == "$cmd" ]] && shift 1 declare APP="$1" KEY="$2" VALUE="$3" - local VALID_KEYS=("dns-provider" "email" "graceperiod" "server" "lego-docker-args") + local VALID_KEYS=("dns-provider" "email" "graceperiod" "server" "lego-args" "lego-docker-options") [[ "$APP" == "--global" ]] || verify_app_name "$APP" [[ -z "$KEY" ]] && dokku_log_fail "No key specified" if ! fn-in-array "$KEY" "${VALID_KEYS[@]}" && [[ "$KEY" != dns-provider-* ]]; then - dokku_log_fail "Invalid key specified, valid keys include: dns-provider, dns-provider-*, email, graceperiod, server, lego-docker-args" + dokku_log_fail "Invalid key specified, valid keys include: dns-provider, dns-provider-*, email, graceperiod, server, lego-args, lego-docker-options" fi if [[ -n "$VALUE" ]]; then diff --git a/plugins/letsencrypt/config b/plugins/letsencrypt/config index 4af4e4291..6ce5d327d 100755 --- a/plugins/letsencrypt/config +++ b/plugins/letsencrypt/config @@ -6,6 +6,8 @@ _DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export LETSENCRYPT_IMAGE=${LETSENCRYPT_IMAGE:="$(awk -F '[ :]' '{print $2}' "${_DIR}/Dockerfile")"} export LETSENCRYPT_IMAGE_VERSION=${LETSENCRYPT_IMAGE_VERSION:="$(awk -F '[ :]' '{print $3}' "${_DIR}/Dockerfile")"} +export DOKKU_LIB_HOST_ROOT=${DOKKU_LIB_HOST_ROOT:=$DOKKU_LIB_ROOT} + export PLUGIN_DISABLE_PULL=${LETSENCRYPT_DISABLE_PULL:=} export PLUGIN_DISABLE_PULL_VARIABLE="LETSENCRYPT_DISABLE_PULL" export PLUGIN_IMAGE=$LETSENCRYPT_IMAGE diff --git a/plugins/letsencrypt/cron-entries b/plugins/letsencrypt/cron-entries index 8aa640562..2e6f0e72c 100755 --- a/plugins/letsencrypt/cron-entries +++ b/plugins/letsencrypt/cron-entries @@ -1,7 +1,7 @@ #!/usr/bin/env bash trigger-letsencrypt-cron-entries() { - if [[ -f "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" ]]; then + if [[ -f "${DOKKU_LIB_ROOT}/data/letsencrypt/--global/autorenew" ]]; then echo "24 6 * * *;dokku letsencrypt:auto-renew;/var/log/dokku/letsencrypt.log" fi } diff --git a/plugins/letsencrypt/help-functions b/plugins/letsencrypt/help-functions index c92a7d663..458398b63 100755 --- a/plugins/letsencrypt/help-functions +++ b/plugins/letsencrypt/help-functions @@ -30,11 +30,12 @@ fn-help-content() { letsencrypt:active , Verify if letsencrypt is active for an app letsencrypt:auto-renew [], Auto-renew app if renewal is necessary letsencrypt:cleanup , Remove stale certificate directories for app - letsencrypt:cron-job [--add --remove], Add or remove a cron job that periodically calls auto-renew. + letsencrypt:cron-job [--add|--remove], Add, remove, or display the status of the auto-renewal cron job. letsencrypt:disable , Disable letsencrypt for an app - letsencrypt:enable , Enable or renew letsencrypt for an app + letsencrypt:enable [--force], Enable or renew letsencrypt for an app (skipped when a valid certificate already exists unless --force is set) letsencrypt:help, Display letsencrypt help letsencrypt:list, List letsencrypt-secured apps with certificate expiry times + letsencrypt:report [|--global] [] [--format json], Display a letsencrypt report for one or more apps letsencrypt:revoke , Revoke letsencrypt certificate for app letsencrypt:set (), Set or clear a letsencrypt property for an app help_content diff --git a/plugins/letsencrypt/install b/plugins/letsencrypt/install index 37eac1813..7a87b7e8b 100755 --- a/plugins/letsencrypt/install +++ b/plugins/letsencrypt/install @@ -25,11 +25,18 @@ fn-letsencrypt-migrate-properties() { value=$(plugn trigger config-get-global "DOKKU_LETSENCRYPT_ARGS" || true) if [[ -n "$value" ]]; then - dokku_log_info1 "Migrating deprecated global DOKKU_LETSENCRYPT_ARGS to letsencrypt lego-docker-args property." - fn-plugin-property-write "letsencrypt" "--global" "lego-docker-args" "$value" + dokku_log_info1 "Migrating deprecated global DOKKU_LETSENCRYPT_ARGS to letsencrypt lego-args property." + fn-plugin-property-write "letsencrypt" "--global" "lego-args" "$value" DOKKU_QUIET_OUTPUT=1 config_unset --global DOKKU_LETSENCRYPT_ARGS || true fi + value="$(fn-plugin-property-get-default "letsencrypt" "--global" "lego-docker-args" "")" + if [[ -n "$value" ]]; then + dokku_log_info1 "Migrating deprecated global lego-docker-args property to lego-args." + fn-plugin-property-write "letsencrypt" "--global" "lego-args" "$value" + fn-plugin-property-delete "letsencrypt" "--global" "lego-docker-args" + fi + value=$(plugn trigger config-get-global "DOKKU_LETSENCRYPT_SERVER" || true) if [[ -n "$value" ]]; then dokku_log_info1 "Migrating deprecated global DOKKU_LETSENCRYPT_SERVER to letsencrypt server property." @@ -54,11 +61,18 @@ fn-letsencrypt-migrate-properties() { value="$(plugn trigger config-get "$app" DOKKU_LETSENCRYPT_ARGS || true)" if [[ -n "$value" ]]; then - dokku_log_info1 "Migrating deprecated DOKKU_LETSENCRYPT_ARGS to letsencrypt lego-docker-args property for $app." - fn-plugin-property-write "letsencrypt" "$app" "lego-docker-args" "$value" + dokku_log_info1 "Migrating deprecated DOKKU_LETSENCRYPT_ARGS to letsencrypt lego-args property for $app." + fn-plugin-property-write "letsencrypt" "$app" "lego-args" "$value" DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$app" "DOKKU_LETSENCRYPT_ARGS" || true fi + value="$(fn-plugin-property-get-default "letsencrypt" "$app" "lego-docker-args" "")" + if [[ -n "$value" ]]; then + dokku_log_info1 "Migrating deprecated lego-docker-args property to lego-args for $app." + fn-plugin-property-write "letsencrypt" "$app" "lego-args" "$value" + fn-plugin-property-delete "letsencrypt" "$app" "lego-docker-args" + fi + value="$(plugn trigger config-get "$app" DOKKU_LETSENCRYPT_SERVER || true)" if [[ -n "$value" ]]; then dokku_log_info1 "Migrating deprecated DOKKU_LETSENCRYPT_SERVER to letsencrypt server property for $app." @@ -84,6 +98,24 @@ plugin-install() { pull-docker-image "${PLUGIN_IMAGE}:${PLUGIN_IMAGE_VERSION}" mkdir -p "${DOKKU_LIB_ROOT}/data/letsencrypt" + chmod 0755 "${DOKKU_LIB_ROOT}/data/letsencrypt" + # Migrate the legacy autorenew marker into the --global container introduced + # to keep plugin-owned entries from colliding with same-named apps. Drop the + # legacy copy unconditionally once the new one exists. + mkdir -p "${DOKKU_LIB_ROOT}/data/letsencrypt/--global" + if [[ -f "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" && ! -e "${DOKKU_LIB_ROOT}/data/letsencrypt/--global/autorenew" ]]; then + touch "${DOKKU_LIB_ROOT}/data/letsencrypt/--global/autorenew" + fi + rm -f "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" + # Heal per-app ACME webroots whose mode was previously left at the invoker's + # umask (commonly 0700 under sudo). nginx must be able to read these to + # serve HTTP-01 challenges; the files are public by design. Skip the + # `--global` container, whose `accounts/` leaf is locked down to 0700 just + # below. + find "${DOKKU_LIB_ROOT}/data/letsencrypt" -mindepth 1 -maxdepth 1 -type d \ + ! -name '--global' -exec chmod 0755 {} + + mkdir -p "${DOKKU_LIB_ROOT}/data/letsencrypt/--global/accounts" + chmod 0700 "${DOKKU_LIB_ROOT}/data/letsencrypt/--global/accounts" chown -R "${DOKKU_SYSTEM_USER}:${DOKKU_SYSTEM_GROUP}" "${DOKKU_LIB_ROOT}/data/letsencrypt" fn-plugin-property-setup "letsencrypt" fn-letsencrypt-migrate-properties diff --git a/plugins/letsencrypt/internal-functions b/plugins/letsencrypt/internal-functions index a460f58d6..f4f06c10d 100755 --- a/plugins/letsencrypt/internal-functions +++ b/plugins/letsencrypt/internal-functions @@ -10,6 +10,83 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config" set -eo pipefail [[ $DOKKU_TRACE ]] && set -x +fn-letsencrypt-detect-rate-limit() { + declare desc="return 0 when the captured lego log shows an ACME rate-limit response" + declare LOG_PATH="$1" + + [[ -f "$LOG_PATH" ]] || return 1 + grep -qE 'urn:ietf:params:acme:error:rateLimited|\b429\b.*[Tt]oo [Mm]any [Rr]equests|too many certificates|too many failed authorizations' "$LOG_PATH" +} + +fn-letsencrypt-log-rate-limit() { + declare desc="emit a friendly warning when the ACME server has rate-limited the request" + declare APP="$1" + + dokku_log_warn "Let's Encrypt rate-limited this request." + dokku_log_warn "See https://letsencrypt.org/docs/rate-limits/ for the active limits and remediation guidance." + dokku_log_warn "Use 'dokku letsencrypt:set ${APP:-} server staging' while iterating to avoid the production limits." +} + +fn-letsencrypt-shared-accounts-dir() { + declare desc="dokku-host path to the shared lego accounts directory" + echo "$DOKKU_LIB_ROOT/data/letsencrypt/--global/accounts" +} + +fn-letsencrypt-shared-accounts-host-dir() { + declare desc="docker-host path to the shared lego accounts directory" + echo "$DOKKU_LIB_HOST_ROOT/data/letsencrypt/--global/accounts" +} + +fn-letsencrypt-ensure-shared-accounts-dir() { + declare desc="create the shared lego accounts directory with strict permissions" + local accounts_dir + accounts_dir="$(fn-letsencrypt-shared-accounts-dir)" + mkdir -p "$accounts_dir" + # account keys live here; restrict to the dokku user. + chmod 0700 "$accounts_dir" +} + +fn-letsencrypt-ensure-app-webroot-dir() { + declare desc="create the per-app ACME webroot with world-traversable perms" + declare APP="$1" + local webroot + webroot="$DOKKU_LIB_ROOT/data/letsencrypt/$APP" + mkdir -p "$webroot" + # nginx must traverse this directory to serve /.well-known/acme-challenge + # files. The files are public by design (ACME HTTP-01 fetches them over + # plain HTTP), and not every dokku install puts www-data in the dokku + # group, so pin 0755 instead of relying on the invoker's umask. + chmod 0755 "$webroot" +} + +fn-letsencrypt-account-server-dir() { + declare desc="lego-style directory name derived from the configured ACME server URL" + declare APP="$1" + local server host + + server="$(fn-letsencrypt-computed-server "$APP")" + host="${server#http://}" + host="${host#https://}" + host="${host%%/*}" + # lego stores accounts under a host segment with ':' replaced by '_' + host="${host//:/_}" + echo "$host" +} + +fn-letsencrypt-shared-account-exists() { + declare desc="return 0 when the shared accounts dir holds an account for this app's (server, email)" + declare APP="$1" + local server_dir email accounts_dir + + server_dir="$(fn-letsencrypt-account-server-dir "$APP")" + email="$(fn-letsencrypt-computed-email "$APP")" + if [[ -z "$server_dir" ]] || [[ -z "$email" ]]; then + return 1 + fi + accounts_dir="$(fn-letsencrypt-shared-accounts-dir)" + [[ -f "$accounts_dir/$server_dir/$email/account.json" ]] +} + fn-letsencrypt-acme-execute-challenge() { declare desc="perform actual ACME validation procedure" declare APP="$1" @@ -35,22 +112,35 @@ fn-letsencrypt-acme-execute-challenge() { host_config_dir="$(echo "$config_dirs" | cut -d: -f1)" container_config_dir="$(echo "$config_dirs" | cut -d: -f2)" read -r -a config <"$container_config_dir/config" + local docker_options_argv=() + if [[ -f "$container_config_dir/docker_options" ]]; then + read -r -a docker_options_argv <"$container_config_dir/docker_options" + fi # run letsencrypt as a docker container using "certonly" mode # port 80 of the standalone webserver will be forwarded by the proxy + local lego_log shared_accounts_lock + lego_log="$(mktemp)" set +e export DOKKU_UID=$(id -u) export DOKKU_GID=$(id -g) - mkdir -p "$DOKKU_LIB_ROOT/data/letsencrypt/$APP" - docker run --rm \ - --env-file "$host_config_dir/docker.env" \ - --user $DOKKU_UID:$DOKKU_GID \ - -v "$host_config_dir:/certs" \ - -v "$DOKKU_LIB_ROOT/data/letsencrypt/$APP:/webroot" \ - "${PLUGIN_IMAGE}:${PLUGIN_IMAGE_VERSION}" \ - "${config[@]}" run | sed "s/^/ /" + fn-letsencrypt-ensure-app-webroot-dir "$APP" + fn-letsencrypt-ensure-shared-accounts-dir + shared_accounts_lock="$(fn-letsencrypt-shared-accounts-dir)/.lock" + { + flock -x 9 + docker run --rm \ + --env-file "$container_config_dir/docker.env" \ + --user $DOKKU_UID:$DOKKU_GID \ + -v "$host_config_dir:/certs" \ + -v "$DOKKU_LIB_HOST_ROOT/data/letsencrypt/$APP:/webroot" \ + -v "$(fn-letsencrypt-shared-accounts-host-dir):/certs/accounts" \ + "${docker_options_argv[@]}" \ + "${PLUGIN_IMAGE}:${PLUGIN_IMAGE_VERSION}" \ + "${config[@]}" run 2>&1 + } 9>"$shared_accounts_lock" | tee "$lego_log" | sed "s/^/ /" - local exit_code=$? + local exit_code=${PIPESTATUS[0]} set -e if [[ "$FAKE_NGINX_CONF" == "true" ]]; then @@ -58,10 +148,16 @@ fn-letsencrypt-acme-execute-challenge() { fi if [[ $exit_code != 0 ]]; then + if fn-letsencrypt-detect-rate-limit "$lego_log"; then + fn-letsencrypt-log-rate-limit "$APP" + fi + rm -f "$lego_log" dokku_log_info1 "Certificate retrieval failed!" return $exit_code fi + rm -f "$lego_log" + # got certificate dokku_log_info1 "Certificate retrieved successfully." fn-letsencrypt-symlink-certs "$APP" "$container_config_dir" @@ -91,23 +187,45 @@ fn-letsencrypt-acme-revoke() { host_config_dir="$(echo "$config_dirs" | cut -d: -f1)" container_config_dir="$(echo "$config_dirs" | cut -d: -f2)" read -r -a config <"$container_config_dir/config" + local docker_options_argv=() + if [[ -f "$container_config_dir/docker_options" ]]; then + read -r -a docker_options_argv <"$container_config_dir/docker_options" + fi # run letsencrypt as a docker container using "certonly" mode # port 80 of the standalone webserver will be forwarded by the proxy + local lego_log shared_accounts_lock + lego_log="$(mktemp)" set +e export DOKKU_UID=$(id -u) export DOKKU_GID=$(id -g) - mkdir -p "$DOKKU_LIB_ROOT/data/letsencrypt/$APP" - docker run --rm \ - --env-file "$host_config_dir/docker.env" \ - --user $DOKKU_UID:$DOKKU_GID \ - -p "$acme_port:$acme_port" \ - -v "$host_config_dir:/certs" \ - -v "$DOKKU_LIB_ROOT/data/letsencrypt/$APP:/webroot" \ - "${PLUGIN_IMAGE}:${PLUGIN_IMAGE_VERSION}" \ - "${config[@]}" revoke | sed "s/^/ /" + fn-letsencrypt-ensure-app-webroot-dir "$APP" + fn-letsencrypt-ensure-shared-accounts-dir + shared_accounts_lock="$(fn-letsencrypt-shared-accounts-dir)/.lock" - local exit_code=$? + # Only overlay the shared accounts dir when it holds the matching + # (server, email) account; otherwise fall back to the per-app account + # so revocation of certs issued before the shared-account change still works. + local account_volume_args=() + if fn-letsencrypt-shared-account-exists "$APP"; then + account_volume_args=(-v "$(fn-letsencrypt-shared-accounts-host-dir):/certs/accounts") + fi + + { + flock -x 9 + docker run --rm \ + --env-file "$container_config_dir/docker.env" \ + --user $DOKKU_UID:$DOKKU_GID \ + -p "$acme_port:$acme_port" \ + -v "$host_config_dir:/certs" \ + -v "$DOKKU_LIB_HOST_ROOT/data/letsencrypt/$APP:/webroot" \ + "${account_volume_args[@]}" \ + "${docker_options_argv[@]}" \ + "${PLUGIN_IMAGE}:${PLUGIN_IMAGE_VERSION}" \ + "${config[@]}" revoke 2>&1 + } 9>"$shared_accounts_lock" | tee "$lego_log" | sed "s/^/ /" + + local exit_code=${PIPESTATUS[0]} set -e # handle return codes @@ -116,11 +234,17 @@ fn-letsencrypt-acme-revoke() { dokku_log_info1 "Certificate revoked successfully." else # error - don't try to link certificates - dokku_log_info1 "Certificate revocation failed (code $simple_result)!" + if fn-letsencrypt-detect-rate-limit "$lego_log"; then + fn-letsencrypt-log-rate-limit "$APP" + fi + rm -f "$lego_log" + dokku_log_info1 "Certificate revocation failed (code $exit_code)!" return fi - local domain="$(get_app_domains "$APP" | xargs | awk '{print $1}')" + rm -f "$lego_log" + + local domain="$(fn-letsencrypt-app-domains "$APP" | xargs | awk '{print $1}')" # removing the certificate will automatically reconfigure nginx if [[ -z $DOKKU_APP_NAME ]]; then @@ -165,14 +289,67 @@ fn-letsencrypt-acme-proxy-enable() { restart_nginx | sed "s/^/ /" } -fn-letsencrypt-computed-dns-provider() { - declare desc="get configured dns provider" +fn-letsencrypt-ensure-http-port() { + declare desc="ensure the app has an http:80 proxy mapping so nginx can serve the HTTP-01 challenge" declare APP="$1" + local port_map line container_port + + # HTTP-01 is the only challenge type that needs a listener on port 80; the + # DNS-01 flow never hits nginx. + if [[ -n "$(fn-letsencrypt-computed-dns-provider "$APP")" ]]; then + return 0 + fi + + port_map="$(plugn trigger ports-get "$APP" 2>/dev/null || true)" + + while IFS= read -r line; do + [[ "$line" == http:80:* ]] && return 0 + done <<<"$port_map" + + container_port="" + while IFS= read -r line; do + if [[ "$line" == http:*:* ]]; then + container_port="${line##*:}" + break + fi + done <<<"$port_map" + + if [[ -z "$container_port" ]]; then + while IFS= read -r line; do + if [[ "$line" == https:*:* ]]; then + container_port="${line##*:}" + break + fi + done <<<"$port_map" + fi + + if [[ -z "$container_port" ]]; then + dokku_log_warn "Cannot enable letsencrypt for $APP: no http or https proxy port mapping was found." + dokku_log_warn " letsencrypt needs an http listener on port 80 to serve the ACME HTTP-01 challenge." + dokku_log_warn " Add one with: dokku ports:add $APP http:80:" + return 1 + fi + + dokku_log_info1 "Adding http:80:${container_port} port mapping to ${APP} for ACME HTTP-01 challenge..." + dokku ports:add "$APP" "http:80:${container_port}" | sed "s/^/ /" +} + +fn-letsencrypt-computed-dns-provider() { + declare desc="get configured dns provider, with 'none' as an explicit opt-out sentinel" + declare APP="$1" + local value value="$(fn-letsencrypt-dns-provider "$APP")" + if [[ "$value" == "none" ]]; then + echo "" + return + fi if [[ -z "$value" ]]; then value="$(fn-letsencrypt-global-dns-provider)" fi + if [[ "$value" == "none" ]]; then + value="" + fi echo "$value" } @@ -190,29 +367,60 @@ fn-letsencrypt-dns-provider() { fn-plugin-property-get-default "letsencrypt" "$APP" "dns-provider" "" } -fn-letsencrypt-computed-lego-docker-args() { - declare desc="get configured lego docker args" +fn-letsencrypt-computed-lego-args() { + declare desc="get configured lego cli args" declare APP="$1" - value="$(fn-letsencrypt-lego-docker-args "$APP")" + value="$(fn-letsencrypt-lego-args "$APP")" if [[ -z "$value" ]]; then - value="$(fn-letsencrypt-global-lego-docker-args)" + value="$(fn-letsencrypt-global-lego-args)" fi echo "$value" } -fn-letsencrypt-global-lego-docker-args() { - declare desc="get configured lego docker args" +fn-letsencrypt-global-lego-args() { + declare desc="get configured lego cli args" - fn-plugin-property-get-default "letsencrypt" "--global" "lego-docker-args" "" + fn-plugin-property-get-default "letsencrypt" "--global" "lego-args" "" } -fn-letsencrypt-lego-docker-args() { - declare desc="get configured lego docker args" +fn-letsencrypt-lego-args() { + declare desc="get configured lego cli args" declare APP="$1" - fn-plugin-property-get-default "letsencrypt" "$APP" "lego-docker-args" "" + fn-plugin-property-get-default "letsencrypt" "$APP" "lego-args" "" +} + +fn-letsencrypt-computed-lego-docker-options() { + declare desc="get configured docker run options for the lego container" + declare APP="$1" + + value="$(fn-letsencrypt-lego-docker-options "$APP")" + if [[ -z "$value" ]]; then + value="$(fn-letsencrypt-global-lego-docker-options)" + fi + + echo "$value" +} + +fn-letsencrypt-global-lego-docker-options() { + declare desc="get configured docker run options for the lego container" + + fn-plugin-property-get-default "letsencrypt" "--global" "lego-docker-options" "" +} + +fn-letsencrypt-lego-docker-options() { + declare desc="get configured docker run options for the lego container" + declare APP="$1" + + fn-plugin-property-get-default "letsencrypt" "$APP" "lego-docker-options" "" +} + +fn-letsencrypt-app-domains() { + declare desc="list app domains, skipping the '_' nginx default-vhost sentinel" + declare APP="$1" + get_app_domains "$APP" | grep -vxF '_' || true } fn-letsencrypt-check-email() { @@ -228,33 +436,22 @@ fn-letsencrypt-check-email() { fi } -fn-letsencrypt-configure-and-get-dir() { - declare desc="assemble lego command line arguments and create a config hash directory for them" +fn-letsencrypt-config-string() { + declare desc="assemble the lego command-line config string (no side effects)" declare APP="$1" - local config config_dir config_hash dns_provider domain_args domains email extra_args cert_timeout key server value + local cert_timeout config dns_provider domain domain_args domains email extra_args server - local app_root="$DOKKU_ROOT/$APP" - local le_root="$app_root/letsencrypt" - mkdir -p "$DOKKU_ROOT/$APP/letsencrypt/account" - - # build up a string of all certificate-controlling configuration settings. - # this will be used to determine the folder name for the account key and certificates - - # get the selected ACME server server="$(fn-letsencrypt-computed-server "$APP")" - # construct domain arguments - domains="$(get_app_domains "$APP")" + domains="$(fn-letsencrypt-app-domains "$APP")" domain_args='' for domain in $domains; do - dokku_log_verbose " - Domain '$domain'" >&2 domain_args="$domain_args --domains $domain" done - # lego --cert.timeout is in seconds and defaults to 30 cert_timeout=30 email="$(fn-letsencrypt-computed-email "$APP")" - extra_args="$(fn-letsencrypt-computed-lego-docker-args "$APP")" + extra_args="$(fn-letsencrypt-computed-lego-args "$APP")" config="--pem --accept-tos --cert.timeout $cert_timeout --path /certs --server $server --email $email $extra_args $domain_args" dns_provider="$(fn-letsencrypt-computed-dns-provider "$APP")" @@ -264,7 +461,36 @@ fn-letsencrypt-configure-and-get-dir() { config="--http --http.webroot /webroot $config" fi - config_hash=$(echo "$config" | sha1sum | awk '{print $1}') + echo "$config" +} + +fn-letsencrypt-config-hash() { + declare desc="compute the sha1 hash that names an app's lego config directory" + declare APP="$1" + local config docker_options_value + + config="$(fn-letsencrypt-config-string "$APP")" + docker_options_value="$(fn-letsencrypt-computed-lego-docker-options "$APP")" + echo "$config $docker_options_value" | sha1sum | awk '{print $1}' +} + +fn-letsencrypt-configure-and-get-dir() { + declare desc="assemble lego command line arguments and create a config hash directory for them" + declare APP="$1" + local config config_dir config_hash dns_provider docker_options_value domain key value + + local app_root="$DOKKU_ROOT/$APP" + local le_root="$app_root/letsencrypt" + + for domain in $(fn-letsencrypt-app-domains "$APP"); do + dokku_log_verbose " - Domain '$domain'" >&2 + done + + config="$(fn-letsencrypt-config-string "$APP")" + docker_options_value="$(fn-letsencrypt-computed-lego-docker-options "$APP")" + dns_provider="$(fn-letsencrypt-computed-dns-provider "$APP")" + + config_hash="$(fn-letsencrypt-config-hash "$APP")" config_dir="$le_root/certs/$config_hash" mkdir -p "$config_dir" @@ -275,7 +501,7 @@ fn-letsencrypt-configure-and-get-dir() { [[ -n "$line" ]] || continue key="$(cut -d" " -f1 <<<"$line")" if [[ "$key" == dns-provider-* ]]; then - value="$(cut -d" " -f2 <<<"$line")" + value="$(cut -d" " -f2- <<<"$line")" echo "${key#"dns-provider-"}=$value" >>"$config_dir/docker.env" fi done @@ -283,7 +509,7 @@ fn-letsencrypt-configure-and-get-dir() { [[ -n "$line" ]] || continue key="$(cut -d" " -f1 <<<"$line")" if [[ "$key" == dns-provider-* ]]; then - value="$(cut -d" " -f2 <<<"$line")" + value="$(cut -d" " -f2- <<<"$line")" echo "${key#"dns-provider-"}=$value" >>"$config_dir/docker.env" fi done @@ -294,6 +520,7 @@ fn-letsencrypt-configure-and-get-dir() { # store config settings echo "$config" >"$config_dir/config" + echo "$docker_options_value" >"$config_dir/docker_options" # send both host and container path # to respect mapped DOKKU_ROOT when running in a container @@ -311,7 +538,8 @@ fn-letsencrypt-cron-job-enabled() { fn-letsencrypt-cron-job-add() { declare desc="Add auto-renew cronjob to dokku user's crontab" - touch "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" + mkdir -p "${DOKKU_LIB_ROOT}/data/letsencrypt/--global" + touch "${DOKKU_LIB_ROOT}/data/letsencrypt/--global/autorenew" if fn-letsencrypt-cron-job-enabled; then plugn trigger cron-write else @@ -326,7 +554,7 @@ fn-letsencrypt-cron-job-add() { fn-letsencrypt-cron-job-remove() { declare desc="Remove auto-renew cronjob from dokku user's crontab" - rm -f "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" + rm -f "${DOKKU_LIB_ROOT}/data/letsencrypt/--global/autorenew" if fn-letsencrypt-cron-job-enabled; then plugn trigger cron-write else @@ -363,7 +591,7 @@ fn-letsencrypt-create-root() { fi sigil -f "$PLUGIN_AVAILABLE_PATH/letsencrypt/templates/default-nginx.conf.sigil" \ - DOMAINS="$(get_app_domains "$APP" | xargs)" DOKKU_ROOT="$DOKKU_ROOT" APP="$APP" \ + DOMAINS="$(fn-letsencrypt-app-domains "$APP" | xargs)" DOKKU_ROOT="$DOKKU_ROOT" APP="$APP" \ NGINX_ACCESS_LOG_FORMAT="$NGINX_ACCESS_LOG_FORMAT" NGINX_ACCESS_LOG_PATH="$NGINX_ACCESS_LOG_PATH" \ NGINX_ERROR_LOG_PATH="$NGINX_ERROR_LOG_PATH" \ >"$DOKKU_ROOT/$APP/nginx.conf" @@ -375,7 +603,7 @@ fn-letsencrypt-is-autorenew-enabled() { declare desc="check if autorenew is enabled" local enabled=false - if [[ -f "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" ]]; then + if [[ -f "${DOKKU_LIB_ROOT}/data/letsencrypt/--global/autorenew" ]]; then enabled=true fi @@ -408,13 +636,13 @@ fn-letsencrypt-email() { } fn-letsencrypt-enable() { - declare APP="$1" + declare APP="$1" FORCE="${2:-false}" local EXIT_CODE=0 local domain verify_app_name "$APP" - domain="$(get_app_domains "$APP" | xargs | awk '{print $1}')" + domain="$(fn-letsencrypt-app-domains "$APP" | xargs | awk '{print $1}')" if [[ -z "$domain" ]]; then dokku_log_warn "No domains detected for $APP" return 1 @@ -423,6 +651,14 @@ fn-letsencrypt-enable() { dokku_log_info2 "Enabling letsencrypt for $APP" fn-letsencrypt-check-email "$APP" + + if [[ "$FORCE" != "true" ]] && ! fn-letsencrypt-needs-issue "$APP"; then + dokku_log_info1 "Certificate is still valid for $APP, skipping certificate request" + dokku_log_info1 "Pass --force to letsencrypt:enable to request a new certificate anyway" + return 0 + fi + + fn-letsencrypt-ensure-http-port "$APP" fn-letsencrypt-acme-proxy-enable "$APP" fn-letsencrypt-acme-execute-challenge "$APP" || EXIT_CODE=$? # remove ACME proxy even if this fails fn-letsencrypt-acme-proxy-disable "$APP" @@ -436,13 +672,47 @@ fn-letsencrypt-enable() { DOKKU_FAIL_EXIT_CODE="$EXIT_CODE" dokku_log_fail "Check log output for further information on failure" } +fn-letsencrypt-needs-issue() { + declare desc="return 0 when the app needs a fresh certificate, 1 when the current one is still valid" + declare APP="$1" + local current_link configured_hash current_hash expiry grace_period time_to_renewal + + if [[ "$(fn-letsencrypt-is-active "$APP")" != "true" ]]; then + return 0 + fi + + current_link="$DOKKU_ROOT/$APP/letsencrypt/certs/current" + if [[ ! -L "$current_link" ]]; then + return 0 + fi + + configured_hash="$(fn-letsencrypt-config-hash "$APP")" + current_hash="$(basename "$(readlink "$current_link")")" + if [[ "$current_hash" != "$configured_hash" ]]; then + return 0 + fi + + expiry="$(fn-letsencrypt-expiration "$APP")" + if [[ -z "$expiry" ]]; then + return 0 + fi + + grace_period="$(fn-letsencrypt-computed-graceperiod "$APP")" + time_to_renewal=$((expiry - grace_period - $(date +%s))) + if [[ $time_to_renewal -lt 0 ]]; then + return 0 + fi + + return 1 +} + fn-letsencrypt-expiration() { declare desc="prints expiration time" declare APP="$1" if [[ -f "$DOKKU_ROOT/$APP/tls/server.letsencrypt.crt" ]]; then date -u -d "$(openssl x509 -in "$DOKKU_ROOT/$APP/tls/server.letsencrypt.crt" -enddate -noout | sed -e "s/^notAfter=//")" "+%s" - else + elif [[ -f "$DOKKU_ROOT/$APP/tls/server.crt" ]]; then date -u -d "$(openssl x509 -in "$DOKKU_ROOT/$APP/tls/server.crt" -enddate -noout | sed -e "s/^notAfter=//")" "+%s" fi } @@ -534,7 +804,7 @@ fn-letsencrypt-is-active() { return fi - domain="$(get_app_domains "$APP" | xargs | awk '{print $1}')" + domain="$(fn-letsencrypt-app-domains "$APP" | xargs | awk '{print $1}')" # check if certificate is identical to the current let's encrypt certificate by comparing SHA1 hashes local cert_sha1 @@ -563,6 +833,7 @@ fn-letsencrypt-is-active() { fn-letsencrypt-list-apps-with-expiry() { declare desc="list all letsencrypt-secured apps together with their expiry date" + declare QUIET="${1:-}" # prints a tab-separated list of # * app name @@ -571,7 +842,20 @@ fn-letsencrypt-list-apps-with-expiry() { # * time left on certificate (in seconds) # * time until renewal (in seconds) - for APP in $(dokku_apps); do + # `dokku_apps` warns on stderr when the host has no apps. That is worth + # surfacing interactively, but not from the daily auto-renew cron job, where + # an app-less host is an unremarkable state rather than something to report. + # It reports the empty host by calling `dokku_log_fail`, which exits rather + # than returns, so the `|| true` has to sit outside the command substitution + # to keep `set -e` from aborting here. + local INSTALLED_APPS="" + if [[ "$QUIET" == "--quiet" ]]; then + INSTALLED_APPS="$(dokku_apps 2>/dev/null)" || true + else + INSTALLED_APPS="$(dokku_apps)" || true + fi + + for APP in $INSTALLED_APPS; do if [[ "$APP" == "=====>" ]] || [[ "$APP" == "My" ]] || [[ "$APP" == "Apps" ]]; then continue; fi if [[ "$(fn-letsencrypt-is-active "$APP")" == "true" ]]; then local expiry=$(fn-letsencrypt-expiration "$APP") @@ -629,7 +913,7 @@ fn-letsencrypt-symlink-certs() { # install the let's encrypt certificate for the app unset DOKKU_APP_NAME - domain="$(get_app_domains "$APP" | xargs | awk '{print $1}')" + domain="$(fn-letsencrypt-app-domains "$APP" | xargs | awk '{print $1}')" local fileSafeDomain fileSafeDomain="${domain/\*/_}" # wildcards are using *.example.com which have certificates named _.example.com dokku certs:add "$APP" "$config_dir/certificates/$fileSafeDomain.pem" "$config_dir/certificates/$fileSafeDomain.key" @@ -637,3 +921,126 @@ fn-letsencrypt-symlink-certs() { cp "$config_dir/certificates/$fileSafeDomain.crt" "$app_root/tls/server.letsencrypt.crt" cp "$config_dir/certificates/$fileSafeDomain.crt" "$app_root/tls/server.crt" } + +fn-letsencrypt-report-parse-args() { + declare desc="strip --format and --global from a report invocation" + REPORT_FORMAT="stdout" + REPORT_IS_GLOBAL="false" + REPORT_ARGS=() + while [[ $# -gt 0 ]]; do + case "$1" in + --format) + REPORT_FORMAT="$2" + shift 2 + ;; + --global) + REPORT_IS_GLOBAL="true" + shift + ;; + *) + REPORT_ARGS+=("$1") + shift + ;; + esac + done +} + +fn-letsencrypt-report-validate-format() { + declare desc="reject --format when combined with an info flag" + declare FORMAT="$1" INFO_FLAG="$2" + if [[ "$FORMAT" != "stdout" ]] && [[ -n "$INFO_FLAG" ]]; then + dokku_log_fail "--format flag cannot be specified when specifying an info flag" + fi +} + +fn-letsencrypt-report-filter-global() { + declare desc="rewrite flag_map keeping only --letsencrypt-global-* entries" + declare flag_map_name="$1" + local -n flag_map_ref="$flag_map_name" + local filtered=() + local flag key + for flag in "${flag_map_ref[@]}"; do + key="$(echo "$flag" | cut -d':' -f1)" + if [[ "$key" == *"-global-"* ]]; then + filtered+=("$flag") + fi + done + flag_map_ref=("${filtered[@]}") +} + +fn-letsencrypt-report-emit-json() { + declare desc="emit a flag_map as a JSON object, stripping the --letsencrypt- key prefix" + declare flag_map_name="$1" + local -n flag_map_ref="$flag_map_name" + local json_output="{}" + local flag key value + for flag in "${flag_map_ref[@]}"; do + key="$(echo "$flag" | cut -d':' -f1 | sed 's/^--letsencrypt-//')" + value="$(echo "$flag" | cut -d':' -f2- | sed 's/^ //')" + json_output="$(echo "$json_output" | jq -c --arg key "$key" --arg value "$value" '. + {($key): $value}')" + done + echo "$json_output" +} + +fn-letsencrypt-report-add-dns-provider-keys() { + declare desc="append per-key dns-provider-* rows to flag_map (computed/global/app), redacting values when LETSENCRYPT_REPORT_SANITIZE=true" + declare flag_map_name="$1" APP="$2" + local -n flag_map_ref="$flag_map_name" + + local sanitize="${LETSENCRYPT_REPORT_SANITIZE:-false}" + local line key value + declare -A global_values=() + declare -A app_values=() + declare -A keyset=() + + while IFS= read -r line; do + [[ -n "$line" ]] || continue + key="$(cut -d" " -f1 <<<"$line")" + if [[ "$key" == dns-provider-* ]]; then + value="$(cut -d" " -f2- <<<"$line")" + global_values["$key"]="$value" + keyset["$key"]=1 + fi + done < <(fn-plugin-property-get-all "letsencrypt" "--global") + + if [[ "$APP" != "--global" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] || continue + key="$(cut -d" " -f1 <<<"$line")" + if [[ "$key" == dns-provider-* ]]; then + value="$(cut -d" " -f2- <<<"$line")" + app_values["$key"]="$value" + keyset["$key"]=1 + fi + done < <(fn-plugin-property-get-all "letsencrypt" "$APP") + fi + + [[ "${#keyset[@]}" -gt 0 ]] || return 0 + + local sorted_key app_value global_value computed_value + while IFS= read -r sorted_key; do + app_value="${app_values[$sorted_key]:-}" + global_value="${global_values[$sorted_key]:-}" + if [[ -n "$app_value" ]]; then + computed_value="$app_value" + else + computed_value="$global_value" + fi + + if [[ "$sanitize" == "true" ]]; then + [[ -n "$app_value" ]] && app_value="****" + [[ -n "$global_value" ]] && global_value="****" + [[ -n "$computed_value" ]] && computed_value="****" + fi + + if [[ -n "$computed_value" ]]; then + flag_map_ref+=("--letsencrypt-computed-${sorted_key}: ${computed_value}") + fi + if [[ -n "$global_value" ]]; then + flag_map_ref+=("--letsencrypt-global-${sorted_key}: ${global_value}") + fi + if [[ -n "$app_value" ]]; then + flag_map_ref+=("--letsencrypt-${sorted_key}: ${app_value}") + fi + done < <(printf '%s\n' "${!keyset[@]}" | sort) +} diff --git a/plugins/letsencrypt/plugin.toml b/plugins/letsencrypt/plugin.toml index 81a7af803..3bd839839 100644 --- a/plugins/letsencrypt/plugin.toml +++ b/plugins/letsencrypt/plugin.toml @@ -1,5 +1,5 @@ [plugin] description = "Automated installation of let's encrypt TLS certificates" -version = "0.20.4" +version = "0.25.2" sponsors = ["orca-scan"] [plugin.config] diff --git a/plugins/letsencrypt/report b/plugins/letsencrypt/report index 8bbe072b4..82149bd0a 100755 --- a/plugins/letsencrypt/report +++ b/plugins/letsencrypt/report @@ -3,4 +3,4 @@ source "$PLUGIN_AVAILABLE_PATH/letsencrypt/command-functions" set -eo pipefail [[ $DOKKU_TRACE ]] && set -x -cmd-letsencrypt-report-single "$@" +LETSENCRYPT_REPORT_SANITIZE=true cmd-letsencrypt-report-single "$@"