├── LICENSE ├── NOTES.rst ├── README.rst ├── acme.lua ├── config.lua ├── docs ├── draft-ietf-acme-acme-12.txt ├── rfc8555.diff └── rfc8555.txt └── haproxy.cfg /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTES.rst: -------------------------------------------------------------------------------- 1 | Notes 2 | ===== 3 | 4 | A few development notes and tips for HAProxy Lua ACME implementation. 5 | 6 | RFCs 7 | ---- 8 | 9 | In addition to standard HTTP, JSON, TLS formats and protocols, knowledge of 10 | other protocols/formats was necessary to develop ACME client. 11 | 12 | * ACME protocol - https://tools.ietf.org/html/draft-ietf-acme-acme-09 13 | * JWS (JSON Web Signature) - https://tools.ietf.org/html/rfc7515 14 | * JWK (JSON Web Key) - https://tools.ietf.org/html/rfc7517 15 | 16 | Tools 17 | ----- 18 | 19 | Aside from `haproxy` and `curl`, additional software was used during 20 | development, for testing and debugging. 21 | 22 | Pebble 23 | ++++++ 24 | 25 | peble_ is a test ACME server with intented purpose to help with development of 26 | ACME clients. It uses slightly different ACME endpoints than referent ACME 27 | servers (all within specification), to force clients to properly implement 28 | endpoint discovery. It also presents some other transient challenges to clients 29 | (e.g. 15% of *nonces* are invalid), with the purpose of creating more robust 30 | client implementations. 31 | 32 | Server is a single binary, with configuration file in JSON format. By default 33 | it includes a test CA infrastructure, and creates server side certificates on 34 | the fly. There is also a simple acme client in the distribution, 35 | `peble-client`, which can be used as a reference/starting point other clients. 36 | 37 | Usage 38 | ~~~~~ 39 | After installing pebble, following their instructions, you can start it with: 40 | 41 | :: 42 | 43 | cd ~/go/src/github.com/letsencrypt/pebble 44 | ~/go/bin/pebble 45 | 46 | It loads the configuration and certificates from `test` subdirectory, and binds 47 | to port 14000 by default. 48 | 49 | 50 | Boulder 51 | +++++++ 52 | 53 | boulder_ is a reference ACME server, used by Let's Encrypt organization. One 54 | can run it locally, via Docker Compose, for easier testing and validation. 55 | 56 | MitmProxy 57 | +++++++++ 58 | 59 | ACME protocol v2 mandates that clients use TLS to access the ACME server. That 60 | means we can't easily track request flow from ACME client to server (e.g. to 61 | debug our own client, or to observe `peble-client` behaviour). Unfortunately, 62 | while `wireshark` can support/decrypt static SSL traffic, it doesn't support 63 | Diffie-Hellman key exchanges, so decrypting standard TLS traffic is impossible. 64 | 65 | mitmproxy_ is a TLS capable HTTP proxy for software developers and penetration 66 | testers. We can use it to observe traffic between local ACME client and local 67 | ACME server. 68 | 69 | Usage 70 | ~~~~~ 71 | 72 | When you run peble it will create server key and cert in 73 | `~/go/src/github.com/letsencrypt/pebble/test/certs/localhost`. You need to 74 | concat test key and cert into single file (`mitm.pem` in our example) 75 | 76 | :: 77 | 78 | sudo mitmweb -R https://localhost:14000 -p 443 --no-browser --insecure --cert localhost=~/go/src/github.com/letsencrypt/pebble/test/certs/localhost/mitm.pem 79 | Proxy server listening at http://0.0.0.0:443/ 80 | Web server listening at http://127.0.0.1:8081/ 81 | 82 | For the time being, it is necesarry to run mitmproxy with sudo, binding to port 83 | 443, even if we'd like to run it on some nonprivileged port, e.g. 8443 84 | 85 | :: 86 | 87 | sudo mitmweb -R https://localhost:14000 -p 8443 --no-browser --insecure --cert localhost=~/go/src/github.com/letsencrypt/pebble/test/certs/localhost/mitm.pem 88 | Proxy server listening at http://0.0.0.0:443/ 89 | Web server listening at http://127.0.0.1:8081/ 90 | 91 | When we run discovery query against the ACME server (through mitmproxy), we 92 | get this: 93 | 94 | :: 95 | 96 | curl -k https://localhost:8443/dir 97 | { 98 | "meta": { 99 | "termsOfService": "data:text/plain,Do%20what%20thou%20wilt" 100 | }, 101 | "newAccount": "https://localhost/sign-me-up", 102 | "newNonce": "https://localhost/nonce-plz", 103 | "newOrder": "https://localhost/order-plz" 104 | } 105 | 106 | were we'd expect to get 107 | 108 | :: 109 | 110 | { 111 | "meta": { 112 | "termsOfService": "data:text/plain,Do%20what%20thou%20wilt" 113 | }, 114 | "newAccount": "https://localhost:8443/sign-me-up", 115 | "newNonce": "https://localhost:8443/nonce-plz", 116 | "newOrder": "https://localhost:8443/order-plz" 117 | } 118 | 119 | mitmproxy doesn't pass full host info to the upstream, i.e. it doesn't respect 120 | HTTP 1.1 RFC (https://tools.ietf.org/html/rfc7230#section-5.4), where `Host` 121 | header **must** include hostname and port if port is not 80 (for http) or 443 122 | (for https). Hence, we force mitmproxy to run on standard HTTPS port, where 123 | explicit port is not required. 124 | 125 | .. _peble: https://github.com/letsencrypt/pebble 126 | .. _boulder: https://github.com/letsencrypt/boulder 127 | .. _mitmproxy: https://mitmproxy.org 128 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | HAProxy ACME v2 client 2 | ====================== 3 | 4 | Deprecated 5 | ---------- 6 | 7 | This project is not maintained anymore. It is recommended to switch to acme.sh instead: 8 | https://github.com/haproxy/wiki/wiki/Letsencrypt-integration-with-HAProxy-and-acme.sh 9 | 10 | Important notice 11 | ---------------- 12 | Beware, the fixes to support for ACME v2 protocol were recently merged, there 13 | might be some sharp edges but it should work. 14 | 15 | This is a client implementation for ACME (Automatic Certificate Management 16 | Environment) protocol, currently draft IETF standard 17 | (https://tools.ietf.org/html/draft-ietf-acme-acme-12) 18 | 19 | The protocol will be supported by Let's Encrypt project from March 2018. 20 | and it is expected that other *Certificate Authorities* will support this 21 | ACME version in the future. 22 | 23 | Intro 24 | ----- 25 | The main idea of this ACME client is to implement as much functionality inside 26 | HAProxy. In addition to supporting single instance HAProxy installations, we 27 | also aim to support multi-instance deployments (i.e. you have a cluster of load 28 | balancers on which you want to use ACME issued certs). 29 | 30 | By using the internal HTTP interface (and http client such as `curl`), you will 31 | be able to execute the following: 32 | 33 | - Upload your own account and domain keys (only RSA keys for now) 34 | - Automatically register your account on ACME servers (linked to your account 35 | key) 36 | - Request and receive certificates for your domains 37 | 38 | The only thing you need to do on your own is to save the received certificate 39 | bundles and reload HAProxy. 40 | 41 | 42 | Requirements 43 | ------------ 44 | 45 | * A modern HAProxy version (v1.8) with Lua support (check with 46 | ``haproxy -vv | grep USE_LUA=1``) 47 | * `haproxy-lua-http`_ - Lua HTTP server/client for HAProxy Lua host 48 | * `json.lua`_ - Lua JSON library 49 | * `luaossl`_ - OpenSSL bindings for Lua 50 | 51 | 52 | Configuration 53 | ------------- 54 | 55 | Install the required Lua libraries to proper LUA_PATH location, and configure 56 | haproxy as follows: 57 | 58 | :: 59 | 60 | global 61 | log /dev/log local0 debug 62 | nbproc 1 63 | daemon 64 | lua-load config.lua 65 | lua-load acme.lua 66 | 67 | defaults 68 | log global 69 | mode http 70 | option httplog 71 | timeout connect 5s 72 | timeout client 10s 73 | timeout server 10s 74 | 75 | listen http 76 | bind *:80 77 | http-request use-service lua.acme if { path_beg /.well-known/acme-challenge/ } 78 | 79 | listen acme 80 | bind 127.0.0.1:9011 81 | http-request use-service lua.acme 82 | 83 | listen acme-ca 84 | bind 127.0.0.1:9012 85 | server ca acme-v02.api.letsencrypt.org:443 ssl verify required ca-file letsencrypt-x3-ca-chain.pem 86 | http-request set-header Host acme-v02.api.letsencrypt.org 87 | 88 | ``letsencrypt-x3-ca-chain.pem`` is the concatenation of the active root certificate and intermediate certificate in one pem file, available here : https://letsencrypt.org/certificates/ 89 | 90 | Configuration is kept in a separate Lua file, where you must explicitly set 91 | ``termsOfServiceAgreed`` option to ``true`` in order to be able to acquire 92 | certs. Before doing that, please read latest Let's Encrypt terms of service and 93 | subscriber agreement available at https://letsencrypt.org/repository/ 94 | 95 | :: 96 | 97 | config = { 98 | registration = { 99 | -- You can read TOS here: https://letsencrypt.org/repository/ 100 | termsOfServiceAgreed = false, 101 | contact = {"mailto:postmaster@example.net"} 102 | }, 103 | 104 | -- ACME certificate authority configuration 105 | ca = { 106 | -- HAProxy backend/server which proxies requests to ACME server 107 | proxy_uri = "http://127.0.0.1:9012", 108 | -- ACME server URI (also returned by ACME directory listings) 109 | -- Use this server name in HAProxy config 110 | uri = "https://acme-v02.api.letsencrypt.org", 111 | } 112 | } 113 | 114 | Key creation 115 | ------------ 116 | 117 | Although Lua module is able to create account key or domain automatically, for 118 | performance and security reasons we require that you create your keys 119 | separately. 120 | 121 | Currently, we only support RSA keys. For account key, key size should be 122 | 4096bits, and for domain key 2048bits (minimal key sizes are also enforced by 123 | Let's Encrypt). 124 | 125 | You can use the following commands to create keys. Note that you need a modern 126 | openssl version, we don't use ``openssl genrsa`` but ``openssl genpkey``, as 127 | we're going to use the same command to create ECDSA keys in the future. 128 | 129 | :: 130 | 131 | openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out account.key 132 | openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out example.net.key 133 | 134 | 135 | Usage 136 | ----- 137 | 138 | After you have provisioned your keys, you can run certificate order via HTTP. 139 | For example by using curl to POST data in *multipart/form-data* format: 140 | 141 | :: 142 | 143 | curl -XPOST http://127.0.0.1:9011/acme/order -F 'account_key=@account.key' \ 144 | -F 'domain=example.net' -F 'domain_key=@example.net.key' \ 145 | -F 'aliases=www.example.net,example.com,www.example.com' \ 146 | -o example.net.pem 147 | 148 | Aliases are optional, and we use curl ``@`` syntax to post files. 149 | The output is full certificate chain (with key appended), suitable for direct 150 | consumption by HAProxy. 151 | 152 | .. _`haproxy-lua-http`: https://github.com/haproxytech/haproxy-lua-http 153 | .. _`json.lua`: https://github.com/rxi/json.lua 154 | .. _`luaossl`: https://github.com/wahern/luaossl 155 | -------------------------------------------------------------------------------- /acme.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- acme.lua 3 | -- 4 | -- ACME v2 protocol client implementation for HAProxy Lua host 5 | -- 6 | -- ACME RFC: 7 | -- https://tools.ietf.org/html/draft-ietf-acme-acme-12 8 | -- 9 | -- Copyright (c) 2017-2020. Adis Nezirovic 10 | -- Copyright (c) 2017-2020. HAProxy Technologies, LLC. 11 | -- 12 | -- Licensed under the Apache License, Version 2.0 (the "License"); 13 | -- you may not use this file except in compliance with the License. 14 | -- You may obtain a copy of the License at 15 | -- 16 | -- http://www.apache.org/licenses/LICENSE-2.0 17 | -- 18 | -- Unless required by applicable law or agreed to in writing, software 19 | -- distributed under the License is distributed on an "AS IS" BASIS, 20 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 | -- See the License for the specific language governing permissions and 22 | -- limitations under the License. 23 | -- 24 | -- SPDX-License-Identifier: Apache-2.0 25 | 26 | local _author = "Adis Nezirovic " 27 | local _copyright = "Copyright 2017-2020. HAProxy Technologies, LLC." 28 | local _version = "1.0.0" 29 | 30 | local http = require "http" 31 | local json = require "json" 32 | 33 | local openssl = { 34 | pkey = require "openssl.pkey", 35 | x509 = require "openssl.x509", 36 | name = require "openssl.x509.name", 37 | altname = require "openssl.x509.altname", 38 | csr = require "openssl.x509.csr", 39 | digest = require "openssl.digest" 40 | } 41 | 42 | if not config then 43 | config = { 44 | -- ACME certificate authority configuration 45 | ca = { 46 | -- HAProxy backend which proxies requests to ACME server 47 | proxy_uri = "http://127.0.0.1:9012", 48 | -- ACME server URI (also returned by ACME directory listings) 49 | uri = "https://acme-v02.api.letsencrypt.org" 50 | }, 51 | 52 | registration = { 53 | termsOfServiceAgreed = false, 54 | contact = {"mailto:user@example.net"} 55 | } 56 | } 57 | end 58 | 59 | -- Storage area for serving challanges to ACME server 60 | local http_challenges = {} 61 | 62 | local ACME = {} 63 | ACME.__index = ACME 64 | 65 | function ACME.create(conf) 66 | local self = setmetatable({}, ACME) 67 | 68 | self.conf = conf or {} 69 | 70 | -- ACME resources, only 'directory' resource should be known in advance 71 | self.resources = nil 72 | 73 | local resp 74 | local err 75 | 76 | resp, err = self:get{url=config.ca.proxy_uri .. "/directory"} 77 | 78 | if resp and resp.status_code == 200 and resp.headers["content-type"] 79 | and resp.headers["content-type"]:match("application/json") then 80 | self.resources = json.decode(resp.content) 81 | self.nonce = resp.headers["replay-nonce"] 82 | end 83 | 84 | if not self.resources then 85 | return nil, err 86 | end 87 | 88 | assert(self.resources["newNonce"]) 89 | assert(self.resources["newAccount"]) 90 | assert(self.resources["newOrder"]) 91 | assert(self.resources["revokeCert"]) 92 | 93 | self.account = { 94 | key = nil, 95 | kid = nil 96 | } 97 | 98 | return self 99 | end 100 | 101 | --- Adapt resource URLs when going through HAProxy 102 | function ACME.proxy_url(self, url) 103 | if url:sub(1, #self.conf.ca.uri) == self.conf.ca.uri then 104 | return string.format("%s%s", self.conf.ca.proxy_uri, url:sub(#self.conf.ca.uri + 1)) 105 | else 106 | return url 107 | end 108 | end 109 | 110 | --- ACME wrapper for http.get() 111 | -- 112 | -- @param resource ACME resource type 113 | -- @param url Valid HTTP url (mandatory)G 114 | -- 115 | -- @return Response object or tuple (nil, msg) on errors 116 | function ACME.get(self, t) 117 | local resp, err = http.get{url=self:proxy_url(t.url)} 118 | 119 | if (not t.retry or t.retry < 5) and (not resp or (resp and resp.status_code == 503)) then 120 | t.retry = not t.retry and 1 or t.retry + 1 121 | 122 | return self:get(t) 123 | end 124 | 125 | return resp, err 126 | end 127 | 128 | --- ACME wrapper for http.post() 129 | -- 130 | -- @param resource ACME resource type 131 | -- @param url Valid HTTP url (mandatory)G 132 | -- @param headers Lua table with request headers 133 | -- @param data Request content 134 | -- 135 | -- @return Response object or tuple (nil, msg) on errors 136 | function ACME.post(self, t) 137 | local jws, err = self:jws{resource=t.resource, url=t.url, payload=t.data} 138 | 139 | if not jws then 140 | return nil, err 141 | end 142 | 143 | if not t.headers then 144 | t.headers = { 145 | ["content-type"] = "application/jose+json" 146 | } 147 | elseif not t.headers["content-type"] then 148 | t.headers["content-type"] = "application/jose+json" 149 | end 150 | 151 | local resp, err = http.post{url=self:proxy_url(t.url), data=jws, 152 | headers=t.headers, timeout=t.timeout} 153 | 154 | if resp and resp.headers then 155 | self.nonce = resp.headers["replay-nonce"] 156 | 157 | if resp.status_code == 400 then 158 | local info = resp:json() 159 | 160 | if info and info.type == "urn:ietf:params:acme:error:badNonce" then 161 | 162 | -- We need to retry once more with new nonce (hence new jws) 163 | jws, err = self:jws{resource=t.resource, url=t.url, 164 | payload=t.data} 165 | if not jws then 166 | return nil, err 167 | end 168 | 169 | resp, err = self:post{url=self:proxy_url(t.url), data=jws, 170 | headers=t.headers} 171 | end 172 | end 173 | end 174 | 175 | if (not t.retry or t.retry < 5) and (not resp or (resp and resp.status_code == 503)) then 176 | t.retry = not t.retry and 1 or t.retry + 1 177 | 178 | return self:post(t) 179 | end 180 | 181 | return resp, err 182 | end 183 | 184 | --- ACME wrapper for POST-as-GET 185 | -- 186 | -- @param resource ACME resource type 187 | -- @param url Valid HTTP url (mandatory)G 188 | -- @param headers Lua table with request headers 189 | -- @param data Request content 190 | -- 191 | -- @return Response object or tuple (nil, msg) on errors 192 | function ACME.postAsGet(self, t) 193 | local jws, err = self:jws{url=t.url, payload=nil} 194 | 195 | if not jws then 196 | return nil, err 197 | end 198 | 199 | if not t.headers then 200 | t.headers = { 201 | ["content-type"] = "application/jose+json" 202 | } 203 | elseif not t.headers["content-type"] then 204 | t.headers["content-type"] = "application/jose+json" 205 | end 206 | 207 | local resp, err = http.post{url=self:proxy_url(t.url), data=jws, 208 | headers=t.headers, timeout=t.timeout} 209 | 210 | if resp and resp.headers then 211 | self.nonce = resp.headers["replay-nonce"] 212 | 213 | if resp.status_code == 400 then 214 | local info = resp:json() 215 | 216 | if info and info.type == "urn:ietf:params:acme:error:badNonce" then 217 | 218 | -- We need to retry once more with new nonce (hence new jws) 219 | jws, err = self:jws{resource=t.resource, url=t.url, 220 | payload=""} 221 | if not jws then 222 | return nil, err 223 | end 224 | 225 | resp, err = self:post{url=self:proxy_url(t.url), data=jws, 226 | headers=t.headers} 227 | end 228 | end 229 | end 230 | 231 | if (not t.retry or t.retry < 5) and (not resp or (resp and resp.status_code == 503)) then 232 | t.retry = not t.retry and 1 or t.retry + 1 233 | 234 | return self:postAsGet(t) 235 | end 236 | 237 | return resp, err 238 | end 239 | 240 | --- Return the ACME nonce 241 | -- 242 | -- Nonce is volatile, if nonce is not present (i.e.from previous request), 243 | -- a fresh nonce is requested from the ACME server, otherwise, the stored 244 | -- nonce is returned (and deleted) 245 | -- 246 | function ACME.refresh_nonce(self) 247 | local nonce = self.nonce 248 | self.nonce = nil 249 | if nonce then return nonce end 250 | 251 | local resp, e = http.head{url=self:proxy_url(self.resources["newNonce"])} 252 | 253 | if resp and resp.headers then 254 | -- TODO: Expect status code 204 255 | -- TODO: Expect Cache-Control: no-store 256 | -- TODO: Expect content size 0 257 | return resp.headers["replay-nonce"] 258 | else 259 | return nil, e 260 | end 261 | end 262 | 263 | --- Enclose the provided payload in JWS 264 | -- 265 | -- @param url URL 266 | -- @param resource ACME resource type 267 | -- @param payload (json) data which will be wrapped in JWS 268 | function ACME.jws(self, t) 269 | if not self.account or not self.account.key then 270 | return nil, "ACME.jws: Account key does not exist." 271 | end 272 | 273 | if not t or not t.url then 274 | return nil, 275 | "ACME.jws: Missing one or more parameters (url)" 276 | end 277 | 278 | -- if key:type() == rsaEncryption 279 | local params = self.account.key:getParameters() 280 | if not params then 281 | return nil, "ACME.jws: Could not extract account key parameters." 282 | end 283 | 284 | local jws = { 285 | protected = { 286 | alg = "RS256", 287 | nonce = self:refresh_nonce(), 288 | url = t.url 289 | }, 290 | payload = t.payload 291 | } 292 | 293 | if t.resource == "newAccount" then 294 | -- if self.account.key:type() == "rsaEncryption" then 295 | jws.protected.jwk = { 296 | e = http.base64.encode(params.e:toBinary(), base64enc), 297 | kty = "RSA", 298 | n = http.base64.encode(params.n:toBinary(), base64enc) 299 | } 300 | 301 | local jwk_ordered = string.format('{"e":"%s","kty":"%s","n":"%s"}', 302 | jws.protected.jwk.e, 303 | jws.protected.jwk.kty, 304 | jws.protected.jwk.n) 305 | local tdigest = openssl.digest.new("SHA256"):final(jwk_ordered) 306 | self.account.thumbprint = http.base64.encode(tdigest, base64enc) 307 | else 308 | jws.protected.kid = self.account.kid 309 | end 310 | 311 | jws.protected = http.base64.encode(json.encode(jws.protected), base64enc) 312 | jws.payload = t.payload and http.base64.encode(json.encode(t.payload), base64enc) or "" 313 | local digest = openssl.digest.new("SHA256") 314 | digest:update(jws.protected .. "." .. jws.payload) 315 | jws.signature = http.base64.encode(self.account.key:sign(digest), base64enc) 316 | 317 | return json.encode(jws) 318 | end 319 | 320 | function ACME.register(self) 321 | if not self.account then 322 | return nil, 'No account key' 323 | end 324 | 325 | local resp, err = self:post{url=self.resources["newAccount"], 326 | data=self.conf.registration, 327 | resource="newAccount"} 328 | 329 | if not resp then 330 | return nil, err 331 | end 332 | 333 | self.account.kid = resp.headers['location'] 334 | 335 | return resp 336 | end 337 | 338 | local function new_order(applet) 339 | local acme, err = ACME.create(config) 340 | 341 | if not acme then 342 | return http.response.create{status_code=500, content=err}:send(applet) 343 | end 344 | 345 | function base64enc(s) 346 | return applet.c:base64(s) 347 | end 348 | 349 | function base64dec(s) 350 | -- Depends on HAProxy v1.8 351 | return applet.c:b64dec(s) 352 | end 353 | 354 | local r = http.request.parse(applet) 355 | 356 | if not (r and r.data) then 357 | return http.response.create{status_code=400, content=err}:send(applet) 358 | end 359 | 360 | local form, err = r:parse_multipart() 361 | 362 | if not form then 363 | return http.response.create{status_code=400, content=err}:send(applet) 364 | end 365 | 366 | if not (form.account_key and form.domain_key and form.domain) then 367 | local err = 'Missing one of mandatory form fields: account_key, domain, domain_key' 368 | return http.response.create{status_code=400, content=err}:send(applet) 369 | end 370 | 371 | acme.account = { 372 | key = openssl.pkey.new(form.account_key.data or form.account_key) 373 | } 374 | 375 | local resp, err = acme:register() 376 | 377 | if not resp then 378 | return http.response.create{status_code=500, data=err}:send(applet) 379 | elseif resp.status_code ~= 200 and resp.status_code ~= 201 then 380 | return resp:send(applet) 381 | end 382 | 383 | local aliases = {} 384 | if form.aliases then 385 | aliases = core.tokenize(form.aliases, ",") 386 | end 387 | 388 | local order_payload = { 389 | identifiers = { 390 | [1] = { 391 | type = "dns", 392 | value = form.domain 393 | } 394 | } 395 | } 396 | 397 | for idx, alias in pairs(aliases) do 398 | order_payload.identifiers[idx+1] = {type = "dns", value = alias} 399 | end 400 | 401 | -- Place new order 402 | local order, err = acme:post{url=acme.resources["newOrder"], data=order_payload, 403 | resource="newOrder"} 404 | if not order then 405 | return http.response.create{status_code=500, data=err}:send(applet) 406 | elseif order.status_code ~= 201 then 407 | return order:send(applet) 408 | end 409 | 410 | local order_json = order:json() 411 | local challenge_token 412 | 413 | for _, auth in ipairs(order_json.authorizations) do 414 | -- 415 | local auth_payload = { 416 | keyAuthorization = nil 417 | } 418 | 419 | -- Get auth token 420 | local auth, err = acme:postAsGet{url=auth} 421 | 422 | if auth then 423 | local auth_json = auth:json() 424 | 425 | for _, ch in ipairs(auth_json.challenges) do 426 | if ch.type == "http-01" then 427 | http_challenges[ch.token] = string.format("%s.%s", 428 | ch.token, acme.account.thumbprint) 429 | resp, err = acme:post{url=ch.url, data=ch, 430 | resource="challengeDone"} 431 | challenge_token = ch.token 432 | break 433 | end 434 | end 435 | end 436 | end 437 | 438 | -- Wait until the order is ready 439 | local order_status 440 | for _, t in pairs({1, 1, 2, 3, 5, 8, 13}) do 441 | core.sleep(t) 442 | local resp, err = acme:postAsGet{url=order.headers["location"]} 443 | 444 | if resp then 445 | order_status = resp:json() 446 | if order_status.status == "ready" then 447 | break 448 | elseif order_status.status == "invalid" then 449 | return resp:send(applet) 450 | end 451 | end 452 | end 453 | 454 | if not order_status then 455 | return http.response.create{status_code=500, 456 | content="Could not get order status"}:send(applet) 457 | end 458 | 459 | if order_status.status ~= "ready" then 460 | return http.response.create{status_code=500, content=order_status}:send(applet) 461 | end 462 | 463 | if challenge_token and http_challenges[challenge_token] then 464 | http_challenges[challenge_token] = nil 465 | end 466 | 467 | -- CSR creation 468 | local dn = openssl.name.new() 469 | dn:add("CN", form.domain) 470 | 471 | local alt = openssl.altname.new() 472 | alt:add("DNS", form.domain) 473 | 474 | for _, alias in pairs(aliases) do 475 | alt:add("DNS", alias) 476 | end 477 | 478 | local csr = openssl.csr.new() 479 | csr:setSubject(dn) 480 | csr:setSubjectAlt(alt) 481 | 482 | local key = openssl.pkey.new(form.domain_key.data or form.domain_key) 483 | csr:setPublicKey(key) 484 | csr:sign(key) 485 | local payload = { 486 | csr = http.base64.encode(csr:tostring("DER"), base64enc) 487 | } 488 | 489 | resp, err = acme:post{url=order_json.finalize, data=payload, 490 | resource="finalizeOrder"} 491 | 492 | if resp and resp.status_code == 200 then 493 | local resp_json = resp:json() 494 | 495 | if not resp.headers["content-type"] == "application/pem-certificate-chain" then 496 | return http.response.create{status_code=500, 497 | content="Wrong content type"}:send(applet) 498 | end 499 | 500 | if not resp_json.certificate then 501 | return http.response.create{status_code=500, content="No cert"}:send(applet) 502 | end 503 | 504 | local resp, err = acme:postAsGet{url=resp_json.certificate} 505 | local bundle = string.format("%s%s", resp.content, key:toPEM("private")) 506 | return http.response.create{status_code=200, content=bundle}:send(applet) 507 | else 508 | return resp:send(applet) 509 | end 510 | end 511 | 512 | local function acme_challenge(applet) 513 | local p = core.tokenize(applet.path, "/", true) 514 | if not p[3] or not http_challenges[p[3]] then 515 | return http.response.create{status_code=404}:send(applet) 516 | end 517 | http.response.create{status_code=200, content=http_challenges[p[3]]}:send(applet) 518 | end 519 | 520 | --- Request handler/router 521 | -- 522 | -- 523 | local function request_handler(applet) 524 | local p = core.tokenize(applet.path, "/", true) 525 | local m = applet.method 526 | local h = { 527 | acme = { 528 | order = { 529 | POST = new_order 530 | } 531 | }, 532 | [".well-known"] = { 533 | ["acme-challenge"] = { 534 | GET = acme_challenge 535 | } 536 | } 537 | } 538 | 539 | if h[p[1]] and h[p[1]][p[2]] and h[p[1]][p[2]][m] then 540 | return h[p[1]][p[2]][m] 541 | end 542 | 543 | return nil 544 | end 545 | 546 | local function main(applet) 547 | local handler = request_handler(applet) 548 | 549 | if not handler then 550 | http.response.create{status_code=404}:send(applet) 551 | else 552 | handler(applet) 553 | end 554 | end 555 | 556 | core.register_service("acme", "http", main) -------------------------------------------------------------------------------- /config.lua: -------------------------------------------------------------------------------- 1 | config = { 2 | registration = { 3 | termsOfServiceAgreed = true, 4 | contact = {"mailto:postmaster@example.net"} 5 | }, 6 | 7 | -- ACME certificate authority configuration 8 | ca = { 9 | -- HAProxy backend which proxies requests to ACME server 10 | proxy_uri = "http://127.0.0.1:9012", 11 | -- ACME server URI (also returned by ACME directory listings) 12 | uri = "https://acme-v02.api.letsencrypt.org" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /haproxy.cfg: -------------------------------------------------------------------------------- 1 | global 2 | log /dev/log local0 debug 3 | daemon 4 | lua-load config.lua 5 | lua-load acme.lua 6 | 7 | defaults 8 | log global 9 | mode http 10 | option httplog 11 | timeout connect 5s 12 | timeout client 10s 13 | timeout server 10s 14 | 15 | listen http 16 | bind *:5002 17 | http-request use-service lua.acme if { path_beg /.well-known/acme-challenge/ } 18 | 19 | listen acme 20 | bind 127.0.0.1:9011 21 | http-request use-service lua.acme 22 | 23 | listen acme-ca 24 | bind 127.0.0.1:9012 25 | # server ca acme-v02.api.letsencrypt.org:443 ssl verify none 26 | server ca 127.0.0.1:4431 ssl verify none 27 | http-request set-header Host acme-v02.api.letsencrypt.org 28 | --------------------------------------------------------------------------------