├── .formatter.exs ├── .github └── workflows │ └── site_encrypt.yaml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── demos └── phoenix │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── config │ └── config.exs │ ├── lib │ └── phoenix_demo │ │ ├── application.ex │ │ └── endpoint.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ ├── endpoint_test.exs │ └── test_helper.exs ├── dialyzer.ignore ├── lib ├── site_encrypt.ex └── site_encrypt │ ├── acme │ ├── client.ex │ ├── client │ │ ├── api.ex │ │ └── crypto.ex │ ├── server.ex │ └── server │ │ ├── account.ex │ │ ├── challenge.ex │ │ ├── crypto.ex │ │ ├── db.ex │ │ ├── jws.ex │ │ ├── nonce.ex │ │ └── plug.ex │ ├── acme_challenge.ex │ ├── adapter.ex │ ├── application.ex │ ├── certification.ex │ ├── certification │ ├── certbot.ex │ ├── job.ex │ ├── native.ex │ └── periodic.ex │ ├── http_client.ex │ ├── phoenix │ ├── endpoint.ex │ └── test.ex │ └── registry.ex ├── mix.exs ├── mix.lock └── test ├── pebble_test.exs ├── site_encrypt └── certification │ └── periodic_test.exs ├── site_encrypt_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:phoenix, :plug, :stream_data] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/workflows/site_encrypt.yaml: -------------------------------------------------------------------------------- 1 | name: "site_encrypt" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | CACHE_VERSION: v2 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: erlef/setup-elixir@v1 16 | with: 17 | otp-version: 27.0 18 | elixir-version: 1.17.1 19 | 20 | - name: Restore cached deps 21 | uses: actions/cache@v1 22 | with: 23 | path: deps 24 | key: deps-${{ env.CACHE_VERSION }}-${{ github.ref }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 25 | restore-keys: | 26 | deps-${{ env.CACHE_VERSION }}-${{ github.ref }}- 27 | deps-${{ env.CACHE_VERSION }}- 28 | 29 | - name: Restore cached build 30 | uses: actions/cache@v1 31 | with: 32 | path: _build 33 | key: build-${{ env.CACHE_VERSION }}-${{ github.ref }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 34 | restore-keys: | 35 | build-${{ env.CACHE_VERSION }}-${{ github.ref }}- 36 | build-${{ env.CACHE_VERSION }}- 37 | 38 | - run: | 39 | sudo apt-get install software-properties-common 40 | sudo apt-get update 41 | sudo apt-get install -y certbot 42 | 43 | - run: docker run -d -e "PEBBLE_VA_NOSLEEP=1" --net=host letsencrypt/pebble:v2.1.0 /usr/bin/pebble -strict 44 | 45 | - run: mix deps.get 46 | 47 | - name: Compile project 48 | run: | 49 | MIX_ENV=test mix compile --warnings-as-errors 50 | MIX_ENV=dev mix compile --warnings-as-errors 51 | MIX_ENV=prod mix compile --warnings-as-error 52 | 53 | - run: mix format --check-formatted 54 | - run: mix test 55 | - run: mix dialyzer 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | site_encrypt-*.tar 24 | 25 | # Elixir Language Server output 26 | .elixir_ls 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.0 2 | elixir 1.17.1-otp-27 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | - drop support for Elixir < v1.16 6 | - [allow](https://github.com/sasa1977/site_encrypt/pull/63) all `Logger.levels()` in `:log_level` option 7 | 8 | ## 0.6.0 9 | 10 | **Breaking**: changed the endpoint setup. Previously the client code had to configure https via the `Phoenix.Endpoint.init/2` callback. However, this callback is deprecated in the latest Phoenix, which now favours passing endpoint options via an argument to `start_link/1` (or `child_spec/1`). This style was previously not supported by site_encrypt. 11 | 12 | So to make all of this work, the setup flow has been changed and simplified. To upgrade from the previous version you need to do the following: 13 | 14 | 1. Remove `use SiteEncrypt.Phoenix` from the endpoint module. 15 | 1. Replace `use Phoenix.Endpoint` with `use SiteEncrypt.Phoenix.Endpoint`. Keep the `:otp_app` option. 16 | 1. Remove invocation of `SiteEncrypt.Phoenix.configure_https/1` from your endpoint's `init/1`. 17 | 1. In the parent supervisor children list, replace the child `{SiteEncrypt.Phoenix, MyEndpoint}` with `MyEndpoint`. 18 | 19 | Note that `init/1` callback is deprecated. To specify endpoint config at runtime, you can use the spec `{MyEndpoint, endpoint_config}`. Alternatively, you can override the `child_spec/1` function in the endpoint module: 20 | 21 | ```elixir 22 | # in your endpoint module 23 | 24 | defoverridable child_spec: 1 25 | 26 | def child_spec(_arg) do 27 | endpoint_config = [ 28 | http: [...], 29 | https: [...], 30 | ... 31 | ] 32 | 33 | super(endpoint_config) 34 | end 35 | ``` 36 | 37 | ## 0.5.1 38 | 39 | - Support bandit 1.x 40 | 41 | ## 0.5.0 42 | 43 | - added `SiteEncrypt.refresh_config/1` 44 | - added the support for bandit web server 45 | 46 | ## 0.4.2 47 | 48 | - correctly handle relative paths 49 | 50 | ## 0.4.1 51 | 52 | - use dialyxir only on dev 53 | 54 | ## 0.4.0 55 | 56 | This version upgrades to the Parent 0.11 and changes the internals. Strictly speaking this version doesn't change anything, so it could have been a patch update. However, moving to Parent 0.11 might introduce breaking changes in the client code, so the major version is bumped. 57 | 58 | ## 0.3.1 59 | 60 | - Fixes invalid dependency requirement. 61 | 62 | ## 0.3.0 63 | 64 | ### Additions and non-breaking changes 65 | 66 | - Exposed lower-level ACME client API functions through `SiteEncrypt.Acme.Client` and `SiteEncrypt.Acme.Client.API`. 67 | - Native client keeps the history of old keys. 68 | - Key size is configurable, with the default of 4096. 69 | - Added support for manual production testing through `SiteEncrypt.dry_certify/2`. See "Testing in production" section in readme for details. 70 | - Renewal happens at a random time of day to avoid possible spikes on CA. 71 | 72 | ### Breaking changes 73 | 74 | - The internal folders structure has been changed. If you're running a site_encrypt system in production and using the certbot client, you need to create the folder `acme-v02.api.letsencrypt.org` (assuming you're using Let's Encrypt production) under `db_folder/certbot`, and then recursively copy the contents of `db_folder/certbot` into the new folder. If you're using the native client, you don't need to do anything. 75 | 76 | ## 0.2.0 77 | 78 | ### Breaking changes 79 | 80 | - The interface for writing tests has been changed. A certification test should now be written as 81 | 82 | ```elixir 83 | defmodule MyEndpoint.CertificationTest do 84 | use ExUnit.Case, async: false 85 | import SiteEncrypt.Phoenix.Test 86 | 87 | test "certification" do 88 | clean_restart(MyEndpoint) 89 | cert = get_cert(MyEndpoint) 90 | assert cert.domains == ~w/mysite.com www.mysite.com/ 91 | end 92 | end 93 | ``` 94 | 95 | ## 0.1.0 96 | 97 | - added a basic native ACME client 98 | - simplified interface 99 | - improved tests 100 | - expanded docs 101 | 102 | This version introduces many breaking changes. If you've been using a pre 0.1 version, here's how to upgrade your project: 103 | 104 | 1. In your endpoint, replace `@behaviour SiteEncrypt` with `use SiteEncrypt.Phoenix` 105 | 2. Also in the endpoint, change the `certification/0` callback to pass the options to `SiteEncrypt.configure/1` instead of just returning them. 106 | 3. Changes in options: 107 | - `:mode` is no longer supported. Manual mode will be automatically set in tests. 108 | - use `:domains` instead of `:domain` and `:extra_domain` 109 | - `:ca_url` has been renamed to `directory_url` 110 | - `:email` has been renamed to `emails` and must be a list 111 | - `:base_folder` has been renamed to `:db_folder` 112 | - `:cert_folder` is no longer supported. It will chosen automatically inside the `:db_folder` 113 | 4. The internal folders structure has been changed. If you're running a site_encrypt system in production, you need to create the folder called `certbot` inside the `:db_folder`, and recurisvely copy top-level folders under `:db_folder` into the newly created `certbot` folder. 114 | 5. If you have been using `SiteEncrypt.Phoenix.Test.verify_certification` for certification testing, drop that test, and add the following module somewhere in your test suite: 115 | ```elixir 116 | defmodule CertificationTest do 117 | use SiteEncrypt.Phoenix.Test, endpoint: MyEndpoint 118 | end 119 | ``` 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018, Saša Jurić 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SiteEncrypt 2 | 3 | [![hex.pm](https://img.shields.io/hexpm/v/parent.svg?style=flat-square)](https://hex.pm/packages/site_encrypt) 4 | [![hexdocs.pm](https://img.shields.io/badge/docs-latest-green.svg?style=flat-square)](https://hexdocs.pm/site_encrypt/) 5 | ![Build Status](https://github.com/sasa1977/site_encrypt/workflows/site_encrypt/badge.svg) 6 | 7 | This project aims to provide integrated certification via [Let's encrypt](https://letsencrypt.org/) for sites implemented in Elixir. 8 | 9 | Integrated certification means that you don't need to run any other OS process in background. Start your site for the first time, and the system will obtain the certificate, and periodically renew it before it expires. 10 | 11 | The target projects are small-to-medium Elixir based sites which don't sit behind reverse proxies such as nginx. 12 | 13 | ## Status 14 | 15 | - The library is tested in a [simple production](https://www.theerlangelist.com), where it has been constantly running since mid 2018. 16 | - Native Elixir client is very new, and not considered stable. If you prefer reliable behaviour, use the Certbot client. This will require installing [Certbot](https://certbot.eff.org/) >= 0.31 17 | - The API is not stable. Expect breaking changes in the future. 18 | 19 | ## Quick start 20 | 21 | A basic demo Phoenix project is available [here](https://github.com/sasa1977/site_encrypt/tree/master/demos/phoenix). 22 | 23 | 1. Add the dependency to `mix.exs`: 24 | 25 | ```elixir 26 | defmodule PhoenixDemo.Mixfile do 27 | # ... 28 | 29 | defp deps do 30 | [ 31 | # ... 32 | {:site_encrypt, "~> 0.6"} 33 | ] 34 | end 35 | end 36 | ``` 37 | 38 | Don't forget to invoke `mix.deps` after that. 39 | 40 | 1. Expand your endpoint 41 | 42 | ```elixir 43 | defmodule PhoenixDemo.Endpoint do 44 | # ... 45 | 46 | # add this instead of `use Phoenix.Endpoint` 47 | use SiteEncrypt.Phoenix.Endpoint, otp_app: :my_app 48 | 49 | # ... 50 | 51 | @impl SiteEncrypt 52 | def certification do 53 | SiteEncrypt.configure( 54 | # Note that native client is very immature. If you want a more stable behaviour, you can 55 | # provide `:certbot` instead. Note that in this case certbot needs to be installed on the 56 | # host machine. 57 | client: :native, 58 | 59 | domains: ["mysite.com", "www.mysite.com"], 60 | emails: ["contact@abc.org", "another_contact@abc.org"], 61 | 62 | # By default the certs will be stored in tmp/site_encrypt_db, which is convenient for 63 | # local development. Make sure that tmp folder is gitignored. 64 | # 65 | # Set OS env var SITE_ENCRYPT_DB on staging/production hosts to some absolute path 66 | # outside of the deployment folder. Otherwise, the deploy may delete the db_folder, 67 | # which will effectively remove the generated key and certificate files. 68 | db_folder: 69 | System.get_env("SITE_ENCRYPT_DB", Path.join("tmp", "site_encrypt_db")), 70 | 71 | # set OS env var CERT_MODE to "staging" or "production" on staging/production hosts 72 | directory_url: 73 | case System.get_env("CERT_MODE", "local") do 74 | "local" -> {:internal, port: 4002} 75 | "staging" -> "https://acme-staging-v02.api.letsencrypt.org/directory" 76 | "production" -> "https://acme-v02.api.letsencrypt.org/directory" 77 | end 78 | ) 79 | end 80 | 81 | # ... 82 | end 83 | ``` 84 | 85 | 1. Start the endpoint: 86 | 87 | ```elixir 88 | defmodule PhoenixDemo.Application do 89 | use Application 90 | 91 | def start(_type, _args) do 92 | children = [PhoenixDemo.Endpoint] 93 | opts = [strategy: :one_for_one, name: PhoenixDemo.Supervisor] 94 | Supervisor.start_link(children, opts) 95 | end 96 | 97 | # ... 98 | end 99 | ``` 100 | 101 | 1. Optionally add a certification test 102 | 103 | ```elixir 104 | defmodule PhoenixDemo.Endpoint.CertificationTest do 105 | use ExUnit.Case, async: false 106 | import SiteEncrypt.Phoenix.Test 107 | 108 | test "certification" do 109 | clean_restart(PhoenixDemo.Endpoint) 110 | cert = get_cert(PhoenixDemo.Endpoint) 111 | assert cert.domains == ~w/mysite.com www.mysite.com/ 112 | end 113 | end 114 | ``` 115 | 116 | And that's it! At this point you can start the system: 117 | 118 | ```text 119 | $ iex -S mix phx.server 120 | 121 | [info] Generating a temporary self-signed certificate. This certificate will be used until a proper certificate is issued by the CA server. 122 | [info] Running PhoenixDemo.Endpoint with cowboy 2.7.0 at 0.0.0.0:4000 (http) 123 | [info] Running PhoenixDemo.Endpoint with cowboy 2.7.0 at 0.0.0.0:4001 (https) 124 | [info] Running local ACME server at port 4002 125 | [info] Ordering a new certificate for domain mysite.com 126 | [info] New certificate for domain mysite.com obtained 127 | [info] Certificate successfully obtained! 128 | ``` 129 | 130 | And visit your certified site at https://localhost:4001 131 | 132 | ## Testing in production 133 | 134 | In general, the configuration above should work out of the box in production, as long as the domain is correctly setup, and ports properly forwarded, so the HTTP site is externally available at port 80. 135 | 136 | If you want a more manual first deploy test, here's how you can do it: 137 | 138 | 1. Explicitly set `mode: :manual` in `certification/0`. This means that the site won't automatically certify itself. However, during the first boot it will generate a self-signed certificate. 139 | 140 | 2. Deploy the site and verify that it's externally reachable via HTTP on port 80. 141 | 142 | 3. Start a remote `iex` shell session to the running system. 143 | 144 | 4. Perform a trial certification through the staging Let's Encrypt CA: 145 | 146 | ```elixir 147 | iex> SiteEncrypt.dry_certify( 148 | MySystemWeb.Endpoint, 149 | directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory" 150 | ) 151 | ``` 152 | 153 | Keep in mind that this can be only invoked in the remote `iex` shell session inside the running system. 154 | 155 | If the certification succeeds, the function will return the key and the certificate. These files won't be stored on disk, and they won't be used by the endpoint. 156 | 157 | 5. If the trial certification succeeded, you can proceed to start the real certification as follows: 158 | 159 | ```elixir 160 | iex> SiteEncrypt.force_certify(MySystemWeb.Endpoint) 161 | ``` 162 | 163 | Unlike the trial certification, this function will go to the CA as configured by the `certification/0` callback in the endpoint. The key and the certificate files will be stored on the disk, and the site will immediately used them. Therefore, if this function succeeds, you can visit your site via HTTPS. 164 | 165 | 6. If all went well, remove the `:mode` setting from the `certification/0` callback and redeploy your system. 166 | 167 | __Note__: be careful not to invoke these functions too frequently, because you might trip some rate limit on Let's Encrypt. See [here](https://letsencrypt.org/docs/rate-limits/) for more details. 168 | 169 | 170 | ## License 171 | 172 | [MIT](./LICENSE) 173 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix, json_library: Jason 4 | 5 | if Mix.env() == :test do 6 | config :logger, level: :warning 7 | end 8 | -------------------------------------------------------------------------------- /demos/phoenix/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:phoenix, :plug] 5 | ] 6 | -------------------------------------------------------------------------------- /demos/phoenix/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | phoenix_demo-*.tar 24 | 25 | /tmp/ 26 | -------------------------------------------------------------------------------- /demos/phoenix/README.md: -------------------------------------------------------------------------------- 1 | # PhoenixDemo 2 | 3 | Minimum example of a Phoenix site powered by site_encrypt. 4 | 5 | Start the site with `iex -S mix`, wait until the certification is done, and in another shell session invoke `curl -k https://localhost:4001`. 6 | On restart, the generated certificate will be used. If you want to force the certificate regeneration, you can clean the project with `rm -rf tmp/site_encrypt_db` and start it again. 7 | 8 | You can also run the certification test with `mix test`. 9 | -------------------------------------------------------------------------------- /demos/phoenix/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :phoenix, json_library: Jason 4 | config :phoenix_demo, PhoenixDemo.Endpoint, adapter: Bandit.PhoenixAdapter 5 | 6 | if Mix.env() == :test do 7 | config :logger, level: :warn 8 | end 9 | -------------------------------------------------------------------------------- /demos/phoenix/lib/phoenix_demo/application.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixDemo.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | Supervisor.start_link( 6 | [PhoenixDemo.Endpoint], 7 | strategy: :one_for_one, 8 | name: __MODULE__ 9 | ) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /demos/phoenix/lib/phoenix_demo/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixDemo.Endpoint do 2 | use SiteEncrypt.Phoenix.Endpoint, 3 | otp_app: :phoenix_demo, 4 | endpoint_opts: [ 5 | http: [port: 4000], 6 | https: [port: 4001], 7 | url: [scheme: "https", host: "localhost", port: 4001] 8 | ] 9 | 10 | plug SiteEncrypt.AcmeChallenge, __MODULE__ 11 | plug Plug.SSL, exclude: [], host: "localhost:4001" 12 | plug :hello 13 | 14 | defp hello(conn, _opts), 15 | do: Plug.Conn.send_resp(conn, :ok, "This site has been encrypted by site_encrypt.") 16 | 17 | @impl SiteEncrypt 18 | def certification do 19 | SiteEncrypt.configure( 20 | client: :native, 21 | domains: ["mysite.com", "www.mysite.com"], 22 | emails: ["admin@email.address"], 23 | db_folder: System.get_env("SITE_ENCRYPT_DB", Path.join("tmp", "site_encrypt_db")), 24 | directory_url: 25 | case System.get_env("CERT_MODE", "local") do 26 | "local" -> {:internal, port: 4002} 27 | "staging" -> "https://acme-staging-v02.api.letsencrypt.org/directory" 28 | "production" -> "https://acme-v02.api.letsencrypt.org/directory" 29 | end 30 | ) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /demos/phoenix/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixDemo.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :phoenix_demo, 7 | version: "0.1.0", 8 | elixir: "~> 1.10", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger], 17 | mod: {PhoenixDemo.Application, []} 18 | ] 19 | end 20 | 21 | defp deps do 22 | [ 23 | {:phoenix, "~> 1.5"}, 24 | {:jason, "~> 1.0"}, 25 | {:bandit, "~> 1.0"}, 26 | {:site_encrypt, path: "../.."} 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /demos/phoenix/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.2.1", "aa485b4ac175065b8e0fb5864ddd5dd7b50d52336b36f61c82f484c3718b3d15", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27393e590a407f1b7d51c5fee4737f139fe224a30449ce25061eac70f763896b"}, 3 | "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, 4 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 7 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, 8 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 9 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 10 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 11 | "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, 12 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 13 | "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, 14 | "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, 15 | "parent": {:hex, :parent, "0.12.1", "495c4386f06de0df492e0a7a7199c10323a55e9e933b27222060dd86dccd6d62", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2ab589ef1f37bfcedbfb5ecfbab93354972fb7391201b8907a866dadd20b39d1"}, 16 | "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, 17 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 18 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 19 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, 20 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.0", "3ae9369c60641084363b08fe90267cbdd316df57e3557ea522114b30b63256ea", [:mix], [{:cowboy, "~> 2.7.0 or ~> 2.8.0 or ~> 2.9.0 or ~> 2.10.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d85444fb8aa1f2fc62eabe83bbe387d81510d773886774ebdcb429b3da3c1a4a"}, 21 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 22 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 23 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 24 | "thousand_island": {:hex, :thousand_island, "1.3.2", "bc27f9afba6e1a676dd36507d42e429935a142cf5ee69b8e3f90bff1383943cd", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0e085b93012cd1057b378fce40cbfbf381ff6d957a382bfdd5eca1a98eec2535"}, 25 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 26 | "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, 27 | "x509": {:hex, :x509, "0.8.8", "aaf5e58b19a36a8e2c5c5cff0ad30f64eef5d9225f0fd98fb07912ee23f7aba3", [:mix], [], "hexpm", "ccc3bff61406e5bb6a63f06d549f3dba3a1bbb456d84517efaaa210d8a33750f"}, 28 | } 29 | -------------------------------------------------------------------------------- /demos/phoenix/test/endpoint_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixDemo.EndpointTest do 2 | use ExUnit.Case, async: false 3 | import SiteEncrypt.Phoenix.Test 4 | 5 | test "certification" do 6 | clean_restart(PhoenixDemo.Endpoint) 7 | cert = get_cert(PhoenixDemo.Endpoint) 8 | assert cert.domains == ~w/mysite.com www.mysite.com/ 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /demos/phoenix/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /dialyzer.ignore: -------------------------------------------------------------------------------- 1 | Unknown type public_key:rsa_private_key/0 2 | Unknown type public_key:ec_private_key/0 3 | -------------------------------------------------------------------------------- /lib/site_encrypt.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt do 2 | @moduledoc "Functions for interacting with sites managed by SiteEncrypt." 3 | 4 | require Logger 5 | 6 | alias SiteEncrypt.Certification 7 | 8 | @opaque certification :: config 9 | 10 | @typedoc false 11 | @type config :: %{ 12 | id: id, 13 | directory_url: directory_url, 14 | domains: nonempty_list(String.t()), 15 | emails: nonempty_list(String.t()), 16 | db_folder: String.t(), 17 | days_to_renew: pos_integer(), 18 | log_level: Logger.level(), 19 | mode: :auto | :manual, 20 | client: :native | :certbot, 21 | backup: String.t() | nil, 22 | callback: module, 23 | key_size: pos_integer, 24 | periodic_offset: Certification.Periodic.offset() 25 | } 26 | 27 | @type pems :: %{privkey: String.t(), cert: String.t(), chain: String.t()} 28 | 29 | @typedoc """ 30 | Uniquely identifies the site certified via site_encrypt. 31 | 32 | The actual value is determined by the adapter used to start the site. For example, if 33 | `SiteEncrypt.Phoenix` is used, the site id is the endpoint module. 34 | """ 35 | @type id :: any 36 | 37 | @typedoc false 38 | @type directory_url :: String.t() | {:internal, [port: pos_integer]} 39 | 40 | @doc """ 41 | Invoked during startup to obtain certification info. 42 | 43 | See `configure/1` for details. 44 | """ 45 | @callback certification() :: certification() 46 | 47 | @doc "Invoked after the new certificate has been obtained." 48 | @callback handle_new_cert() :: any 49 | 50 | @certification_schema [ 51 | client: [ 52 | type: {:in, [:native, :certbot]}, 53 | required: true, 54 | doc: """ 55 | Can be either `:native` or `:certbot`. 56 | 57 | The native client requires no extra OS-level dependencies, and it runs faster, which is 58 | especially useful in a local development and tests. However, this client is very immature, 59 | possibly buggy, and incomplete. 60 | 61 | The certbot client is a wrapper around the [certbot](https://certbot.eff.org/) tool, which has 62 | to be installed on the host machine. This client is much more reliable than the native client, 63 | but it is also significantly slower. 64 | 65 | As a compromise between these two choices, you can consider running certbot client in 66 | production and during CI tests, while using the native client for local development and local 67 | tests. 68 | """ 69 | ], 70 | domains: [ 71 | type: {:custom, __MODULE__, :validate_non_empty_string_list, []}, 72 | required: true, 73 | doc: "The list of domains for which the certificate will be obtained. Must 74 | contain at least one element." 75 | ], 76 | emails: [ 77 | type: {:custom, __MODULE__, :validate_non_empty_string_list, []}, 78 | required: true, 79 | doc: "The list of email addresses which will be passed to the CA when 80 | creating the new account." 81 | ], 82 | db_folder: [ 83 | type: :string, 84 | required: true, 85 | doc: "The folder where site_encrypt stores its data, such as certificates 86 | and account keys." 87 | ], 88 | directory_url: [ 89 | type: {:custom, __MODULE__, :validate_directory_url, []}, 90 | required: true, 91 | doc: """ 92 | The URL to CA directory resource. It can be either a string 93 | (e.g. `"https://acme-v02.api.letsencrypt.org/directory"`) or a tuple in the shape of 94 | `{:internal, port: local_acme_server_port}`. In the latter case, an internal ACME server 95 | will be started at the given port. This is useful for local development and testing. 96 | """ 97 | ], 98 | backup: [ 99 | type: :string, 100 | doc: """ 101 | Path to the backup file. If this option is provided, site_encrypt 102 | will backup the entire content of the `:db_folder` to the given path after every successful 103 | certification. When the system is being started, if the backup file exists while the 104 | `:db_folder` is empty, the system will perform a restore. The generated file will be a 105 | zipped tarball. If this option is not provided no backup will be generated. 106 | """ 107 | ], 108 | days_to_renew: [ 109 | type: :pos_integer, 110 | default: 30, 111 | doc: """ 112 | A positive integer which determines the next renewal attempt. For example, if this value is 113 | 30, the certificate will be renewed if it expires in 30 days or less. 114 | """ 115 | ], 116 | log_level: [ 117 | type: {:in, Logger.levels()}, 118 | default: :info, 119 | doc: "Logger level for info messages." 120 | ], 121 | key_size: [ 122 | type: :pos_integer, 123 | default: 4096, 124 | doc: "The size used for generating private keys." 125 | ], 126 | mode: [ 127 | type: {:in, [:auto, :manual]}, 128 | default: :auto, 129 | doc: """ 130 | When set to `:auto`, the certificate will be automatically created or renewed when needed. 131 | 132 | When set to `:manual`, you need to start the certification manually, using functions such as 133 | `SiteEncrypt.force_certify/1` or `SiteEncrypt.dry_certify/2`. This can be useful for the 134 | first deploy, where you want to manually test the certification. In `:test` mix environment 135 | the mode is always `:manual`. 136 | """ 137 | ] 138 | ] 139 | 140 | @doc """ 141 | Invoke this macro from `certification/0` to return the fully shaped configuration. 142 | 143 | The minimal implementation of `certification/0` looks as follows: 144 | 145 | @impl SiteEncrypt 146 | def certification do 147 | SiteEncrypt.configure( 148 | client: :native, 149 | domains: ["mysite.com", "www.mysite.com"], 150 | emails: ["contact@abc.org", "another_contact@abc.org"], 151 | 152 | # By default the certs will be stored in tmp/site_encrypt_db, which is convenient for 153 | # local development. Make sure that tmp folder is gitignored. 154 | # 155 | # Set OS env var SITE_ENCRYPT_DB on staging/production hosts to some absolute path 156 | # outside of the deployment folder. Otherwise, the deploy may delete the db_folder, 157 | # which will effectively remove the generated key and certificate files. 158 | db_folder: 159 | System.get_env("SITE_ENCRYPT_DB", Path.join("tmp", "site_encrypt_db")), 160 | 161 | # set OS env var CERT_MODE to "staging" or "production" on staging/production hosts 162 | directory_url: 163 | case System.get_env("CERT_MODE", "local") do 164 | "local" -> {:internal, port: 4002} 165 | "staging" -> "https://acme-staging-v02.api.letsencrypt.org/directory" 166 | "production" -> "https://acme-v02.api.letsencrypt.org/directory" 167 | end 168 | ) 169 | end 170 | 171 | ## Options 172 | 173 | #{NimbleOptions.docs(@certification_schema)} 174 | """ 175 | defmacro configure(opts) do 176 | overrides = if Mix.env() == :test, do: %{mode: :manual}, else: %{} 177 | 178 | # adding a suffix in test env to avoid removal of dev certificates during tests 179 | db_folder_suffix = if Mix.env() == :test, do: "test", else: "" 180 | 181 | quote do 182 | defaults = %{id: __MODULE__, backup: nil} 183 | 184 | user_config = 185 | unquote(opts) 186 | |> NimbleOptions.validate!(unquote(Macro.escape(@certification_schema))) 187 | |> Map.new() 188 | |> Map.update!(:db_folder, &(&1 |> Path.join(unquote(db_folder_suffix)) |> Path.expand())) 189 | 190 | config = 191 | defaults 192 | |> Map.merge(user_config) 193 | |> Map.merge(%{callback: __MODULE__, periodic_offset: Certification.Periodic.offset()}) 194 | |> Map.merge(unquote(Macro.escape(overrides))) 195 | |> Map.update!(:backup, &(&1 && Path.expand(&1))) 196 | 197 | if SiteEncrypt.local_ca?(config), do: %{config | key_size: 2048}, else: config 198 | end 199 | end 200 | 201 | @doc "Returns the paths to the certificates and the key for the given site." 202 | @spec https_keys(id) :: [keyfile: Path.t(), certfile: Path.t(), cacertfile: Path.t()] 203 | def https_keys(id) do 204 | config = SiteEncrypt.Registry.config(id) 205 | 206 | [ 207 | keyfile: Path.join(cert_folder(config), "privkey.pem"), 208 | certfile: Path.join(cert_folder(config), "cert.pem"), 209 | cacertfile: Path.join(cert_folder(config), "chain.pem") 210 | ] 211 | end 212 | 213 | @doc """ 214 | Unconditionally obtains the new certificate for the site. 215 | 216 | Be very careful when invoking this function in production, because you might trip some rate 217 | limit at the CA server (see [here](https://letsencrypt.org/docs/rate-limits/) for Let's 218 | Encrypt limits). 219 | """ 220 | @spec force_certify(id) :: :ok | :error 221 | def force_certify(id), do: Certification.run_renew(SiteEncrypt.Registry.config(id)) 222 | 223 | @doc """ 224 | Generates a new throwaway certificate for the given site. 225 | 226 | This function will perform the full certification at the given CA server. The new certificate 227 | won't be used by the site, nor stored on disk. This is mostly useful to test the certification 228 | through the staging CA server from the production server, which can be done as follows: 229 | 230 | SiteEncrypt.dry_certify( 231 | MySystemWeb.Endpoint, 232 | directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory" 233 | ) 234 | 235 | If for some reasons you want to apply the certificate to the site, you can pass the returned 236 | pems to `set_certificate/2`. 237 | """ 238 | @spec dry_certify(id, directory_url: String.t()) :: {:ok, pems} | :error 239 | def dry_certify(id, opts \\ []) do 240 | id 241 | |> SiteEncrypt.Registry.config() 242 | |> Map.update!(:directory_url, &Keyword.get(opts, :directory_url, &1)) 243 | |> SiteEncrypt.Certification.Job.certify() 244 | end 245 | 246 | @doc """ 247 | Sets the new site certificate. 248 | 249 | This operation doesn't persist the certificate in the client storage. As a result, if the client 250 | previously obtained and stored a valid certificate, that certificate will be used after the 251 | endpoint is restarted. 252 | """ 253 | @spec set_certificate(id, pems) :: :ok 254 | def set_certificate(id, pems) do 255 | config = SiteEncrypt.Registry.config(id) 256 | store_pems(config, pems) 257 | :ssl.clear_pem_cache() 258 | 259 | unless is_nil(config.backup), do: SiteEncrypt.Certification.backup(config) 260 | config.callback.handle_new_cert() 261 | 262 | :ok 263 | end 264 | 265 | @doc false 266 | @spec log(config, iodata) :: :ok 267 | def log(config, chardata_or_fun), do: Logger.log(config.log_level, chardata_or_fun) 268 | 269 | @doc false 270 | @spec directory_url(config) :: String.t() 271 | def directory_url(config) do 272 | with {:internal, opts} <- config.directory_url, 273 | do: "https://localhost:#{Keyword.fetch!(opts, :port)}/directory" 274 | end 275 | 276 | @doc false 277 | @spec local_ca?(config) :: boolean 278 | def local_ca?(config), do: URI.parse(directory_url(config)).host == "localhost" 279 | 280 | @doc false 281 | def client(%{client: :native}), do: Certification.Native 282 | def client(%{client: :certbot}), do: Certification.Certbot 283 | 284 | @doc false 285 | def initialize_certs(config) do 286 | Certification.restore(config) 287 | unless is_nil(config.backup) or File.exists?(config.backup), do: Certification.backup(config) 288 | 289 | File.mkdir_p!(cert_folder(config)) 290 | File.chmod!(config.db_folder, 0o700) 291 | 292 | case SiteEncrypt.client(config).pems(config) do 293 | {:ok, keys} -> store_pems(config, keys) 294 | :error -> unless certificates_exist?(config), do: generate_self_signed_certificate!(config) 295 | end 296 | end 297 | 298 | defp store_pems(config, keys) do 299 | Enum.each( 300 | keys, 301 | fn {name, content} -> 302 | File.write!(Path.join(cert_folder(config), "#{name}.pem"), content) 303 | end 304 | ) 305 | end 306 | 307 | defp certificates_exist?(config) do 308 | ~w(privkey.pem cert.pem chain.pem) 309 | |> Stream.map(&Path.join(cert_folder(config), &1)) 310 | |> Enum.all?(&File.exists?/1) 311 | end 312 | 313 | defp generate_self_signed_certificate!(config) do 314 | log( 315 | config, 316 | "Generating a temporary self-signed certificate. " <> 317 | "This certificate will be used until a proper certificate is issued by the CA server." 318 | ) 319 | 320 | config.domains 321 | |> SiteEncrypt.Acme.Server.Crypto.self_signed_chain() 322 | |> Stream.map(fn {type, pem} -> {file_name(type), pem} end) 323 | |> Enum.each(&save_pem!(config, &1)) 324 | end 325 | 326 | defp file_name(:ca_cert), do: "chain.pem" 327 | defp file_name(:server_cert), do: "cert.pem" 328 | defp file_name(:server_key), do: "privkey.pem" 329 | 330 | defp save_pem!(config, {file_name, contents}), 331 | do: File.write!(Path.join(cert_folder(config), file_name), contents) 332 | 333 | defp cert_folder(config), 334 | do: Path.join([config.db_folder, "certs", hd(config.domains)]) 335 | 336 | @doc false 337 | def validate_non_empty_string_list(list) do 338 | cond do 339 | not is_list(list) -> {:error, "expected a list"} 340 | Enum.empty?(list) -> {:error, "expected a non-empty list"} 341 | Enum.any?(list, &(not String.valid?(&1))) -> {:error, "expected a list of strings"} 342 | true -> {:ok, list} 343 | end 344 | end 345 | 346 | @doc false 347 | def validate_directory_url({:internal, opts} = internal) do 348 | port = Keyword.get(opts, :port) 349 | 350 | cond do 351 | is_nil(port) -> {:error, "missing port for the internal CA server"} 352 | not is_integer(port) -> {:error, "port for the internal CA server must be an integer"} 353 | port <= 0 -> {:error, "port for the internal CA server must be a positive integer"} 354 | true -> {:ok, internal} 355 | end 356 | end 357 | 358 | def validate_directory_url(string) do 359 | if String.valid?(string), 360 | do: {:ok, string}, 361 | else: {:error, ":directory_url must be a string or an `:internal` tuple"} 362 | end 363 | 364 | @doc false 365 | def certificate_subjects_changed?(config) do 366 | with {:ok, pems} <- client(config).pems(config), 367 | {:ok, certified_domains} <- certified_domains(pems.cert), 368 | do: MapSet.new(config.domains) != MapSet.new(certified_domains), 369 | else: (:error -> false) 370 | end 371 | 372 | defp certified_domains(cert) do 373 | certificate = X509.Certificate.from_pem!(cert) 374 | 375 | case X509.Certificate.extension(certificate, :subject_alt_name) do 376 | {:Extension, _, _, dns_names} -> 377 | {:ok, Enum.map(dns_names, fn {_, dns_name} -> to_string(dns_name) end)} 378 | 379 | _ -> 380 | :error 381 | end 382 | end 383 | 384 | @doc """ 385 | Refresh the configuration for a given endpoint. 386 | 387 | Use this if your endpoint is dynamically retrieving the list of domains from the database for example and you want to 388 | update the configuration in the registry. In most cases it makes sense to call `SiteEncrypt.force_certify/1` after 389 | the config has been refreshed. 390 | """ 391 | def refresh_config(id) do 392 | SiteEncrypt.Adapter.refresh_config(id) 393 | end 394 | end 395 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/client.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Client do 2 | @moduledoc """ 3 | ACME related functions for endpoints managed by `SiteEncrypt`. 4 | 5 | This module exposes higher-level ACME client-side scenarios, such as new account creation, and 6 | certification. Normally these functions are invoked automatically by `SiteEncrypt` processes. 7 | 8 | The functions in this module operate on a running endpoint managed by `SiteEncrypt`. For Phoenix 9 | this means that the endpoint must be started through `SiteEncrypt.Phoenix`. 10 | 11 | See also `SiteEncrypt.Acme.Client.API` for details about API sessions and lower level API. 12 | """ 13 | alias SiteEncrypt.Acme.Client.{API, Crypto} 14 | 15 | @doc "Creates the new account." 16 | @spec new_account(SiteEncrypt.id(), API.session_opts()) :: API.session() 17 | def new_account(id, session_opts \\ []) do 18 | config = SiteEncrypt.Registry.config(id) 19 | account_key = JOSE.JWK.generate_key({:rsa, config.key_size}) 20 | session = start_session(SiteEncrypt.directory_url(config), account_key, session_opts) 21 | {:ok, session} = API.new_account(session, config.emails) 22 | session 23 | end 24 | 25 | @doc "Returns `API` session for the existing account." 26 | @spec for_existing_account(SiteEncrypt.id(), JOSE.JWK.t(), API.session_opts()) :: API.session() 27 | def for_existing_account(id, account_key, session_opts) do 28 | config = SiteEncrypt.Registry.config(id) 29 | session = start_session(SiteEncrypt.directory_url(config), account_key, session_opts) 30 | {:ok, session} = API.fetch_kid(session) 31 | session 32 | end 33 | 34 | @doc """ 35 | Obtains the new certificate. 36 | 37 | The obtained certificate will not be applied to the endpoint or stored to disk. If you want to 38 | apply the new certificate to the endpoint, you can pass the returned pems to the function 39 | `SiteEncrypt.set_certificate/2`. 40 | """ 41 | @spec create_certificate(API.session(), SiteEncrypt.id()) :: {SiteEncrypt.pems(), API.session()} 42 | def create_certificate(session, id) do 43 | config = SiteEncrypt.Registry.config(id) 44 | {:ok, order, session} = API.new_order(session, config.domains) 45 | {private_key, order, session} = process_new_order(session, order, config) 46 | {:ok, cert, chain, session} = API.get_cert(session, order) 47 | {%{privkey: Crypto.private_key_to_pem(private_key), cert: cert, chain: chain}, session} 48 | end 49 | 50 | defp start_session(directory_url, account_key, session_opts) do 51 | {:ok, session} = API.new_session(directory_url, account_key, session_opts) 52 | session 53 | end 54 | 55 | defp process_new_order(session, %{status: :pending} = order, config) do 56 | {pending, session} = 57 | Enum.reduce( 58 | order.authorizations, 59 | {[], session}, 60 | fn authorization, {pending_authorizations, session} -> 61 | case authorize(session, config, authorization) do 62 | {:pending, challenge, session} -> 63 | {[{authorization, challenge} | pending_authorizations], session} 64 | 65 | {:valid, session} -> 66 | {pending_authorizations, session} 67 | end 68 | end 69 | ) 70 | 71 | {pending_authorizations, pending_challenges} = Enum.unzip(pending) 72 | SiteEncrypt.Registry.await_challenges(config.id, pending_challenges, :timer.minutes(1)) 73 | 74 | {:ok, session} = poll(session, config, &validate_authorizations(&1, pending_authorizations)) 75 | 76 | {order, session} = 77 | poll(session, config, fn session -> 78 | case API.order_status(session, order) do 79 | {:ok, %{status: :ready} = order, session} -> {order, session} 80 | {:ok, _, session} -> {nil, session} 81 | end 82 | end) 83 | 84 | process_new_order(session, order, config) 85 | end 86 | 87 | defp process_new_order(session, %{status: :ready} = order, config) do 88 | private_key = Crypto.new_private_key(Map.get(config, :key_size, 4096)) 89 | csr = Crypto.csr(private_key, config.domains) 90 | 91 | {:ok, _finalization, session} = API.finalize(session, order, csr) 92 | 93 | {order, session} = 94 | poll(session, config, fn session -> 95 | case API.order_status(session, order) do 96 | {:ok, %{status: :valid} = order, session} -> {order, session} 97 | {:ok, _, session} -> {nil, session} 98 | end 99 | end) 100 | 101 | {private_key, order, session} 102 | end 103 | 104 | defp authorize(session, config, authorization) do 105 | {:ok, challenges, session} = API.authorization(session, authorization) 106 | 107 | http_challenge = Enum.find(challenges, &(&1.type == "http-01")) 108 | false = is_nil(http_challenge) 109 | 110 | case http_challenge.status do 111 | :pending -> 112 | key_thumbprint = JOSE.JWK.thumbprint(session.account_key) 113 | SiteEncrypt.Registry.register_challenge(config.id, http_challenge.token, key_thumbprint) 114 | {:ok, _challenge_response, session} = API.challenge(session, http_challenge) 115 | {:pending, http_challenge.token, session} 116 | 117 | :valid -> 118 | {:valid, session} 119 | end 120 | end 121 | 122 | defp validate_authorizations(session, []), do: {:ok, session} 123 | 124 | defp validate_authorizations(session, [authorization | other_authorizations]) do 125 | {:ok, challenges, session} = API.authorization(session, authorization) 126 | 127 | if Enum.any?(challenges, &(&1.status == :valid)), 128 | do: validate_authorizations(session, other_authorizations), 129 | else: {nil, session} 130 | end 131 | 132 | defp poll(session, config, operation) do 133 | poll( 134 | session, 135 | operation, 136 | 60, 137 | if(SiteEncrypt.local_ca?(config), do: 50, else: :timer.seconds(2)) 138 | ) 139 | end 140 | 141 | defp poll(session, _operation, 0, _), do: {:error, session} 142 | 143 | defp poll(session, operation, attempt, delay) do 144 | with {nil, session} <- operation.(session) do 145 | Process.sleep(delay) 146 | poll(session, operation, attempt - 1, delay) 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/client/api.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Client.API do 2 | @moduledoc """ 3 | Low level API for interacting with an ACME CA server. 4 | 5 | This module is a very incomplete implementation of the ACME client, as described in 6 | [RFC8555](https://tools.ietf.org/html/rfc8555). Internally, the module uses `Mint.HTTP` to 7 | communicate with the server. All functions will internally make a blocking HTTP request to 8 | the server. Therefore it's advised to invoke the functions of this module from within a separate 9 | process, powered by `Task`. 10 | 11 | To use the client, you first need to create the session with `new_session/3`. Then you can 12 | interact with the server using the remaining functions of this module. The session doesn't hold 13 | any resources open, so you can safely use it from multiple processes. 14 | """ 15 | alias SiteEncrypt.HttpClient 16 | alias SiteEncrypt.Acme.Client.Crypto 17 | 18 | defmodule Session do 19 | @moduledoc false 20 | defstruct ~w/http_opts account_key kid directory nonce/a 21 | 22 | defimpl Inspect do 23 | def inspect(session, _opts), do: "##{inspect(session.__struct__)}<#{session.directory.url}>" 24 | end 25 | end 26 | 27 | @type session :: %Session{ 28 | http_opts: Keyword.t(), 29 | account_key: JOSE.JWK.t(), 30 | kid: nil | String.t(), 31 | directory: nil | directory, 32 | nonce: nil | String.t() 33 | } 34 | 35 | @type directory :: %{ 36 | url: String.t(), 37 | key_change: String.t(), 38 | new_account: String.t(), 39 | new_nonce: String.t(), 40 | new_order: String.t(), 41 | revoke_cert: String.t() 42 | } 43 | 44 | @type error :: Mint.Types.error() | HttpClient.response() 45 | 46 | @type order :: %{ 47 | :status => status, 48 | :authorizations => [String.t()], 49 | :finalize => String.t(), 50 | :location => String.t(), 51 | optional(:certificate) => String.t() 52 | } 53 | 54 | @type challenge :: %{ 55 | :status => status, 56 | :type => String.t(), 57 | :url => String.t(), 58 | optional(:token) => String.t() 59 | } 60 | 61 | @type status :: :invalid | :pending | :ready | :processing | :valid 62 | 63 | @type session_opts :: [verify_server_cert: boolean] 64 | 65 | @doc """ 66 | Creates a new session to the given CA. 67 | 68 | - `directory_url` has to point to the GET directory resource, such as 69 | https://acme-v02.api.letsencrypt.org/directory or 70 | https://acme-staging-v02.api.letsencrypt.org/directory 71 | - `account_key` is the private key of the CA account. If you want to create the new account, you 72 | need to generate this key yourself, for example with 73 | 74 | JOSE.JWK.generate_key({:rsa, _key_size = 4096}) 75 | 76 | Note that this will not create the account. You need to invoke `new_account/2` to do that. 77 | It is your responsibility to safely store the private key somewhere. 78 | 79 | If you want to access the existing account, you should pass the same key used for the account 80 | creation. In this case you'll usually need to invoke `fetch_kid/1` to fetch the key identifier 81 | from the CA server. 82 | 83 | Note that this function will make an in-process GET HTTP request to the given directory URL. 84 | """ 85 | @spec new_session(String.t(), JOSE.JWK.t(), session_opts) :: 86 | {:ok, session} | {:error, error} 87 | def new_session(directory_url, account_key, http_opts \\ []) do 88 | with {response, session} <- initialize_session(http_opts, account_key, directory_url), 89 | :ok <- validate_response(response) do 90 | directory = 91 | response.payload 92 | |> normalize_keys(~w/keyChange newAccount newNonce newOrder revokeCert/) 93 | |> Map.merge(session.directory) 94 | 95 | {:ok, %Session{session | directory: directory}} 96 | end 97 | end 98 | 99 | @doc "Creates the new account at the CA server." 100 | @spec new_account(session, [String.t()]) :: {:ok, session} | {:error, error} 101 | def new_account(session, emails) do 102 | url = session.directory.new_account 103 | payload = %{"contact" => Enum.map(emails, &"mailto:#{&1}"), "termsOfServiceAgreed" => true} 104 | 105 | with {:ok, response, session} <- jws_request(session, :post, url, :jwk, payload) do 106 | location = :proplists.get_value("location", response.headers) 107 | {:ok, %Session{session | kid: location}} 108 | end 109 | end 110 | 111 | @doc """ 112 | Obtains the key identifier of the existing account. 113 | 114 | You only need to invoke this function if the session is created using the key of the existing 115 | account. 116 | """ 117 | @spec fetch_kid(session) :: {:ok, session} | {:error, error} 118 | def fetch_kid(session) do 119 | url = session.directory.new_account 120 | payload = %{"onlyReturnExisting" => true} 121 | 122 | with {:ok, response, session} <- jws_request(session, :post, url, :jwk, payload) do 123 | location = :proplists.get_value("location", response.headers) 124 | {:ok, %Session{session | kid: location}} 125 | end 126 | end 127 | 128 | @doc "Creates a new order on the CA server." 129 | @spec new_order(session, [String.t()]) :: {:ok, order, session} | {:error, error} 130 | def new_order(session, domains) do 131 | payload = %{"identifiers" => Enum.map(domains, &%{"type" => "dns", "value" => &1})} 132 | 133 | with {:ok, response, session} <- 134 | jws_request(session, :post, session.directory.new_order, :kid, payload) do 135 | location = :proplists.get_value("location", response.headers) 136 | 137 | result = 138 | response.payload 139 | |> normalize_keys(~w/authorizations finalize status/) 140 | |> Map.update!(:status, &parse_status!/1) 141 | |> Map.put(:location, location) 142 | 143 | {:ok, result, session} 144 | end 145 | end 146 | 147 | @doc "Obtains the status of the given order." 148 | @spec order_status(session, order) :: {:ok, order, session} | {:error, error} 149 | def order_status(session, order) do 150 | with {:ok, response, session} <- jws_request(session, :post, order.location, :kid) do 151 | result = 152 | response.payload 153 | |> normalize_keys(~w/authorizations finalize status certificate/) 154 | |> Map.update!(:status, &parse_status!/1) 155 | 156 | {:ok, Map.merge(order, result), session} 157 | end 158 | end 159 | 160 | @doc "Obtains authorization challenges from the CA." 161 | @spec authorization(session, String.t()) :: {:ok, [challenge], session} 162 | def authorization(session, authorization) do 163 | with {:ok, response, session} <- jws_request(session, :post, authorization, :kid) do 164 | challenges = 165 | response.payload 166 | |> Map.fetch!("challenges") 167 | |> Stream.map(&normalize_keys(&1, ~w/status token type url/)) 168 | |> Enum.map(&Map.update!(&1, :status, fn value -> parse_status!(value) end)) 169 | 170 | {:ok, challenges, session} 171 | end 172 | end 173 | 174 | @doc "Returns the status and the token of the http-01 challenge." 175 | @spec challenge(session, challenge) :: 176 | {:ok, %{status: status, token: String.t()}, session} | {:error, error} 177 | def challenge(session, challenge) do 178 | payload = %{} 179 | 180 | with {:ok, response, session} <- jws_request(session, :post, challenge.url, :kid, payload) do 181 | result = 182 | response.payload 183 | |> normalize_keys(~w/status token/) 184 | |> Map.update!(:status, &parse_status!/1) 185 | 186 | {:ok, result, session} 187 | end 188 | end 189 | 190 | @doc "Finalizes the given order." 191 | @spec finalize(session, order, binary) :: {:ok, %{status: status}, session} | {:error, error} 192 | def finalize(session, order, csr) do 193 | payload = %{"csr" => Base.url_encode64(csr, padding: false)} 194 | 195 | with {:ok, response, session} <- jws_request(session, :post, order.finalize, :kid, payload) do 196 | result = 197 | response.payload 198 | |> normalize_keys(~w/status/) 199 | |> Map.update!(:status, &parse_status!/1) 200 | 201 | {:ok, result, session} 202 | end 203 | end 204 | 205 | @doc "Obtains the certificate and chain from a finalized order." 206 | @spec get_cert(session, order) :: {:ok, String.t(), String.t(), session} | {:error, error} 207 | def get_cert(session, order) do 208 | with {:ok, response, session} <- jws_request(session, :post, order.certificate, :kid) do 209 | [cert | chain] = String.split(response.body, ~r/^\-+END CERTIFICATE\-+$\K/m, parts: 2) 210 | {:ok, Crypto.normalize_pem(cert), Crypto.normalize_pem(to_string(chain)), session} 211 | end 212 | end 213 | 214 | defp initialize_session(http_opts, account_key, directory_url) do 215 | http_request( 216 | %Session{ 217 | http_opts: http_opts, 218 | account_key: account_key, 219 | directory: %{url: directory_url} 220 | }, 221 | :get, 222 | directory_url 223 | ) 224 | end 225 | 226 | defp jws_request(session, verb, url, id_field, payload \\ "") do 227 | with {:ok, session} <- get_nonce(session) do 228 | headers = [{"content-type", "application/jose+json"}] 229 | body = jws_body(session, url, id_field, payload) 230 | session = %Session{session | nonce: nil} 231 | 232 | case http_request(session, verb, url, headers: headers, body: body) do 233 | {%{status: status} = response, session} when status in 200..299 -> 234 | {:ok, response, session} 235 | 236 | {%{payload: %{"type" => "urn:ietf:params:acme:error:badNonce"}}, session} -> 237 | jws_request(session, verb, url, id_field, payload) 238 | 239 | {response, session} -> 240 | {:error, response, session} 241 | end 242 | end 243 | end 244 | 245 | defp get_nonce(%Session{nonce: nil} = session) do 246 | with {response, session} <- http_request(session, :head, session.directory.new_nonce), 247 | :ok <- validate_response(response), 248 | do: {:ok, session} 249 | end 250 | 251 | defp get_nonce(session), do: {:ok, session} 252 | 253 | defp jws_body(session, url, id_field, payload) do 254 | protected = 255 | Map.merge( 256 | %{"alg" => "RS256", "nonce" => session.nonce, "url" => url}, 257 | id_map(id_field, session) 258 | ) 259 | 260 | plain_text = if payload == "", do: "", else: Jason.encode!(payload) 261 | {_, signed} = JOSE.JWS.sign(session.account_key, plain_text, protected) 262 | Jason.encode!(signed) 263 | end 264 | 265 | defp id_map(:jwk, session) do 266 | {_modules, public_map} = JOSE.JWK.to_public_map(session.account_key) 267 | %{"jwk" => public_map} 268 | end 269 | 270 | defp id_map(:kid, session), do: %{"kid" => session.kid} 271 | 272 | defp http_request(session, verb, url, opts \\ []) do 273 | opts = 274 | opts 275 | |> Keyword.put_new(:headers, []) 276 | |> Keyword.update!(:headers, &[{"user-agent", "site_encrypt native client"} | &1]) 277 | |> Keyword.merge(session.http_opts) 278 | 279 | response = HttpClient.request(verb, url, opts) 280 | 281 | content_type = :proplists.get_value("content-type", response.headers, "") 282 | 283 | payload = 284 | if String.starts_with?(content_type, "application/json") or 285 | String.starts_with?(content_type, "application/problem+json"), 286 | do: Jason.decode!(response.body) 287 | 288 | session = 289 | case Enum.find(response.headers, &match?({"replay-nonce", _nonce}, &1)) do 290 | {"replay-nonce", nonce} -> %Session{session | nonce: nonce} 291 | nil -> session 292 | end 293 | 294 | {Map.put(response, :payload, payload), session} 295 | end 296 | 297 | defp parse_status!("invalid"), do: :invalid 298 | defp parse_status!("pending"), do: :pending 299 | defp parse_status!("ready"), do: :ready 300 | defp parse_status!("processing"), do: :processing 301 | defp parse_status!("valid"), do: :valid 302 | 303 | defp normalize_keys(map, allowed_keys) do 304 | map 305 | |> Map.take(allowed_keys) 306 | |> Enum.into(%{}, fn {key, value} -> 307 | {key |> Macro.underscore() |> String.to_atom(), value} 308 | end) 309 | end 310 | 311 | defp validate_response(response), 312 | do: if(response.status in 200..299, do: :ok, else: {:error, response}) 313 | end 314 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/client/crypto.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Client.Crypto do 2 | @moduledoc false 3 | 4 | @spec new_private_key(non_neg_integer) :: X509.PrivateKey.t() 5 | def new_private_key(size), do: X509.PrivateKey.new_rsa(size) 6 | 7 | @spec private_key_to_pem(X509.PrivateKey.t()) :: String.t() 8 | def private_key_to_pem(private_key), 9 | do: private_key |> X509.PrivateKey.to_pem() |> normalize_pem() 10 | 11 | @spec csr(X509.PrivateKey.t(), [String.t()]) :: binary 12 | def csr(private_key, domains) do 13 | private_key 14 | |> X509.CSR.new( 15 | {:rdnSequence, []}, 16 | extension_request: [X509.Certificate.Extension.subject_alt_name(domains)] 17 | ) 18 | |> X509.CSR.to_der() 19 | end 20 | 21 | @spec normalize_pem(String.t()) :: String.t() 22 | def normalize_pem(pem) do 23 | case String.trim(pem) do 24 | "" -> "" 25 | pem -> pem <> "\n" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/server.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Server do 2 | @moduledoc false 3 | use Parent.Supervisor 4 | require Logger 5 | alias SiteEncrypt.Acme.Server.Account 6 | 7 | @type start_opts :: [id: SiteEncrypt.id(), dns: dns_fun, port: pos_integer()] 8 | @type config :: %{id: SiteEncrypt.id(), site: String.t(), site_uri: URI.t(), dns: dns_fun} 9 | @type dns_fun :: (-> %{String.t() => String.t()}) 10 | @type method :: :get | :head | :put | :post | :delete 11 | @type handle_response :: %{status: status, headers: headers, body: body} 12 | @type status :: pos_integer 13 | @type headers :: [{String.t(), String.t()}] 14 | @type body :: binary 15 | @type domains :: [String.t()] 16 | @type opts :: [log_level: Logger.level(), adapter: adapter] 17 | @type adapter :: :cowboy | :bandit 18 | 19 | defp auto_determine_adapter do 20 | loaded_apps = 21 | for {app, _, _} <- Application.loaded_applications(), into: MapSet.new(), do: app 22 | 23 | cond do 24 | MapSet.member?(loaded_apps, :cowboy) -> :cowboy 25 | MapSet.member?(loaded_apps, :bandit) -> :bandit 26 | true -> raise "Local ACME server can only run if cowboy or bandit app is present" 27 | end 28 | end 29 | 30 | @spec start_link(term, pos_integer, dns_fun, opts) :: Supervisor.on_start() 31 | def start_link(id, port, dns, opts \\ []) do 32 | adapter = Keyword.get_lazy(opts, :adapter, &auto_determine_adapter/0) 33 | Logger.log(Keyword.get(opts, :log_level, :debug), "Running local ACME server at port #{port}") 34 | 35 | site = "https://localhost:#{port}" 36 | 37 | acme_server_config = %{ 38 | id: id, 39 | site: site, 40 | site_uri: URI.parse(site), 41 | dns: dns 42 | } 43 | 44 | Parent.Supervisor.start_link([ 45 | {SiteEncrypt.Acme.Server.Db, acme_server_config}, 46 | endpoint_spec(adapter, acme_server_config, port) 47 | ]) 48 | end 49 | 50 | def whereis(id) do 51 | {:ok, server} = Parent.Client.child_pid(SiteEncrypt.Registry.root(id), __MODULE__) 52 | server 53 | end 54 | 55 | @spec resource_path(config, String.t()) :: {:ok, String.t()} | :error 56 | def resource_path(config, request_path) do 57 | path = config.site_uri.path || "" 58 | size = byte_size(path) 59 | 60 | case request_path do 61 | <<^path::binary-size(size), rest_path::binary>> -> {:ok, rest_path} 62 | _ -> :error 63 | end 64 | end 65 | 66 | @spec handle(config, method, String.t(), binary) :: handle_response 67 | def handle(config, method, path, body) 68 | 69 | def handle(config, :get, "/directory", _body) do 70 | respond_json(200, %{ 71 | newNonce: "#{config.site}/new-nonce", 72 | newAccount: "#{config.site}/new-account", 73 | newOrder: "#{config.site}/new-order", 74 | newAuthz: "#{config.site}/new-authz", 75 | revokeCert: "#{config.site}/revoke-cert", 76 | keyChange: "#{config.site}/key-change" 77 | }) 78 | end 79 | 80 | def handle(config, :head, "/new-nonce", _body), do: respond(200, [nonce_header(config)]) 81 | 82 | def handle(config, :post, "/new-account", body) do 83 | request = decode_request(config, body) 84 | 85 | account = 86 | case Account.fetch(config, client_key(request)) do 87 | {:ok, account} -> account 88 | :error -> Account.create(config, client_key(request)) 89 | end 90 | 91 | respond_json( 92 | 201, 93 | [{"Location", account.location}, nonce_header(config)], 94 | %{status: :valid, contact: Map.get(request.payload, "contact", [])} 95 | ) 96 | end 97 | 98 | def handle(config, :post, "/new-order", body) do 99 | request = decode_request(config, body) 100 | 101 | domains = 102 | request.payload 103 | |> Map.fetch!("identifiers") 104 | |> Enum.filter(&(Map.fetch!(&1, "type") == "dns")) 105 | |> Enum.map(&Map.fetch!(&1, "value")) 106 | 107 | {account, order} = create_order(config, request, domains) 108 | order_path = order_path(account.id, order.id) 109 | 110 | respond_json( 111 | 201, 112 | [{"Location", "#{config.site}/order/#{order_path}"}, nonce_header(config)], 113 | %{ 114 | status: order.status, 115 | expires: expires(), 116 | identifiers: Enum.map(domains, &%{type: "dns", value: &1}), 117 | authorizations: ["#{config.site}/authorizations/#{order_path}"], 118 | finalize: "#{config.site}/finalize/#{order_path}" 119 | } 120 | ) 121 | end 122 | 123 | def handle(config, :post, "/authorizations/" <> order_path, body) do 124 | _request = decode_request(config, body) 125 | 126 | {account_id, order_id} = decode_order_path(order_path) 127 | order = SiteEncrypt.Acme.Server.Account.get_order!(config, account_id, order_id) 128 | 129 | respond_json( 130 | 200, 131 | [nonce_header(config)], 132 | %{ 133 | status: with(:ready <- order.status, do: :valid), 134 | identifier: %{type: "dns", value: "localhost"}, 135 | challenges: [http_challenge_data(config, account_id, order)] 136 | } 137 | ) 138 | end 139 | 140 | def handle(config, :post, "/challenge/http/" <> order_path, body) do 141 | request = decode_request(config, body) 142 | {account_id, order_id} = decode_order_path(order_path) 143 | order = SiteEncrypt.Acme.Server.Account.get_order!(config, account_id, order_id) 144 | authorizations_url = "#{config.site}/authorizations/#{order_path}" 145 | 146 | challenge_data = %{ 147 | dns: config.dns.(), 148 | account_id: account_id, 149 | order: order, 150 | key_thumbprint: JOSE.JWK.thumbprint(client_key(request)) 151 | } 152 | 153 | Parent.Client.start_child( 154 | whereis(config.id), 155 | {SiteEncrypt.Acme.Server.Challenge, {config, challenge_data}} 156 | ) 157 | 158 | respond_json( 159 | 200, 160 | [nonce_header(config), {"Link", "<#{authorizations_url}>;rel=\"up\""}], 161 | http_challenge_data(config, account_id, order, status: :processing) 162 | ) 163 | end 164 | 165 | def handle(config, :post, "/finalize/" <> order_path, body) do 166 | request = decode_request(config, body) 167 | csr = request.payload |> Map.fetch!("csr") |> Base.url_decode64!(padding: false) 168 | 169 | {account_id, order_id} = decode_order_path(order_path) 170 | order = SiteEncrypt.Acme.Server.Account.get_order!(config, account_id, order_id) 171 | 172 | cert = SiteEncrypt.Acme.Server.Crypto.sign_csr!(csr, order.domains) 173 | updated_order = %{order | cert: cert, status: :valid} 174 | SiteEncrypt.Acme.Server.Account.update_order(config, account_id, updated_order) 175 | 176 | respond_json(200, [nonce_header(config)], order_data(config, account_id, updated_order)) 177 | end 178 | 179 | def handle(config, :post, "/order/" <> order_path, _body) do 180 | {account_id, order_id} = decode_order_path(order_path) 181 | order = SiteEncrypt.Acme.Server.Account.get_order!(config, account_id, order_id) 182 | respond_json(200, [nonce_header(config)], order_data(config, account_id, order)) 183 | end 184 | 185 | def handle(config, :post, "/cert/" <> order_path, body) do 186 | _ = decode_request(config, body) 187 | {account_id, order_id} = decode_order_path(order_path) 188 | certificate = SiteEncrypt.Acme.Server.Account.get_order!(config, account_id, order_id).cert 189 | respond(200, [nonce_header(config)], certificate) 190 | end 191 | 192 | defp http_challenge_data(config, account_id, order, opts \\ []) do 193 | %{ 194 | type: "http-01", 195 | status: Keyword.get(opts, :status, with(:ready <- order.status, do: :valid)), 196 | url: "#{config.site}/challenge/http/#{order_path(account_id, order.id)}", 197 | token: order.token 198 | } 199 | end 200 | 201 | defp order_data(config, account_id, order) do 202 | %{ 203 | status: order.status, 204 | identifier: %{type: "dns", value: "localhost"}, 205 | certificate: "#{config.site}/cert/#{order_path(account_id, order.id)}" 206 | } 207 | end 208 | 209 | defp order_path(account_id, order_id), do: "#{account_id}/#{order_id}" 210 | 211 | defp expires() do 212 | NaiveDateTime.utc_now() 213 | |> NaiveDateTime.add(3600, :second) 214 | |> DateTime.from_naive!("Etc/UTC") 215 | |> DateTime.to_iso8601() 216 | end 217 | 218 | defp respond(status, headers, body \\ ""), do: %{status: status, headers: headers, body: body} 219 | 220 | defp respond_json(status, headers \\ [], data), 221 | do: respond(status, [{"Content-Type", "application/json"} | headers], Jason.encode!(data)) 222 | 223 | defp nonce_header(config) do 224 | {"Replay-Nonce", 225 | SiteEncrypt.Acme.Server.Nonce.new(config) |> to_string() |> Base.encode64(padding: false)} 226 | end 227 | 228 | defp decode_request(config, body) do 229 | {:ok, request} = SiteEncrypt.Acme.Server.JWS.decode(body) 230 | verify_nonce!(config, request) 231 | request 232 | end 233 | 234 | defp client_key(request), do: request.jwk 235 | 236 | defp verify_nonce!(config, request) do 237 | nonce = 238 | request.protected 239 | |> Map.fetch!("nonce") 240 | |> Base.decode64!(padding: false) 241 | |> String.to_integer() 242 | 243 | SiteEncrypt.Acme.Server.Nonce.verify!(config, nonce) 244 | end 245 | 246 | defp decode_order_path(order_path) do 247 | [account_id, order_id] = String.split(order_path, "/") 248 | {account_id, String.to_integer(order_id)} 249 | end 250 | 251 | defp create_order(config, request, domains) do 252 | account = 253 | case Account.fetch(config, client_key(request)) do 254 | {:ok, account} -> account 255 | :error -> Account.create(config, client_key(request)) 256 | end 257 | 258 | {account, SiteEncrypt.Acme.Server.Account.new_order(config, account, domains)} 259 | end 260 | 261 | defp endpoint_spec(adapter, config, port) do 262 | key = X509.PrivateKey.new_rsa(2048) 263 | cert = X509.Certificate.self_signed(key, "/C=US/ST=CA/O=Acme/CN=ECDSA Root CA") 264 | 265 | adapter_spec = adapter_spec(adapter, config, port, key, cert) 266 | Supervisor.child_spec(adapter_spec, []) 267 | end 268 | 269 | defp adapter_spec(:cowboy, config, port, key, cert) do 270 | adapter_opts = [ 271 | plug: {SiteEncrypt.Acme.Server.Plug, config}, 272 | scheme: :https, 273 | key: {:PrivateKeyInfo, X509.PrivateKey.to_der(key, wrap: true)}, 274 | cert: X509.Certificate.to_der(cert), 275 | transport_options: [num_acceptors: 1], 276 | ref: :"#{__MODULE__}_#{port}", 277 | options: [port: port] 278 | ] 279 | 280 | {Plug.Cowboy, adapter_opts} 281 | end 282 | 283 | defp adapter_spec(:bandit, config, port, key, cert) do 284 | adapter_opts = [ 285 | plug: {SiteEncrypt.Acme.Server.Plug, config}, 286 | scheme: :https, 287 | thousand_island_options: [ 288 | num_acceptors: 1, 289 | port: port, 290 | transport_options: [ 291 | key: {:PrivateKeyInfo, X509.PrivateKey.to_der(key, wrap: true)}, 292 | cert: X509.Certificate.to_der(cert) 293 | ] 294 | ] 295 | ] 296 | 297 | {Bandit, adapter_opts} 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/server/account.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Server.Account do 2 | @moduledoc false 3 | 4 | @type t :: %{id: id, location: String.t()} 5 | @type id :: String.t() 6 | @type key :: map 7 | @type order :: %{ 8 | id: integer(), 9 | status: :valid | :pending | :ready, 10 | cert: nil | binary(), 11 | domains: SiteEncrypt.Acme.Server.domains(), 12 | token: binary() 13 | } 14 | 15 | @spec create(SiteEncrypt.Acme.Server.config(), key) :: t() 16 | def create(config, client_key) do 17 | id = client_key |> :erlang.term_to_binary() |> Base.url_encode64(padding: false) 18 | location = "#{config.site}/account/#{id}" 19 | account = %{id: id, location: location} 20 | 21 | SiteEncrypt.Acme.Server.Db.store_new!(config, {:account, client_key}, account) 22 | 23 | account 24 | end 25 | 26 | @spec client_key(id) :: key 27 | def client_key(account_id), 28 | do: account_id |> Base.url_decode64!(padding: false) |> :erlang.binary_to_term() 29 | 30 | @spec fetch(SiteEncrypt.Acme.Server.config(), key) :: {:ok, t()} | :error 31 | def fetch(config, client_key), 32 | do: SiteEncrypt.Acme.Server.Db.fetch(config, {:account, client_key}) 33 | 34 | @spec new_order(SiteEncrypt.Acme.Server.config(), t(), SiteEncrypt.Acme.Server.domains()) :: 35 | order() 36 | def new_order(config, account, domains) do 37 | order = %{ 38 | id: :erlang.unique_integer([:positive, :monotonic]), 39 | status: :pending, 40 | cert: nil, 41 | domains: domains, 42 | token: Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false) 43 | } 44 | 45 | SiteEncrypt.Acme.Server.Db.store_new!(config, {:order, account.id, order.id}, order) 46 | order 47 | end 48 | 49 | @spec update_order(SiteEncrypt.Acme.Server.config(), id, order) :: :ok 50 | def update_order(config, account_id, order), 51 | do: SiteEncrypt.Acme.Server.Db.store(config, {:order, account_id, order.id}, order) 52 | 53 | @spec get_order!(SiteEncrypt.Acme.Server.config(), id, integer()) :: any() 54 | def get_order!(config, account_id, order_id), 55 | do: SiteEncrypt.Acme.Server.Db.fetch!(config, {:order, account_id, order_id}) 56 | end 57 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/server/challenge.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Server.Challenge do 2 | @moduledoc false 3 | 4 | # This module powers a single process, which issues an http challenge to the 5 | # server. If the challenge succeeds, the job updates the account info. 6 | # 7 | # Each challenge is running as a separate process, which ensure proper error 8 | # isolation. Failure or blockage of while challenging one site won't affect 9 | # other challenges. 10 | # 11 | # A challenge process is a Parent.GenServer which makes the actual request 12 | # in a child task. This approach is chosen for better control of error 13 | # handling. The parent process can apply delay and retry logic, and give 14 | # up after some number of retries. 15 | # 16 | # Because failure of one challenge request shouldn't affect other challenges, 17 | # the restart strategy is temporary. In principle, the Parent.GenServer has 18 | # minimal logic, since most of the action is happening in the child task, so 19 | # it shouldn't crash. But even if it does, we don't want to trip up the 20 | # restart intensity, and crash other challenges. 21 | 22 | # We'll retry at most 12 times, with 5 seconds delay between each retry. 23 | # Each challenge request must return in 5 seconds. Therefore, in total 24 | # we're challenging for at most 1 minute 55 seconds (12 timeouts of 5 seconds 25 | # plus 11 delays of 5 seconds). 26 | @max_retries 12 27 | @retry_delay :timer.seconds(5) 28 | 29 | use Parent.GenServer, restart: :temporary 30 | 31 | def start_link({config, challenge_data}), 32 | do: Parent.GenServer.start_link(__MODULE__, {config, challenge_data}) 33 | 34 | @impl GenServer 35 | def init({config, challenge_data}) do 36 | state = Map.merge(challenge_data, %{parent: self(), attempts: 1, config: config}) 37 | start_challenge(state) 38 | {:ok, state} 39 | end 40 | 41 | @impl GenServer 42 | def handle_info(:challenge_succeeded, state), do: {:stop, :normal, state} 43 | def handle_info(:challenge_failed, state), do: retry_challenge(state) 44 | 45 | def handle_info(:start_challenge, state) do 46 | start_challenge(state) 47 | {:noreply, state} 48 | end 49 | 50 | @impl Parent.GenServer 51 | def handle_stopped_children(%{challenge: %{exit_reason: reason}}, state) when reason != :normal, 52 | do: retry_challenge(state) 53 | 54 | def handle_stopped_children(_, state), do: {:noreply, state} 55 | 56 | defp retry_challenge(state) do 57 | if(state.attempts == @max_retries) do 58 | {:stop, {:error, :max_failures}, state} 59 | else 60 | Process.send_after(self(), :start_challenge, @retry_delay) 61 | {:noreply, update_in(state.attempts, &(&1 + 1))} 62 | end 63 | end 64 | 65 | defp start_challenge(state) do 66 | Parent.start_child(%{ 67 | id: :challenge, 68 | start: {Task, :start_link, [fn -> challenge(state) end]}, 69 | restart: :temporary, 70 | ephemeral?: true 71 | }) 72 | end 73 | 74 | defp challenge(state) do 75 | if challenge_domains( 76 | state.order.domains, 77 | state.order.token, 78 | state.dns, 79 | state.key_thumbprint 80 | ) 81 | |> Enum.all?(&(&1 == :ok)) do 82 | order = %{state.order | status: :ready} 83 | SiteEncrypt.Acme.Server.Account.update_order(state.config, state.account_id, order) 84 | send(state.parent, :challenge_succeeded) 85 | else 86 | send(state.parent, :challenge_failed) 87 | end 88 | end 89 | 90 | defp challenge_domains(domains, token, dns, key_thumbprint) do 91 | domains 92 | |> Task.async_stream(&challenge_domain(Map.get(dns, &1, &1), token, key_thumbprint)) 93 | |> Enum.map(fn 94 | {:ok, result} -> result 95 | _ -> :error 96 | end) 97 | end 98 | 99 | defp challenge_domain(url, token, key_thumbprint) do 100 | with %{status: 200, body: response} <- http_request(url, token), 101 | ^response <- "#{token}.#{key_thumbprint}" do 102 | :ok 103 | else 104 | _ -> :error 105 | end 106 | end 107 | 108 | defp http_request(server, token) do 109 | url = "http://#{server}/.well-known/acme-challenge/#{token}" 110 | SiteEncrypt.HttpClient.request(:get, url, verify_server_cert: false) 111 | end 112 | 113 | def child_spec({config, challenge_data} = arg) do 114 | # We'll register each challenge with the registry, using ACME server site and 115 | # challenge data as the unique key. This ensures that no duplicate challenges 116 | # are running at the same time. 117 | arg 118 | |> super() 119 | |> Parent.child_spec(id: {config.site, challenge_data}, ephemeral?: true) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/server/crypto.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Server.Crypto do 2 | @moduledoc false 3 | 4 | alias X509.{CSR, PrivateKey, PublicKey, Certificate} 5 | alias X509.Certificate.Extension 6 | 7 | @spec sign_csr!(binary(), SiteEncrypt.Acme.Server.domains()) :: binary() | no_return() 8 | def sign_csr!(der, domains) do 9 | csr = CSR.from_der!(der) 10 | unless CSR.valid?(csr), do: raise("CSR validation failed") 11 | 12 | {ca_key, ca_cert} = ca_key_and_cert() 13 | 14 | signed_csr = 15 | csr 16 | |> CSR.public_key() 17 | |> server_cert(ca_key, ca_cert, domains) 18 | |> Certificate.to_pem() 19 | 20 | "#{signed_csr}\n#{Certificate.to_pem(ca_cert)}\n" 21 | end 22 | 23 | def self_signed_chain(domains) do 24 | {ca_key, ca_cert} = ca_key_and_cert() 25 | 26 | server_key = PrivateKey.new_rsa(2048) 27 | 28 | server_cert = 29 | server_key 30 | |> PublicKey.derive() 31 | |> server_cert(ca_key, ca_cert, domains) 32 | 33 | %{ 34 | ca_cert: Certificate.to_pem(ca_cert), 35 | server_cert: Certificate.to_pem(server_cert), 36 | server_key: PrivateKey.to_pem(server_key) 37 | } 38 | end 39 | 40 | defp ca_key_and_cert() do 41 | ca_key = PrivateKey.new_rsa(2048) 42 | ca_cert = Certificate.self_signed(ca_key, "/O=Site Encrypt/CN=Acme Server CA", template: :ca) 43 | {ca_key, ca_cert} 44 | end 45 | 46 | defp server_cert(public_key, ca_key, ca_cert, domains) do 47 | Certificate.new( 48 | public_key, 49 | "/O=Site Encrypt/CN=#{hd(domains)}", 50 | ca_cert, 51 | ca_key, 52 | validity: 1000 * 365, 53 | extensions: [subject_alt_name: Extension.subject_alt_name(domains)] 54 | ) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/server/db.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Server.Db do 2 | @moduledoc false 3 | use GenServer 4 | alias SiteEncrypt.Acme 5 | 6 | def start_link(config), do: GenServer.start_link(__MODULE__, config) 7 | 8 | @spec store(Acme.Server.config(), any(), any()) :: :ok 9 | def store(config, key, value) do 10 | :ets.insert(table(config), {key, value}) 11 | :ok 12 | end 13 | 14 | @spec store_new!(Acme.Server.config(), any(), any()) :: :ok 15 | def store_new!(config, key, value), do: :ok = store_new(config, key, value) 16 | 17 | @spec store_new(Acme.Server.config(), any(), any()) :: :ok | :error 18 | def store_new(config, key, value), 19 | do: if(:ets.insert_new(table(config), {key, value}), do: :ok, else: :error) 20 | 21 | @spec fetch!(Acme.Server.config(), any()) :: any() 22 | def fetch!(config, key) do 23 | {:ok, value} = fetch(config, key) 24 | value 25 | end 26 | 27 | @spec fetch(Acme.Server.config(), any()) :: {:ok, any()} | :error 28 | def fetch(config, key) do 29 | case :ets.lookup(table(config), key) do 30 | [{^key, value}] -> {:ok, value} 31 | _ -> :error 32 | end 33 | end 34 | 35 | @spec pop!(Acme.Server.config(), any()) :: any() 36 | def pop!(config, key) do 37 | [{^key, value}] = :ets.take(table(config), key) 38 | value 39 | end 40 | 41 | defp table(config) do 42 | {:ok, table} = Parent.Client.child_meta(Acme.Server.whereis(config.id), __MODULE__) 43 | table 44 | end 45 | 46 | @impl GenServer 47 | def init(config), do: {:ok, nil, {:continue, {:create_table, config}}} 48 | 49 | @impl GenServer 50 | def handle_continue({:create_table, config}, state) do 51 | table = :ets.new(__MODULE__, [:public, read_concurrency: true, write_concurrency: true]) 52 | Parent.Client.update_child_meta(Acme.Server.whereis(config.id), __MODULE__, fn _ -> table end) 53 | {:noreply, state} 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/server/jws.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Server.JWS do 2 | @moduledoc false 3 | 4 | @spec decode(iodata()) :: {:ok, map()} | :error 5 | def decode(body) do 6 | data = Jason.decode!(body) 7 | 8 | protected = 9 | data 10 | |> Map.fetch!("protected") 11 | |> Base.decode64!(padding: false) 12 | |> Jason.decode!() 13 | 14 | jwk = 15 | Map.get_lazy(protected, "jwk", fn -> 16 | "/account/" <> account_id = 17 | protected 18 | |> Map.fetch!("kid") 19 | |> URI.parse() 20 | |> Map.fetch!(:path) 21 | 22 | SiteEncrypt.Acme.Server.Account.client_key(account_id) 23 | end) 24 | 25 | key = JOSE.JWK.from(jwk) 26 | 27 | case JOSE.JWS.verify(key, data) do 28 | {true, payload, _jws} -> 29 | {:ok, %{payload: decode_payload(payload), protected: protected, jwk: jwk}} 30 | 31 | _ -> 32 | :error 33 | end 34 | end 35 | 36 | defp decode_payload(""), do: "" 37 | defp decode_payload(payload), do: Jason.decode!(payload) 38 | end 39 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/server/nonce.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Server.Nonce do 2 | @moduledoc false 3 | 4 | @spec new(SiteEncrypt.Acme.Server.config()) :: integer() 5 | def new(config) do 6 | nonce = :erlang.unique_integer([:positive, :monotonic]) 7 | SiteEncrypt.Acme.Server.Db.store_new!(config, {:nonce, nonce}, nil) 8 | nonce 9 | end 10 | 11 | @spec verify!(SiteEncrypt.Acme.Server.config(), integer()) :: :ok 12 | def verify!(config, nonce) do 13 | SiteEncrypt.Acme.Server.Db.pop!(config, {:nonce, nonce}) 14 | :ok 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme/server/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Acme.Server.Plug do 2 | @moduledoc false 3 | 4 | @behaviour Plug 5 | import Plug.Conn 6 | 7 | @impl Plug 8 | def init(config), do: config 9 | 10 | @impl Plug 11 | def call(conn, config) do 12 | case SiteEncrypt.Acme.Server.resource_path(config, conn.request_path) do 13 | {:ok, path} -> handle_request(conn, config, path) |> halt() 14 | :error -> conn 15 | end 16 | end 17 | 18 | defp handle_request(conn, config, path) do 19 | method = method(conn.method) 20 | {:ok, body, conn} = read_body(conn) 21 | acme_response = SiteEncrypt.Acme.Server.handle(config, method, path, body) 22 | send_response(conn, acme_response) 23 | end 24 | 25 | defp method("GET"), do: :get 26 | defp method("HEAD"), do: :head 27 | defp method("POST"), do: :post 28 | defp method("PUT"), do: :put 29 | defp method("DELETE"), do: :delete 30 | 31 | defp send_response(conn, acme_response) do 32 | conn 33 | |> merge_resp_headers(acme_response.headers) 34 | |> send_resp(acme_response.status, acme_response.body) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/site_encrypt/acme_challenge.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.AcmeChallenge do 2 | @moduledoc false 3 | @behaviour Plug 4 | alias SiteEncrypt.Registry 5 | 6 | @impl Plug 7 | def init(id), do: id 8 | 9 | @impl Plug 10 | def call(%{request_path: "/.well-known/acme-challenge/" <> challenge} = conn, id) do 11 | conn 12 | |> Plug.Conn.send_resp(200, challenge_response(id, challenge)) 13 | |> Plug.Conn.halt() 14 | end 15 | 16 | def call(conn, _endpoint), do: conn 17 | 18 | defp challenge_response(id, challenge) do 19 | case Registry.get_challenge(id, challenge) do 20 | nil -> 21 | config = Registry.config(id) 22 | SiteEncrypt.client(config).full_challenge(config, challenge) 23 | 24 | key_thumbprint -> 25 | "#{challenge}.#{key_thumbprint}" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/site_encrypt/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Adapter do 2 | alias SiteEncrypt.{Acme, Registry} 3 | 4 | use Parent.GenServer 5 | 6 | @callback config(SiteEncrypt.id(), Keyword.t()) :: %{ 7 | certification: SiteEncrypt.certification(), 8 | site_spec: Parent.child_spec() 9 | } 10 | 11 | @callback http_port(SiteEncrypt.id(), arg :: any) :: {:ok, pos_integer} | :error 12 | 13 | defmacro __using__(opts) do 14 | quote bind_quoted: [opts: opts] do 15 | @behaviour SiteEncrypt.Adapter 16 | 17 | @doc """ 18 | Returns a specification to start this module under a supervisor. 19 | 20 | See `Supervisor`. 21 | """ 22 | def child_spec(start_opts) do 23 | Supervisor.child_spec( 24 | %{ 25 | id: __MODULE__, 26 | type: :supervisor, 27 | start: {__MODULE__, :start_link, [start_opts]} 28 | }, 29 | unquote(opts) 30 | ) 31 | end 32 | end 33 | end 34 | 35 | def start_link(callback, id, opts), 36 | do: Parent.GenServer.start_link(__MODULE__, {callback, id, opts}, name: Registry.root(id)) 37 | 38 | @doc false 39 | # used only in tests 40 | def restart_site(id, fun) do 41 | Parent.Client.shutdown_all(Registry.root(id)) 42 | fun.() 43 | GenServer.call(Registry.root(id), :start_all_children) 44 | end 45 | 46 | @doc """ 47 | Refresh the configuration for a given endpoint 48 | """ 49 | def refresh_config(id) do 50 | GenServer.call(Registry.root(id), :refresh_config) 51 | end 52 | 53 | @impl GenServer 54 | def init({callback, id, opts}) do 55 | state = %{callback: callback, id: id, opts: opts} 56 | start_all_children!(state) 57 | {:ok, state} 58 | end 59 | 60 | @impl GenServer 61 | def handle_call(:start_all_children, _from, state) do 62 | start_all_children!(state) 63 | {:reply, :ok, state} 64 | end 65 | 66 | @impl GenServer 67 | def handle_call(:refresh_config, _from, state) do 68 | adapter_config = state.callback.config(state.id, state.opts) 69 | Registry.store_config(state.id, adapter_config.certification) 70 | {:reply, :ok, state} 71 | end 72 | 73 | defp start_all_children!(state) do 74 | adapter_config = state.callback.config(state.id, state.opts) 75 | Registry.store_config(state.id, adapter_config.certification) 76 | 77 | SiteEncrypt.initialize_certs(adapter_config.certification) 78 | 79 | Parent.start_all_children!([ 80 | Parent.child_spec(adapter_config.site_spec, id: :site), 81 | Parent.child_spec(Acme.Server, 82 | start: fn -> start_acme_server(state, adapter_config) end, 83 | binds_to: [:site] 84 | ) 85 | | SiteEncrypt.Certification.child_specs(state.id) 86 | ]) 87 | end 88 | 89 | defp start_acme_server(state, adapter_config) do 90 | config = adapter_config.certification 91 | 92 | with {:ok, site_port} <- state.callback.http_port(state.id, state.opts), 93 | %{directory_url: {:internal, acme_server_opts}} <- config do 94 | {acme_server_port, acme_server_opts} = Keyword.pop!(acme_server_opts, :port) 95 | dns = dns(config.id, site_port) 96 | acme_server_opts = [log_level: config.log_level] ++ acme_server_opts 97 | Acme.Server.start_link(config.id, acme_server_port, dns, acme_server_opts) 98 | else 99 | _ -> :ignore 100 | end 101 | end 102 | 103 | defp dns(id, endpoint_port) do 104 | fn -> 105 | # refetching the new config before resolving domain names allows us to correctly handle 106 | # config changes made after the endpoint has been started 107 | Enum.into(Registry.config(id).domains, %{}, &{&1, "localhost:#{endpoint_port}"}) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/site_encrypt/application.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_type, _args) do 6 | Supervisor.start_link( 7 | [SiteEncrypt.Registry], 8 | strategy: :one_for_one, 9 | name: SiteEncrypt.Supervisor 10 | ) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/site_encrypt/certification.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Certification do 2 | @moduledoc false 3 | require Logger 4 | alias SiteEncrypt.Registry 5 | alias SiteEncrypt.Certification.Periodic 6 | 7 | def child_specs(id) do 8 | [ 9 | %{ 10 | id: __MODULE__.InitialRenewal, 11 | start: {Task, :start_link, [fn -> start_initial_renewal(id) end]}, 12 | restart: :temporary, 13 | binds_to: [:site] 14 | }, 15 | Parent.child_spec({Periodic, id}, binds_to: [:site]) 16 | ] 17 | end 18 | 19 | @spec run_renew(SiteEncrypt.config()) :: :ok | :error 20 | def run_renew(config) do 21 | pid = 22 | case start_renew(config) do 23 | {:ok, pid} -> pid 24 | {:error, {:already_started, pid}} -> pid 25 | end 26 | 27 | mref = Process.monitor(pid) 28 | 29 | receive do 30 | {:DOWN, ^mref, :process, ^pid, reason} -> 31 | if reason == :normal, do: :ok, else: :error 32 | end 33 | end 34 | 35 | @spec backup(SiteEncrypt.config()) :: :ok 36 | def backup(config) do 37 | {:ok, tar} = :erl_tar.open(to_charlist(config.backup), [:write, :compressed]) 38 | 39 | config.db_folder 40 | |> Path.join("*") 41 | |> Path.wildcard() 42 | |> Enum.each(fn path -> 43 | :ok = 44 | :erl_tar.add( 45 | tar, 46 | to_charlist(path), 47 | to_charlist(Path.relative_to(path, config.db_folder)), 48 | [] 49 | ) 50 | end) 51 | 52 | :ok = :erl_tar.close(tar) 53 | File.chmod!(config.backup, 0o600) 54 | catch 55 | type, error -> 56 | Logger.error( 57 | "Error backing up certificates: #{Exception.format(type, error, __STACKTRACE__)}" 58 | ) 59 | end 60 | 61 | @spec restore(SiteEncrypt.config()) :: :ok 62 | def restore(config) do 63 | if not is_nil(config.backup) and 64 | File.exists?(config.backup) and 65 | not File.exists?(config.db_folder) do 66 | SiteEncrypt.log(config, "restoring certificates for #{hd(config.domains)}") 67 | File.mkdir_p!(config.db_folder) 68 | 69 | :ok = 70 | :erl_tar.extract( 71 | to_charlist(config.backup), 72 | [:compressed, cwd: to_charlist(config.db_folder)] 73 | ) 74 | 75 | with {:ok, pems} <- SiteEncrypt.client(config).pems(config) do 76 | SiteEncrypt.set_certificate(config.id, pems) 77 | SiteEncrypt.log(config, "certificates for #{hd(config.domains)} restored") 78 | end 79 | end 80 | catch 81 | type, error -> 82 | Logger.error( 83 | "Error restoring certificates: #{Exception.format(type, error, __STACKTRACE__)}" 84 | ) 85 | end 86 | 87 | defp start_initial_renewal(id) do 88 | config = Registry.config(id) 89 | 90 | if config.mode == :auto do 91 | if Periodic.cert_due_for_renewal?(config) or 92 | SiteEncrypt.certificate_subjects_changed?(config) do 93 | start_renew(config) 94 | else 95 | SiteEncrypt.log(config, [ 96 | "Certificate for #{hd(config.domains)} is valid until ", 97 | "#{Periodic.cert_valid_until(config)}. ", 98 | "Next renewal is scheduled for #{Periodic.renewal_date(config)}." 99 | ]) 100 | end 101 | end 102 | end 103 | 104 | defp start_renew(config) do 105 | Parent.Client.start_child( 106 | Registry.root(config.id), 107 | {SiteEncrypt.Certification.Job, config} 108 | ) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/site_encrypt/certification/certbot.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Certification.Certbot do 2 | @moduledoc false 3 | 4 | @behaviour SiteEncrypt.Certification.Job 5 | require Logger 6 | 7 | @impl SiteEncrypt.Certification.Job 8 | def pems(config) do 9 | [ 10 | privkey: keyfile(config), 11 | cert: certfile(config), 12 | chain: cacertfile(config) 13 | ] 14 | |> Stream.map(fn {type, path} -> 15 | case File.read(path) do 16 | {:ok, content} -> {type, content} 17 | _error -> nil 18 | end 19 | end) 20 | |> Enum.split_with(&is_nil/1) 21 | |> case do 22 | {[], pems} -> {:ok, Map.new(pems)} 23 | {[_ | _], _} -> :error 24 | end 25 | end 26 | 27 | @impl SiteEncrypt.Certification.Job 28 | def certify(config, opts) do 29 | ensure_folders(config) 30 | 31 | result = 32 | case pems(config) do 33 | {:ok, _} -> 34 | if SiteEncrypt.certificate_subjects_changed?(config), 35 | do: expand(config, opts), 36 | else: renew(config, opts) 37 | 38 | _ -> 39 | certonly(config, opts) 40 | end 41 | 42 | case result do 43 | {output, 0} -> 44 | SiteEncrypt.log(config, output) 45 | :ok 46 | 47 | {output, _error} -> 48 | Logger.error(output) 49 | :error 50 | end 51 | end 52 | 53 | @impl SiteEncrypt.Certification.Job 54 | def full_challenge(config, challenge) do 55 | Path.join([ 56 | webroot_folder(config), 57 | ".well-known", 58 | "acme-challenge", 59 | challenge 60 | ]) 61 | |> File.read!() 62 | end 63 | 64 | defp ensure_folders(config) do 65 | Enum.each( 66 | [config_folder(config), work_folder(config), webroot_folder(config)], 67 | &File.mkdir_p!/1 68 | ) 69 | end 70 | 71 | defp certonly(config, opts) do 72 | certbot_cmd( 73 | config, 74 | opts, 75 | ~w( 76 | certonly 77 | --webroot 78 | --webroot-path #{webroot_folder(config)} 79 | --agree-tos 80 | ) ++ 81 | domain_params(config) 82 | ) 83 | end 84 | 85 | defp renew(config, opts) do 86 | args = ~w( 87 | renew 88 | --agree-tos 89 | --no-random-sleep-on-renew 90 | --cert-name #{hd(config.domains)} 91 | --force-renewal 92 | ) 93 | 94 | certbot_cmd(config, opts, args) 95 | end 96 | 97 | defp expand(config, opts) do 98 | certbot_cmd( 99 | config, 100 | opts, 101 | ~w( 102 | certonly 103 | --webroot 104 | --webroot-path #{webroot_folder(config)} 105 | --agree-tos 106 | --expand 107 | ) ++ 108 | domain_params(config) 109 | ) 110 | end 111 | 112 | defp certbot_cmd(config, opts, args), 113 | do: System.cmd("certbot", args ++ common_args(config, opts), stderr_to_stdout: true) 114 | 115 | defp common_args(config, opts) do 116 | ~w( 117 | -m #{Enum.join(config.emails, " ")} 118 | --server #{SiteEncrypt.directory_url(config)} 119 | --work-dir #{work_folder(config)} 120 | --config-dir #{config_folder(config)} 121 | --logs-dir #{log_folder(config)} 122 | --no-self-upgrade 123 | --non-interactive 124 | --rsa-key-size #{config.key_size} 125 | #{unless Keyword.get(opts, :verify_server_cert, true), do: "--no-verify-ssl"} 126 | ) 127 | end 128 | 129 | defp domain_params(config), do: Enum.map(config.domains, &"-d #{&1}") 130 | 131 | defp keys_folder(config), do: Path.join(~w(#{config_folder(config)} live #{hd(config.domains)})) 132 | defp config_folder(config), do: Path.join(root_folder(config), "config") 133 | defp log_folder(config), do: Path.join(root_folder(config), "log") 134 | defp work_folder(config), do: Path.join(root_folder(config), "work") 135 | defp webroot_folder(config), do: Path.join(root_folder(config), "webroot") 136 | defp root_folder(config), do: Path.join([config.db_folder, "certbot", ca_folder(config)]) 137 | 138 | defp keyfile(config), do: Path.join(keys_folder(config), "privkey.pem") 139 | defp certfile(config), do: Path.join(keys_folder(config), "cert.pem") 140 | defp cacertfile(config), do: Path.join(keys_folder(config), "chain.pem") 141 | 142 | defp ca_folder(config) do 143 | case URI.parse(SiteEncrypt.directory_url(config)) do 144 | %URI{host: host, port: 443} -> host 145 | %URI{host: host, port: port} -> "#{host}_#{port}" 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/site_encrypt/certification/job.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Certification.Job do 2 | @moduledoc false 3 | 4 | require Logger 5 | 6 | @callback pems(SiteEncrypt.config()) :: {:ok, SiteEncrypt.pems()} | :error 7 | @callback full_challenge(SiteEncrypt.config(), String.t()) :: String.t() 8 | 9 | @callback certify(SiteEncrypt.config(), force_certifyal: boolean) :: :ok | :error 10 | 11 | @spec certify(SiteEncrypt.config()) :: {:ok, SiteEncrypt.pems()} | :error 12 | def certify(config) do 13 | opts = [verify_server_cert: not SiteEncrypt.local_ca?(config)] 14 | 15 | case SiteEncrypt.client(config).certify(config, opts) do 16 | :error -> 17 | Logger.error("Error obtaining certificate for #{hd(config.domains)}") 18 | :error 19 | 20 | :ok -> 21 | SiteEncrypt.client(config).pems(config) 22 | end 23 | end 24 | 25 | defp certify_and_apply(config) do 26 | with {:ok, pems} <- certify(config) do 27 | valid_until = SiteEncrypt.Certification.Periodic.cert_valid_until(config) 28 | renewal_date = SiteEncrypt.Certification.Periodic.renewal_date(config) 29 | 30 | SiteEncrypt.log(config, [ 31 | "Certificate successfully obtained! It is valid until #{valid_until}. ", 32 | "Next renewal is scheduled for #{renewal_date}. " 33 | ]) 34 | 35 | SiteEncrypt.set_certificate(config.id, pems) 36 | end 37 | end 38 | 39 | def child_spec(config) do 40 | %{ 41 | id: __MODULE__, 42 | start: {Task, :start_link, [fn -> certify_and_apply(config) end]}, 43 | timeout: :timer.minutes(5), 44 | restart: :temporary, 45 | ephemeral?: true, 46 | binds_to: [:site] 47 | } 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/site_encrypt/certification/native.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Certification.Native do 2 | @moduledoc false 3 | @behaviour SiteEncrypt.Certification.Job 4 | 5 | alias SiteEncrypt.Acme.Client 6 | alias SiteEncrypt.Certification.Job 7 | 8 | @impl Job 9 | def pems(config) do 10 | {:ok, 11 | Enum.into( 12 | ~w/privkey cert chain/a, 13 | %{}, 14 | &{&1, File.read!(Path.join(domain_folder(config), "#{&1}.pem"))} 15 | )} 16 | catch 17 | _, _ -> 18 | :error 19 | end 20 | 21 | @impl Job 22 | def certify(config, opts) do 23 | case account_key(config) do 24 | nil -> new_account(config, opts) 25 | account_key -> new_cert(config, account_key, opts) 26 | end 27 | end 28 | 29 | @impl Job 30 | def full_challenge(_config, _challenge), do: raise("unknown challenge") 31 | 32 | defp log(config, msg) do 33 | SiteEncrypt.log(config, "#{msg} (CA #{URI.parse(SiteEncrypt.directory_url(config)).host})") 34 | end 35 | 36 | defp session_opts(opts), do: Keyword.take(opts, ~w/verify_server_cert/a) 37 | 38 | defp new_account(config, opts) do 39 | log(config, "Creating new account") 40 | session = Client.new_account(config.id, session_opts(opts)) 41 | store_account_key!(config, session.account_key) 42 | create_certificate(config, session) 43 | end 44 | 45 | defp new_cert(config, account_key, opts) do 46 | session = Client.for_existing_account(config.id, account_key, session_opts(opts)) 47 | create_certificate(config, session) 48 | end 49 | 50 | defp create_certificate(config, session) do 51 | log(config, "Ordering a new certificate for domain #{hd(config.domains)}") 52 | {pems, _session} = Client.create_certificate(session, config.id) 53 | store_pems!(config, pems) 54 | log(config, "New certificate for domain #{hd(config.domains)} obtained") 55 | end 56 | 57 | defp account_key(config) do 58 | File.read!(Path.join(ca_folder(config), "account_key.json")) 59 | |> Jason.decode!() 60 | |> JOSE.JWK.from_map() 61 | catch 62 | _, _ -> nil 63 | end 64 | 65 | defp store_account_key!(config, account_key) do 66 | {_, map} = JOSE.JWK.to_map(account_key) 67 | store_file!(Path.join(ca_folder(config), "account_key.json"), Jason.encode!(map)) 68 | end 69 | 70 | defp store_pems!(config, pems) do 71 | timestamp = 72 | DateTime.utc_now() 73 | |> DateTime.to_string() 74 | |> String.replace(~r/[\- \:\.Z]/, "") 75 | 76 | Enum.each( 77 | pems, 78 | fn {type, content} -> 79 | store_file!(Path.join(domain_folder(config), "#{type}.pem"), content) 80 | 81 | store_file!( 82 | Path.join([domain_folder(config), "archive", timestamp, "#{type}.pem"]), 83 | content 84 | ) 85 | end 86 | ) 87 | end 88 | 89 | defp store_file!(path, content) do 90 | File.mkdir_p(Path.dirname(path)) 91 | File.write!(path, content) 92 | File.chmod!(path, 0o600) 93 | end 94 | 95 | defp domain_folder(config), 96 | do: Path.join([ca_folder(config), "domains", hd(config.domains)]) 97 | 98 | defp ca_folder(config) do 99 | Path.join([ 100 | config.db_folder, 101 | "native", 102 | "authorities", 103 | case URI.parse(SiteEncrypt.directory_url(config)) do 104 | %URI{host: host, port: 443} -> host 105 | %URI{host: host, port: port} -> "#{host}_#{port}" 106 | end 107 | ]) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/site_encrypt/certification/periodic.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Certification.Periodic do 2 | @moduledoc false 3 | 4 | @hour_interval 8 5 | 6 | @type offset :: %{hour: 0..unquote(@hour_interval), minute: 0..59, second: 0..59} 7 | 8 | @spec offset :: offset 9 | def offset do 10 | # Offset for periodical job. This offset is randomized to reduce traffic spikes on the CA 11 | # server, as suggested at https://letsencrypt.org/docs/integration-guide/ 12 | %{ 13 | hour: :rand.uniform(@hour_interval) - 1, 14 | minute: :rand.uniform(60) - 1, 15 | second: :rand.uniform(60) - 1 16 | } 17 | end 18 | 19 | def start_link(id) do 20 | config = SiteEncrypt.Registry.config(id) 21 | 22 | Periodic.start_link( 23 | id: __MODULE__, 24 | run: fn -> SiteEncrypt.force_certify(config.id) end, 25 | every: :timer.seconds(1), 26 | when: fn -> time_to_renew?(config, utc_now(config)) end, 27 | on_overlap: :ignore, 28 | mode: config.mode 29 | ) 30 | end 31 | 32 | defp time_to_renew?(config, now) do 33 | rem(now.hour, @hour_interval) == config.periodic_offset.hour and 34 | now.minute == config.periodic_offset.minute and 35 | now.second == config.periodic_offset.second and 36 | cert_due_for_renewal?(config, now) 37 | end 38 | 39 | def cert_due_for_renewal?(config, now \\ nil) do 40 | now = now || utc_now(config) 41 | Date.compare(DateTime.to_date(now), renewal_date(config)) in [:eq, :gt] 42 | end 43 | 44 | def renewal_date(config) do 45 | cert_valid_until = cert_valid_until(config) 46 | Date.add(cert_valid_until, -config.days_to_renew) 47 | end 48 | 49 | def cert_valid_until(config) do 50 | case SiteEncrypt.client(config).pems(config) do 51 | :error -> 52 | DateTime.to_date(utc_now(config)) 53 | 54 | {:ok, pems} -> 55 | {:Validity, _from, to} = 56 | pems.cert 57 | |> X509.Certificate.from_pem!() 58 | |> X509.Certificate.validity() 59 | 60 | to 61 | |> X509.DateTime.to_datetime() 62 | |> DateTime.to_date() 63 | end 64 | end 65 | 66 | defp utc_now(config), do: :persistent_term.get({__MODULE__, config.id}, DateTime.utc_now()) 67 | 68 | @doc false 69 | # for test purposes only 70 | def tick(id, datetime) do 71 | :persistent_term.put({__MODULE__, id}, datetime) 72 | 73 | {:ok, scheduler_pid} = Parent.Client.child_pid(SiteEncrypt.Registry.root(id), __MODULE__) 74 | 75 | case Periodic.Test.sync_tick(scheduler_pid, :infinity) do 76 | {:ok, :normal} -> :ok 77 | {:ok, abnormal} -> {:error, abnormal} 78 | error -> error 79 | end 80 | after 81 | :persistent_term.erase({{__MODULE__, id}, datetime}) 82 | end 83 | 84 | def child_spec(id) do 85 | %{ 86 | id: __MODULE__, 87 | start: {__MODULE__, :start_link, [id]}, 88 | type: :supervisor 89 | } 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/site_encrypt/http_client.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.HttpClient do 2 | @moduledoc false 3 | 4 | # This is a very naive Mint-based HTTP client which does no connection reusing/pooling, i.e. 5 | # every request is done on a separate connection. This approach has been chosen for simplicity. 6 | # 7 | # As an alternative, Finch was also considered, but at the time of writing this it didn't seem 8 | # suitable for a couple of reasons: 9 | # 10 | # 1. We have to explicitly choose http1 vs http2, which we can't do because we don't know 11 | # the CA servers upfront. 12 | # 2. Finch seems to always keep the connection open. Since we interact with CA very 13 | # infrequently, and in short burtsts, this is not what we want. 14 | # 15 | # A previous version of the client implemented a manual naive pool on top of Mint, but I wasn't 16 | # able to convince myself that it's reliable enough, and I didn't want to invest extra effort 17 | # in dealing with errors, reconnects, timeouts, and other pooling-related problems. 18 | # 19 | # While this approach is not super fast, it should be sufficient for the typical scenarios (a 20 | # couple of requests issued every few months). 21 | 22 | @type method :: :get | :head | :post | :put | :delete 23 | 24 | @type opts :: [ 25 | verify_server_cert: boolean, 26 | headers: Mint.Types.headers(), 27 | body: binary 28 | ] 29 | 30 | @type response :: %{status: Mint.Types.status(), headers: Mint.Types.headers(), body: binary} 31 | 32 | @spec request(method, String.t(), opts) :: response 33 | def request(method, url, opts \\ []) do 34 | uri = URI.parse(url) 35 | {scheme, http_opts} = parse_scheme(uri.scheme, opts) 36 | http_opts = Keyword.put(http_opts, :mode, :passive) 37 | 38 | {:ok, conn} = Mint.HTTP.connect(scheme, uri.host, uri.port, http_opts) 39 | 40 | try do 41 | method = String.upcase(to_string(method)) 42 | path = URI.to_string(%URI{path: uri.path, query: uri.query}) 43 | headers = Keyword.get(opts, :headers, []) 44 | body = Keyword.get(opts, :body) 45 | {:ok, conn, req} = Mint.HTTP.request(conn, method, path, headers, body) 46 | {response, conn} = get_response(conn, req) 47 | Mint.HTTP.close(conn) 48 | response 49 | after 50 | Mint.HTTP.close(conn) 51 | end 52 | end 53 | 54 | defp parse_scheme("http", _opts), do: {:http, []} 55 | defp parse_scheme("https", opts), do: {:https, [transport_opts: [verify: verify(opts)]]} 56 | 57 | defp verify(opts), 58 | do: if(Keyword.get(opts, :verify_server_cert, true), do: :verify_peer, else: :verify_none) 59 | 60 | defp get_response(conn, req, response \\ %{status: nil, headers: [], body: ""}) do 61 | {:ok, conn, responses} = Mint.HTTP.recv(conn, 0, :timer.minutes(1)) 62 | merge_responses(conn, req, response, responses) 63 | end 64 | 65 | defp merge_responses(conn, req, response, []), do: get_response(conn, req, response) 66 | 67 | defp merge_responses(conn, req, response, [{:status, req, status} | responses]), 68 | do: merge_responses(conn, req, Map.put(response, :status, status), responses) 69 | 70 | defp merge_responses(conn, req, response, [{:headers, req, headers} | responses]) do 71 | headers = Enum.map(headers, fn {key, val} -> {String.downcase(key), val} end) 72 | merge_responses(conn, req, Map.update!(response, :headers, &[&1, headers]), responses) 73 | end 74 | 75 | defp merge_responses(conn, req, response, [{:data, req, data} | responses]), 76 | do: merge_responses(conn, req, Map.update!(response, :body, &[&1, data]), responses) 77 | 78 | defp merge_responses(conn, req, response, [{:done, req}]) do 79 | {response 80 | |> Map.update!(:headers, &List.flatten/1) 81 | |> Map.update!(:body, &IO.iodata_to_binary/1), conn} 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/site_encrypt/phoenix/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Phoenix.Endpoint do 2 | @moduledoc """ 3 | `SiteEncrypt` adapter for Phoenix endpoints. 4 | 5 | ## Usage 6 | 7 | 1. Replace `use Phoenix.Endpoint` with `use SiteEncrypt.Phoenix.Endpoint` 8 | 2. Add the implementation of `c:SiteEncrypt.certification/0` to the endpoint (the 9 | `@behaviour SiteEncrypt` is injected when this module is used). 10 | 11 | See `__using__/1` for details. 12 | """ 13 | 14 | use SiteEncrypt.Adapter 15 | alias SiteEncrypt.Adapter 16 | 17 | @type start_opts :: [endpoint: module, endpoint_opts: Keyword.t()] 18 | 19 | @spec child_spec(start_opts) :: Supervisor.child_spec() 20 | 21 | @doc "Starts the endpoint managed by `SiteEncrypt`." 22 | @spec start_link(start_opts) :: Supervisor.on_start() 23 | def start_link(opts) do 24 | id = Keyword.fetch!(opts, :endpoint) 25 | Adapter.start_link(__MODULE__, id, opts) 26 | end 27 | 28 | @doc """ 29 | Turns the module into a Phoenix Endpoint certified by site_encrypt. 30 | 31 | This macro will add `use Phoenix.Endpoint` and `@behaviour SiteEncrypt` to the caller module. 32 | It will also provide the default implementation of `c:SiteEncrypt.handle_new_cert/0`. 33 | 34 | The macro accepts the following options: 35 | 36 | - `:otp_app` - Same as with `Phoenix.Endpoint`, specifies the otp_app running the endpoint. Any 37 | app env endpoint options must be placed under that app. 38 | - `:endpoint_opts` - Endpoint options which are deep merged on top of options defined in app 39 | config. 40 | 41 | The macro generates the `child_spec/1` function, so you can list your endpoint module as a 42 | supervisor child. In addition, you can pass additional endpoint options with `{MyEndpoint, opts}`, 43 | where `opts` is standard 44 | [Phoenix endpoint configuration](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#module-endpoint-configuration). 45 | 46 | The final endpoint config is assembled in the following order: 47 | 48 | 1. Options provided in config.exs and runtime.exs (via `config :my_app, MyEndpoint, [...]`) 49 | 2. Options provided via `use SiteEncrypt.Phoenix.Endpoint, endpoint_opts: [...]` 50 | 3. Options provided via `{MyEndpoint, opts}`. 51 | 52 | ## Overriding child_spec 53 | 54 | To provide config at runtime and embed it inside the endpoint module, you can override the 55 | `child_spec/1` function: 56 | 57 | defmodule MyEndpoint do 58 | use SiteEncrypt.Phoenix.Endpoint, otp_app: :my_app 59 | 60 | defoverridable child_spec: 1 61 | 62 | def child_spec(_arg) do 63 | # invoked at runtime, before the endpoint is first started 64 | 65 | # builds endpoint config at runtime 66 | endpoint_config = [ 67 | http: [...], 68 | https: [...], 69 | ... 70 | ] 71 | 72 | # Invokes the base implementation with the built config. This will be merged on top of 73 | # options provided via `use SiteEncrypt.Phoenix.Endpoint` and app config. 74 | super(endpoint_config) 75 | end 76 | 77 | ... 78 | end 79 | """ 80 | defmacro __using__(opts) do 81 | quote bind_quoted: [opts: opts] do 82 | {phoenix_using_opts, using_opts} = Keyword.split(opts, [:otp_app]) 83 | use Phoenix.Endpoint, phoenix_using_opts 84 | 85 | @behaviour SiteEncrypt 86 | require SiteEncrypt 87 | 88 | plug SiteEncrypt.AcmeChallenge, __MODULE__ 89 | 90 | @impl SiteEncrypt 91 | def handle_new_cert, do: :ok 92 | 93 | defoverridable handle_new_cert: 0 94 | 95 | @doc false 96 | def app_env_config, do: Application.get_env(@otp_app, __MODULE__, []) 97 | 98 | defoverridable child_spec: 1 99 | 100 | def child_spec(opts) do 101 | Supervisor.child_spec( 102 | { 103 | SiteEncrypt.Phoenix.Endpoint, 104 | unquote(using_opts) 105 | |> Config.Reader.merge(endpoint_opts: opts) 106 | |> Keyword.put(:endpoint, __MODULE__) 107 | }, 108 | [] 109 | ) 110 | end 111 | end 112 | end 113 | 114 | @impl Adapter 115 | def config(id, opts) do 116 | endpoint = Keyword.fetch!(opts, :endpoint) 117 | endpoint_opts = Keyword.get(opts, :endpoint_opts, []) 118 | 119 | %{ 120 | certification: endpoint.certification(), 121 | site_spec: %{ 122 | id: id, 123 | start: {__MODULE__, :start_endpoint, [id, endpoint, endpoint_opts]}, 124 | type: :supervisor 125 | } 126 | } 127 | end 128 | 129 | @doc false 130 | def start_endpoint(id, endpoint, endpoint_opts) do 131 | adapter = 132 | Keyword.get_lazy( 133 | # 1. Try to get adapter from opts passed to start_link 134 | endpoint_opts, 135 | :adapter, 136 | fn -> 137 | Keyword.get( 138 | # 2. Try to get adapter from app env 139 | endpoint.app_env_config(), 140 | :adapter, 141 | # 3. If adapter is not provided, default to cowboy 142 | Phoenix.Endpoint.Cowboy2Adapter 143 | ) 144 | end 145 | ) 146 | 147 | endpoint_opts = 148 | case adapter do 149 | Phoenix.Endpoint.Cowboy2Adapter -> 150 | Config.Reader.merge(endpoint_opts, https: SiteEncrypt.https_keys(id)) 151 | 152 | Bandit.PhoenixAdapter -> 153 | Config.Reader.merge(endpoint_opts, 154 | https: [thousand_island_options: [transport_options: SiteEncrypt.https_keys(id)]] 155 | ) 156 | end 157 | 158 | endpoint.start_link(endpoint_opts) 159 | end 160 | 161 | @impl Adapter 162 | def http_port(_id, opts) do 163 | endpoint = Keyword.fetch!(opts, :endpoint) 164 | 165 | if server?(endpoint) do 166 | http_config = endpoint.config(:http) 167 | 168 | with true <- Keyword.keyword?(http_config), 169 | port when is_integer(port) <- Keyword.get(http_config, :port) do 170 | {:ok, port} 171 | else 172 | _ -> 173 | raise_http_required(http_config) 174 | end 175 | else 176 | :error 177 | end 178 | end 179 | 180 | defp raise_http_required(http_config) do 181 | raise "Unable to retrieve HTTP port from the HTTP configuration. SiteEncrypt relies on the Lets Encrypt " <> 182 | "HTTP-01 challenge type which requires an HTTP version of the endpoint to be running and " <> 183 | "the configuration received did not include an http port.\n" <> 184 | "Received: #{inspect(http_config)}" 185 | end 186 | 187 | defp server?(endpoint) do 188 | endpoint.config(:server) || 189 | Application.get_env(:phoenix, :serve_endpoints, false) 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/site_encrypt/phoenix/test.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Phoenix.Test do 2 | @moduledoc """ 3 | Helper for testing the certification. 4 | 5 | ## Usage 6 | 7 | defmodule MyEndpoint.CertificationTest do 8 | use ExUnit.Case, async: false 9 | import SiteEncrypt.Phoenix.Test 10 | 11 | test "certification" do 12 | clean_restart(MyEndpoint) 13 | cert = get_cert(MyEndpoint) 14 | assert cert.domains == ~w/mysite.com www.mysite.com/ 15 | end 16 | end 17 | 18 | For this to work, you need to use the internal ACME server during tests. 19 | Refer to `SiteEncrypt.configure/1` for details. 20 | 21 | Also note that this test will restart the endpoint. In addition, it will configure Phoenix to 22 | serve the traffic. Therefore, make sure you pick a different set of ports in test, if you want 23 | to be able to run the tests while the system is started. 24 | 25 | Due to endpoint being restarted, the test case has to be marked as `async: false`. 26 | """ 27 | 28 | require X509.ASN1 29 | 30 | @doc """ 31 | Restarts the endpoint, removing all site_encrypt folders in the process. 32 | 33 | After the restart, the new certificate will be obtained. 34 | """ 35 | @spec clean_restart(module) :: :ok 36 | def clean_restart(endpoint) do 37 | SiteEncrypt.Adapter.restart_site(endpoint, fn -> 38 | ~w/db_folder backup/a 39 | |> Stream.map(&Map.fetch!(endpoint.certification(), &1)) 40 | |> Stream.reject(&is_nil/1) 41 | |> Enum.each(&File.rm_rf/1) 42 | 43 | app = Mix.Project.config() |> Keyword.fetch!(:app) 44 | endpoint_config = Application.get_env(app, endpoint, []) 45 | Application.put_env(app, endpoint, Keyword.put(endpoint_config, :server, true)) 46 | ExUnit.Callbacks.on_exit(fn -> Application.put_env(app, endpoint, endpoint_config) end) 47 | end) 48 | 49 | :ok = SiteEncrypt.force_certify(endpoint) 50 | end 51 | 52 | @doc """ 53 | Obtains the certificate for the given endpoint. 54 | 55 | The certificate is obtained by establishing an SSL connection. Therefore, for this function to 56 | work, the endpoint has to be serving traffic. This will happen if you previously invoked 57 | `clean_restart/1`. 58 | """ 59 | @spec get_cert(module) :: %{der: binary, issuer: String.t(), domains: [String.t()]} 60 | def get_cert(endpoint) do 61 | {:ok, socket} = 62 | :ssl.connect( 63 | ~c"localhost", 64 | https_port(endpoint), 65 | [{:verify, :verify_none}], 66 | :timer.seconds(5) 67 | ) 68 | 69 | {:ok, der_cert} = :ssl.peercert(socket) 70 | :ssl.close(socket) 71 | cert = X509.Certificate.from_der!(der_cert) 72 | 73 | domains = 74 | cert 75 | |> X509.Certificate.extension(:subject_alt_name) 76 | |> X509.ASN1.extension(:extnValue) 77 | |> Keyword.values() 78 | |> Enum.map(&to_string/1) 79 | 80 | [issuer] = cert |> X509.Certificate.issuer() |> X509.RDNSequence.get_attr("commonName") 81 | 82 | %{der: der_cert, domains: domains, issuer: issuer} 83 | end 84 | 85 | defp https_port(endpoint), do: Keyword.fetch!(endpoint.config(:https), :port) 86 | end 87 | -------------------------------------------------------------------------------- /lib/site_encrypt/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Registry do 2 | @moduledoc false 3 | 4 | def child_spec(_), 5 | do: Supervisor.child_spec({Registry, keys: :unique, name: __MODULE__}, id: __MODULE__) 6 | 7 | def root(id), do: {:via, Registry, {__MODULE__, id}} 8 | 9 | def store_config(id, config) do 10 | {_, _} = Registry.update_value(__MODULE__, id, fn _ -> config end) 11 | :ok 12 | end 13 | 14 | @spec config(SiteEncrypt.id()) :: SiteEncrypt.config() 15 | def config(id) do 16 | [{_pid, config}] = Registry.lookup(__MODULE__, id) 17 | config 18 | end 19 | 20 | @spec register_challenge(SiteEncrypt.id(), String.t(), String.t()) :: :ok 21 | def register_challenge(id, challenge_token, key_thumbprint) do 22 | Registry.register(__MODULE__, {id, {:challenge, challenge_token}}, key_thumbprint) 23 | :ok 24 | end 25 | 26 | @spec get_challenge(SiteEncrypt.id(), String.t()) :: {pid, String.t()} | nil 27 | def get_challenge(id, challenge_token) do 28 | case Registry.lookup(__MODULE__, {id, {:challenge, challenge_token}}) do 29 | [{pid, key_thumbprint}] -> 30 | send(pid, {:got_challenge, id, challenge_token}) 31 | key_thumbprint 32 | 33 | [] -> 34 | nil 35 | end 36 | end 37 | 38 | @spec await_challenges(SiteEncrypt.id(), [String.t()], non_neg_integer) :: boolean 39 | def await_challenges(_id, [], _timeout), do: true 40 | 41 | def await_challenges(id, [token | tokens], timeout) do 42 | start = System.monotonic_time() 43 | 44 | receive do 45 | {:got_challenge, ^id, ^token} -> 46 | time = System.convert_time_unit(System.monotonic_time() - start, :native, :millisecond) 47 | await_challenges(id, tokens, max(timeout - time, 0)) 48 | after 49 | timeout -> false 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.6.0" 5 | 6 | def project do 7 | [ 8 | app: :site_encrypt, 9 | version: @version, 10 | elixir: "~> 1.16", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | dialyzer: [ 14 | plt_add_deps: :app_tree, 15 | plt_add_apps: [:mix, :ex_unit], 16 | ignore_warnings: "dialyzer.ignore" 17 | ], 18 | docs: docs(), 19 | package: package() 20 | ] 21 | end 22 | 23 | def application do 24 | [ 25 | extra_applications: [:logger, :inets], 26 | mod: {SiteEncrypt.Application, []} 27 | ] 28 | end 29 | 30 | defp deps do 31 | [ 32 | {:bandit, "~> 0.7 or ~> 1.0", optional: true}, 33 | {:castore, "~> 0.1 or ~> 1.0"}, 34 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 35 | {:ex_doc, "~> 0.21", only: :dev, runtime: false}, 36 | {:jason, "~> 1.0"}, 37 | {:jose, "~> 1.10"}, 38 | {:mint, "~> 1.4"}, 39 | {:nimble_options, "~> 0.3 or ~> 1.0"}, 40 | {:parent, "~> 0.11"}, 41 | {:phoenix, "~> 1.5", optional: true}, 42 | {:plug_cowboy, "~> 2.5", optional: true}, 43 | {:plug, "~> 1.7", optional: true}, 44 | {:stream_data, "~> 0.1", only: [:dev, :test]}, 45 | {:x509, "~> 0.8.8"} 46 | ] 47 | end 48 | 49 | defp docs do 50 | [ 51 | main: "readme", 52 | extras: ["README.md", "CHANGELOG.md"], 53 | source_url: "https://github.com/sasa1977/site_encrypt/", 54 | source_ref: @version 55 | ] 56 | end 57 | 58 | defp package() do 59 | [ 60 | description: "Integrated certification via Let's encrypt for Elixir-powered sites", 61 | maintainers: ["Saša Jurić"], 62 | licenses: ["MIT"], 63 | links: %{ 64 | "Github" => "https://github.com/sasa1977/site_encrypt", 65 | "Changelog" => 66 | "https://github.com/sasa1977/site_encrypt/blob/#{@version}/CHANGELOG.md##{String.replace(@version, ".", "")}" 67 | } 68 | ] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.2.0", "2b5784909cc25b2514868055ff27458cdc63314514b90d86448ff91d18bece80", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "05688b883d87cc3b32991517a61e8c2ce8ee2dd6aa6eb73635426002a6661491"}, 3 | "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, 4 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, 11 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 12 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 13 | "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, 14 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.4", "29563475afa9b8a2add1b7a9c8fb68d06ca7737648f28398e04461f008b69521", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f4ed47ecda66de70dd817698a703f8816daa91272e7e45812469498614ae8b29"}, 17 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 18 | "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, 19 | "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 21 | "parent": {:hex, :parent, "0.12.1", "495c4386f06de0df492e0a7a7199c10323a55e9e933b27222060dd86dccd6d62", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2ab589ef1f37bfcedbfb5ecfbab93354972fb7391201b8907a866dadd20b39d1"}, 22 | "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, 23 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 24 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 25 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, 26 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.0", "3ae9369c60641084363b08fe90267cbdd316df57e3557ea522114b30b63256ea", [:mix], [{:cowboy, "~> 2.7.0 or ~> 2.8.0 or ~> 2.9.0 or ~> 2.10.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d85444fb8aa1f2fc62eabe83bbe387d81510d773886774ebdcb429b3da3c1a4a"}, 27 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 28 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 29 | "stream_data": {:hex, :stream_data, "0.6.0", "e87a9a79d7ec23d10ff83eb025141ef4915eeb09d4491f79e52f2562b73e5f47", [:mix], [], "hexpm", "b92b5031b650ca480ced047578f1d57ea6dd563f5b57464ad274718c9c29501c"}, 30 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 31 | "thousand_island": {:hex, :thousand_island, "1.3.2", "bc27f9afba6e1a676dd36507d42e429935a142cf5ee69b8e3f90bff1383943cd", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0e085b93012cd1057b378fce40cbfbf381ff6d957a382bfdd5eca1a98eec2535"}, 32 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 33 | "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, 34 | "x509": {:hex, :x509, "0.8.8", "aaf5e58b19a36a8e2c5c5cff0ad30f64eef5d9225f0fd98fb07912ee23f7aba3", [:mix], [], "hexpm", "ccc3bff61406e5bb6a63f06d549f3dba3a1bbb456d84517efaaa210d8a33750f"}, 35 | } 36 | -------------------------------------------------------------------------------- /test/pebble_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.PebbleTest do 2 | use ExUnit.Case, async: true 3 | import SiteEncrypt.Phoenix.Test 4 | alias __MODULE__.TestEndpoint 5 | 6 | @moduletag :pebble 7 | 8 | setup_all do 9 | start_supervised!(TestEndpoint) 10 | :ok 11 | end 12 | 13 | test "certification" do 14 | clean_restart(TestEndpoint) 15 | cert = get_cert(TestEndpoint) 16 | assert cert.issuer =~ "Pebble" 17 | end 18 | 19 | test "renewal" do 20 | clean_restart(TestEndpoint) 21 | first_cert = get_cert(TestEndpoint) 22 | 23 | assert SiteEncrypt.force_certify(TestEndpoint) == :ok 24 | 25 | second_cert = get_cert(TestEndpoint) 26 | refute second_cert == first_cert 27 | assert second_cert.issuer =~ "Pebble" 28 | end 29 | 30 | defmodule TestEndpoint do 31 | @moduledoc false 32 | 33 | use SiteEncrypt.Phoenix.Endpoint, 34 | otp_app: :site_encrypt, 35 | endpoint_opts: [ 36 | http: [port: 5002], 37 | https: [port: 5001], 38 | url: [scheme: "https", host: "localhost", port: 5001] 39 | ] 40 | 41 | @impl SiteEncrypt 42 | def certification do 43 | SiteEncrypt.configure( 44 | directory_url: "https://localhost:14000/dir", 45 | domains: ["localhost"], 46 | emails: ["admin@foo.bar"], 47 | db_folder: 48 | Application.app_dir( 49 | :site_encrypt, 50 | Path.join(["priv", "site_encrypt_pebble"]) 51 | ), 52 | backup: Path.join(System.tmp_dir!(), "site_encrypt_pebble_backup.tgz"), 53 | client: :native 54 | ) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/site_encrypt/certification/periodic_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SiteEncrypt.Certification.PeriodicTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | 5 | import SiteEncrypt.Phoenix.Test 6 | import StreamData 7 | import SiteEncrypt.Phoenix.Test 8 | 9 | alias __MODULE__.TestEndpoint 10 | alias SiteEncrypt.Certification.Periodic 11 | 12 | setup_all do 13 | start_supervised!(TestEndpoint) 14 | :ok 15 | end 16 | 17 | setup do 18 | clean_restart(TestEndpoint) 19 | end 20 | 21 | test "is scheduled for the desired date" do 22 | assert Date.diff(cert_valid_until(), renewal_date()) == 23 | config().days_to_renew 24 | end 25 | 26 | test "happens on the given date at target times" do 27 | first_cert = get_cert(TestEndpoint) 28 | config = SiteEncrypt.Registry.config(TestEndpoint) 29 | hour = config.periodic_offset.hour 30 | 31 | Enum.reduce( 32 | [hour, hour + 8, hour + 16], 33 | first_cert, 34 | fn hour, previous_cert -> 35 | {:ok, time} = 36 | Time.new( 37 | hour, 38 | config.periodic_offset.minute, 39 | config.periodic_offset.second 40 | ) 41 | 42 | {:ok, now} = NaiveDateTime.new(renewal_date(), time) 43 | now = DateTime.from_naive!(now, "Etc/UTC") 44 | assert Periodic.tick(TestEndpoint, now) == :ok 45 | new_cert = get_cert(TestEndpoint) 46 | assert new_cert != previous_cert 47 | new_cert 48 | end 49 | ) 50 | end 51 | 52 | test "may happen after the given date" do 53 | first_cert = get_cert(TestEndpoint) 54 | date = Date.add(renewal_date(), 1) 55 | config = SiteEncrypt.Registry.config(TestEndpoint) 56 | 57 | {:ok, time} = 58 | Time.new( 59 | config.periodic_offset.hour, 60 | config.periodic_offset.minute, 61 | config.periodic_offset.second 62 | ) 63 | 64 | {:ok, now} = NaiveDateTime.new(date, time) 65 | now = DateTime.from_naive!(now, "Etc/UTC") 66 | assert Periodic.tick(TestEndpoint, now) == :ok 67 | assert get_cert(TestEndpoint) != first_cert 68 | end 69 | 70 | test "doesn't happens before the given date" do 71 | first_cert = get_cert(TestEndpoint) 72 | date = Date.add(renewal_date(), -1) 73 | 74 | {:ok, time} = Time.new(0, 0, 0) 75 | {:ok, now} = NaiveDateTime.new(date, time) 76 | now = DateTime.from_naive!(now, "Etc/UTC") 77 | assert Periodic.tick(TestEndpoint, now) == {:error, :job_not_started} 78 | assert get_cert(TestEndpoint) == first_cert 79 | end 80 | 81 | property "doesn't happen outside of target times" do 82 | check all hour <- integer(0..23), 83 | minute <- integer(0..59), 84 | second <- integer(0..59), 85 | rem(hour, 8) != 0 or minute != 0 or second != 0 do 86 | {:ok, time} = Time.new(hour, minute, second) 87 | {:ok, now} = NaiveDateTime.new(renewal_date(), time) 88 | now = DateTime.from_naive!(now, "Etc/UTC") 89 | assert Periodic.tick(TestEndpoint, now) == {:error, :job_not_started} 90 | end 91 | end 92 | 93 | defp cert_valid_until, do: Periodic.cert_valid_until(config()) 94 | defp renewal_date, do: Periodic.renewal_date(config()) 95 | defp config, do: TestEndpoint.certification() 96 | 97 | defmodule TestEndpoint do 98 | @moduledoc false 99 | 100 | use SiteEncrypt.Phoenix.Endpoint, 101 | otp_app: :site_encrypt, 102 | endpoint_opts: [ 103 | http: [port: 4200], 104 | https: [port: 4201], 105 | url: [scheme: "https", host: "localhost", port: 4201] 106 | ] 107 | 108 | @impl SiteEncrypt 109 | def certification do 110 | SiteEncrypt.configure( 111 | directory_url: internal(), 112 | domains: ["localhost", "foo.localhost"], 113 | emails: ["admin@foo.bar"], 114 | db_folder: Application.app_dir(:site_encrypt, "priv") |> Path.join("periodic_test"), 115 | backup: Path.join(System.tmp_dir!(), "periodic_site_encrypt_backup.tgz"), 116 | client: :native 117 | ) 118 | end 119 | 120 | defp internal, do: {:internal, port: 4202} 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/site_encrypt_test.exs: -------------------------------------------------------------------------------- 1 | inputs = 2 | for adapter <- [Phoenix.Endpoint.Cowboy2Adapter, Bandit.PhoenixAdapter], 3 | client <- [:native, :certbot], 4 | do: %{adapter: adapter, client: client} 5 | 6 | for {input, index} <- Enum.with_index(inputs), 7 | input.client != :certbot or System.get_env("CI") == "true" do 8 | http_port = 40000 + 100 * index 9 | https_port = http_port + 1 10 | acme_server_port = http_port + 2 11 | 12 | defmodule Module.concat([ 13 | SiteEncrypt, 14 | input.adapter, 15 | "#{Macro.camelize(to_string(input.client))}Test" 16 | ]) do 17 | # Tests are sync because "backup and restore" fails in an async setting. 18 | # TODO: investigate why and either fix it or extract that test into a sync case. 19 | use ExUnit.Case, async: false 20 | use ExUnitProperties 21 | import SiteEncrypt.Phoenix.Test 22 | alias __MODULE__.TestEndpoint 23 | 24 | server_adapter = 25 | case input.adapter do 26 | Phoenix.Endpoint.Cowboy2Adapter -> :cowboy 27 | Bandit.PhoenixAdapter -> :bandit 28 | end 29 | 30 | setup_all do 31 | start_supervised!({ 32 | TestEndpoint, 33 | adapter: unquote(input.adapter), 34 | http: [port: unquote(http_port)], 35 | https: [port: unquote(https_port)], 36 | url: [scheme: "https", host: "localhost", port: unquote(https_port)] 37 | }) 38 | 39 | :ok 40 | end 41 | 42 | setup do 43 | TestEndpoint.clear_domains() 44 | clean_restart(TestEndpoint) 45 | end 46 | 47 | test "certification" do 48 | assert get_cert(TestEndpoint).domains == ~w/localhost foo.localhost/ 49 | end 50 | 51 | test "force_certify" do 52 | first_cert = get_cert(TestEndpoint) 53 | assert SiteEncrypt.force_certify(TestEndpoint) == :ok 54 | assert get_cert(TestEndpoint) != first_cert 55 | end 56 | 57 | test "new_cert" do 58 | first_cert = get_cert(TestEndpoint) 59 | assert {:ok, _pems} = SiteEncrypt.dry_certify(TestEndpoint) 60 | assert get_cert(TestEndpoint) == first_cert 61 | end 62 | 63 | # due to unsafe symlinks, restore doesn't work for certbot client on OTP 23+ 64 | if input.client != :certbot do 65 | test "backup and restore" do 66 | config = SiteEncrypt.Registry.config(TestEndpoint) 67 | first_cert = get_cert(TestEndpoint) 68 | assert File.exists?(config.backup) 69 | 70 | # remove db folder and restart the site 71 | SiteEncrypt.Adapter.restart_site(TestEndpoint, fn -> 72 | :ssl.clear_pem_cache() 73 | File.rm_rf!(config.db_folder) 74 | end) 75 | 76 | # make sure the cert is restored 77 | assert get_cert(TestEndpoint) == first_cert 78 | 79 | # make sure that renewal is still working correctly 80 | assert SiteEncrypt.force_certify(TestEndpoint) == :ok 81 | refute get_cert(TestEndpoint) == first_cert 82 | end 83 | end 84 | 85 | test "change configuration" do 86 | first_cert = get_cert(TestEndpoint) 87 | 88 | TestEndpoint.set_domains(TestEndpoint.domains() ++ ["bar.localhost"]) 89 | SiteEncrypt.Adapter.refresh_config(TestEndpoint) 90 | 91 | updated_config = SiteEncrypt.Registry.config(TestEndpoint) 92 | assert updated_config.domains == first_cert.domains ++ ["bar.localhost"] 93 | assert SiteEncrypt.certificate_subjects_changed?(updated_config) 94 | 95 | :ok = SiteEncrypt.force_certify(TestEndpoint) 96 | assert get_cert(TestEndpoint).domains == first_cert.domains ++ ["bar.localhost"] 97 | end 98 | 99 | defmodule TestEndpoint do 100 | @moduledoc false 101 | 102 | use SiteEncrypt.Phoenix.Endpoint, otp_app: :site_encrypt 103 | 104 | def domains, do: :persistent_term.get({__MODULE__, :domains}, ~w/localhost foo.localhost/) 105 | def set_domains(domains), do: :persistent_term.put({__MODULE__, :domains}, domains) 106 | def clear_domains, do: :persistent_term.erase({__MODULE__, :domains}) 107 | 108 | @impl SiteEncrypt 109 | def certification do 110 | SiteEncrypt.configure( 111 | directory_url: 112 | {:internal, port: unquote(acme_server_port), adapter: unquote(server_adapter)}, 113 | domains: domains(), 114 | emails: ["admin@foo.bar"], 115 | db_folder: 116 | Application.app_dir( 117 | :site_encrypt, 118 | Path.join([ 119 | "priv", 120 | "site_encrypt_#{unquote(input.client)}_#{unquote(input.adapter)}" 121 | ]) 122 | ), 123 | backup: 124 | Path.join(System.tmp_dir!(), "site_encrypt_#{unquote(input.client)}_backup.tgz"), 125 | client: unquote(input.client) 126 | ) 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ex_unit_opts = 2 | try do 3 | %{status: 200} = 4 | SiteEncrypt.HttpClient.request(:get, "https://localhost:14000/dir", 5 | verify_server_cert: false 6 | ) 7 | 8 | [] 9 | catch 10 | _, _ -> 11 | Mix.shell().info(""" 12 | To enable pebble tests, start the local container with the following command: 13 | 14 | docker run --rm -it -e "PEBBLE_VA_NOSLEEP=1" --net=host letsencrypt/pebble:v2.1.0 /usr/bin/pebble --strict 15 | """) 16 | 17 | [exclude: [:pebble]] 18 | end 19 | 20 | ExUnit.start(ex_unit_opts) 21 | Application.ensure_all_started(:ranch) 22 | Application.ensure_all_started(:bandit) 23 | Application.ensure_all_started(:phoenix) 24 | 25 | # Custom test translator which drops the verify_none warning log. 26 | Logger.add_translator({SiteEncrypt.Test.LoggerTranslator, :translate}) 27 | 28 | defmodule SiteEncrypt.Test.LoggerTranslator do 29 | def translate(_min_level, _level, _kind, message) do 30 | # This warning is emitted by the Erlang error logger. In local tests we're not validating 31 | # the peer, so we're dropping the warning. 32 | desc = 33 | ~c"Server authenticity is not verified since certificate path validation is not enabled" 34 | 35 | case message do 36 | {:logger, %{description: ^desc}} -> :skip 37 | _other -> :none 38 | end 39 | end 40 | end 41 | --------------------------------------------------------------------------------