generated from nginxinc/template-repository
-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
README.md rework and docker-compose volume (#20)
* Reorganize README.md a bit with a focus on users; Put named volume for certs back in docker-compose * mention minimum njs version
- Loading branch information
1 parent
333dc46
commit 9c97edc
Showing
3 changed files
with
43 additions
and
181 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,15 @@ | ||
# njs-acme | ||
|
||
This repository provides a JavaScript library to work with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) providers such as Let's Encrypt for **NJS**. The source code is compatible with the `ngx_http_js_module` runtime. This allows for the automatic issue of TLS/SSL certificates for NGINX. | ||
This repository provides a JavaScript library to work with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) providers such as Let's Encrypt for [NJS](https://nginx.org/en/docs/njs/). The source code is compatible with the `ngx_http_js_module` runtime. This allows for the automatic issue of TLS/SSL certificates for NGINX. | ||
|
||
Some ACME providers have strict rate limits. Please consult with your provider. For Let's Encrypt refer to [this](https://letsencrypt.org/docs/rate-limits/) rate-limits documentation. | ||
Requires at least `njs-0.7.12`, which is included with NGINX since nginx-1.24.0. | ||
|
||
This project uses Babel and Rollup to compile TypeScript sources into a single JavaScript file for `njs`. It uses Mocha with nginx-testing for running integration tests against the NGINX server. This project uses [njs-typescript-starter](https://github.com/jirutka/njs-typescript-starter/tree/master) to write NJS modules and integration tests in TypeScript. | ||
|
||
The ACME RESTful client is implemented using [ngx.fetch](http://nginx.org/en/docs/njs/reference.html#ngx_fetch), [crypto API](http://nginx.org/en/docs/njs/reference.html#builtin_crypto), [PKI.js](https://pkijs.org/) APIs in NJS runtime. | ||
NOTE: Some ACME providers have strict rate limits. Please consult with your provider. For Let's Encrypt refer to their [rate-limits documentation](https://letsencrypt.org/docs/rate-limits/). | ||
|
||
|
||
## Configuration Variables | ||
|
||
You can use environment variables or NGINX configuration variables to control the behavior of the NJS ACME client. In the case where both are defined, environment variables are preferred. Environment variables are in `ALL_CAPS`, whereas the nginx config variable is the same name, just `lower_case`. | ||
You can use environment variables or NGINX configuration variables to control the behavior of the NJS ACME client. In the case where both are defined, environment variables take precedence. Environment variables are in `ALL_CAPS`, whereas the nginx config variable is the same name, just `lower_case`. | ||
|
||
### Required Variables | ||
|
||
|
@@ -27,7 +25,7 @@ You can use environment variables or NGINX configuration variables to control th | |
|
||
### Optional Variables | ||
- `NJS_ACME_VERIFY_PROVIDER_HTTPS`\ | ||
Verifies the ACME provider SSL certificate when connecting.\ | ||
Verify the ACME provider certificate when connecting.\ | ||
value: `false` | `true`\ | ||
default: `true` | ||
|
||
|
@@ -54,7 +52,7 @@ You can use environment variables or NGINX configuration variables to control th | |
|
||
## NGINX Configuration | ||
|
||
There are a few pieces that are required to be present in your `nginx.conf` file. The file at `examples/nginx.conf` shows them all. | ||
There are a few pieces that are required to be present in your `nginx.conf` file. The file at [`examples/nginx.conf`](./examples/nginx.conf) shows them all. | ||
|
||
### Config Root | ||
* Ensures the NJS module is loaded. | ||
|
@@ -63,7 +61,7 @@ There are a few pieces that are required to be present in your `nginx.conf` file | |
``` | ||
|
||
### `http` Section | ||
* Adds our module directory to the search path. | ||
* Adds the NJS module directory to the search path. | ||
```nginx | ||
js_path "/usr/lib/nginx/njs_modules/"; | ||
``` | ||
|
@@ -82,12 +80,12 @@ There are a few pieces that are required to be present in your `nginx.conf` file | |
|
||
### `server` Section | ||
* Set the hostname or hostnames (space-separated) to generate the certificate. | ||
This may also be the environment variable `NJS_ACME_SERVER_NAMES`. | ||
This may also be defined with the environment variable `NJS_ACME_SERVER_NAMES`. | ||
```nginx | ||
js_var $njs_acme_server_names proxy.nginx.com; | ||
``` | ||
* Set your email address to use to configure your ACME account. This may also | ||
be the environment variable `NJS_ACME_ACCOUNT_EMAIL`. | ||
be defined with the environment variable `NJS_ACME_ACCOUNT_EMAIL`. | ||
```nginx | ||
js_var $njs_acme_account_email [email protected]; | ||
``` | ||
|
@@ -107,17 +105,6 @@ be the environment variable `NJS_ACME_ACCOUNT_EMAIL`. | |
} | ||
``` | ||
|
||
`$njs_acme_challenge_dir` is optional when `js_content acme.challengeResponse` is used to serve challenges; Alternatively, set the variable `$njs_acme_challenge_dir` with folder path to store challenges in server section and use for example `root` directive to serve them. | ||
```nginx | ||
server { | ||
js_var $njs_acme_challenge_dir /etc/nginx/njs-acme/challenge; | ||
<...> | ||
location ~ "^/\.well-known/acme-challenge/[-_A-Za-z0-9]{22,128}$" { | ||
default_type "text/plain"; | ||
root /etc/nginx/njs-acme/challenge/; | ||
} | ||
``` | ||
* Location, that when requested, inspects the stored certificate (if present) and will request a new certificate if necessary. The included `docker-compose.yml` shows how to use a `healthcheck:` configuration for the NGINX service to periodically request this endpoint. | ||
```nginx | ||
location = /acme/auto { | ||
|
@@ -127,35 +114,54 @@ be the environment variable `NJS_ACME_ACCOUNT_EMAIL`. | |
|
||
## Automatic Certificate Renewal | ||
|
||
NGINX and NJS do not yet have a mechanism for running code on a time interval, which presents a challenge for certificate renewal. One workaround to this is to set something up to periodically request `/acme/auto` from the NGINX server. This can be done via `cron`, or if you are running in a `docker compose` context, you can use Docker's `healthcheck:` functionality to do this. Here is an example: | ||
NGINX and NJS do not yet have a mechanism for running code on a time interval, which presents a challenge for certificate renewal. One workaround to this is to set something up to periodically request `/acme/auto` from the NGINX server. | ||
|
||
If running directly on a host, you can use `cron` to schedule a periodic request. When deploying in Kubernetes you can use a [liveness-check](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-http-request). If you are running in a `docker` context, you can use Docker's `healthcheck:` functionality to do this. | ||
|
||
Here is an example using `docker compose`: | ||
|
||
```docker | ||
service: | ||
nginx: | ||
... | ||
healthcheck: | ||
test: ["CMD", "curl", "-f", "http://proxy.nginx.com:8000/acme/auto"] | ||
interval: 1m30s | ||
interval: 90s | ||
timeout: 90s | ||
retries: 3 | ||
start_period: 10s | ||
``` | ||
|
||
This configuration will request `/acme/auto` every 90 seconds. If the certificate is nearing expiry, it will be automatically renewed. | ||
|
||
When deploying in Kubernetes you can use [liveness-check](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-http-request) similarly to periodically request `/acme/auto`. | ||
## Advanced | ||
### Serving challenges directly | ||
If you do not wish to use `js_content acme.challengeResponse` to respond to challenge requests, then you can serve them directly with NGINX. Just be sure that the `root` directive value in your location block matches the value of `$njs_acme_challenge_dir`. | ||
|
||
We also recommend to use a persistent storage for certificates and keys. | ||
```nginx | ||
server { | ||
js_var $njs_acme_challenge_dir /etc/nginx/njs-acme/challenge; | ||
<...> | ||
location ~ "^/\.well-known/acme-challenge/[-_A-Za-z0-9]{22,128}$" { | ||
default_type "text/plain"; | ||
root /etc/nginx/njs-acme/challenge/; | ||
} | ||
``` | ||
|
||
## Development | ||
|
||
This project uses Babel and Rollup to compile TypeScript sources into a single JavaScript file for `njs`. It uses Mocha with nginx-testing for running integration tests against the NGINX server. This project uses [njs-typescript-starter](https://github.com/jirutka/njs-typescript-starter/tree/master) to write NJS modules and integration tests in TypeScript. | ||
|
||
The ACME RESTful client is implemented using [ngx.fetch](http://nginx.org/en/docs/njs/reference.html#ngx_fetch), [crypto API](http://nginx.org/en/docs/njs/reference.html#builtin_crypto), [PKI.js](https://pkijs.org/) APIs in the NJS runtime. | ||
|
||
### With Docker | ||
|
||
There is a `docker-compose.yml` file in the project root directory that brings up an ACME server, a challenge server, a Node.js container for rebuilding the `acme.js` file when source files change, and an NGINX container. The built `acme.js` file is shared between the Node.js and NGINX containers. The NGINX container will reload when the `acme.js` file changes. | ||
|
||
To start up the development environment with docker compose, run the following: | ||
|
||
make start-all | ||
make docker-devup | ||
|
||
If you use VSCode or another devcontainer-compatible editor, then run the following: | ||
|
||
|
@@ -184,11 +190,11 @@ To follow these steps, you will need to have Node.js version 14.15 or greater in | |
|
||
Run this command to build an NGINX container that has the `acme.js` file and an example config loaded: | ||
|
||
make build-docker | ||
make docker-build | ||
|
||
You can then copy the created `acme.js` file out of the container with this command: | ||
|
||
make copy-docker | ||
make docker-copy | ||
The `acme.js` file will then be copied into the `dist/` directory. | ||
|
||
|
||
|
@@ -213,13 +219,13 @@ To build `acme.js` from the TypeScript source, first ensure that you have Node.j | |
|
||
1. Start a test environment in Docker: | ||
|
||
make start-all | ||
make docker-devup | ||
|
||
2. Optionally you can watch for `nginx` log file in a separate shell: | ||
|
||
docker compose logs -f nginx | ||
|
||
3. When started initially, nginx would not have certificates at all (/etc/letsencrypt/), so we can issue a new one by sending an HTTP request to a location with `js_content` handler: | ||
3. When started initially, nginx would not have certificates at all (`$njs_acme_dir`, e.g. `/etc/nginx/njs-acme/`), so we can issue a new one by sending an HTTP request to a location with the `js_content` handler: | ||
|
||
curl -vik --resolve proxy.nginx.com:8000:127.0.0.1 http://proxy.nginx.com:8000/acme/auto | ||
|
||
|
@@ -237,16 +243,16 @@ To build `acme.js` from the TypeScript source, first ensure that you have Node.j | |
|
||
7. Display content of certificates | ||
|
||
docker compose exec -it nginx ls -la /etc/letsencrypt/ | ||
docker compose exec -it nginx ls -la /etc/nginx/njs-acme/ | ||
|
||
[Docker-compose](./docker-compose.yml) file uses volumes to persist artifacts (account keys, certificate, keys). Additionally, [letsencrypt/pebble](https://github.com/letsencrypt/pebble) is used for testing in Docker, so you don't need to open up port 80 for challenge validation. | ||
The [docker-compose](./docker-compose.yml) file uses volumes to persist artifacts (account keys, certificate, keys). Additionally, [letsencrypt/pebble](https://github.com/letsencrypt/pebble) is used for testing in Docker, so you don't need to open up port 80 for challenge validation. | ||
|
||
|
||
## Build Your Own Flows | ||
|
||
If the reference implementation does not meet your needs, then you can build your own flows using this project as a library of convenience functions. | ||
|
||
The `clientAutoMode` exported function is a reference implementation of the `js_content` handler. | ||
Look at `clientAutoMode` in [`src/index.ts`](./src/index.ts) to see how you can use the convenience functions to build a ACME client implementation. | ||
|
||
## Project Structure | ||
|
||
|
@@ -255,148 +261,6 @@ The `clientAutoMode` exported function is a reference implementation of the `js_ | |
| [src](src) | Contains your source code that will be compiled to the `dist/` directory. | | ||
| [integration-tests](integration-tests) | Contains your source code of tests. | | ||
|
||
```TypeScript | ||
/** | ||
* Demonstrates an automated workflow to issue a new certificate for `r.variables.server_name` | ||
* | ||
* @param {NginxHTTPRequest} r Incoming request | ||
* @returns void | ||
*/ | ||
async function clientAutoMode(r: NginxHTTPRequest): Promise<void> { | ||
const log = new Logger('auto') | ||
const prefix = acmeDir(r) | ||
const serverNames = acmeServerNames(r) | ||
|
||
const commonName = serverNames[0] | ||
const pkeyPath = joinPaths(prefix, commonName + KEY_SUFFIX) | ||
const csrPath = joinPaths(prefix, commonName + '.csr') | ||
const certPath = joinPaths(prefix, commonName + CERTIFICATE_SUFFIX) | ||
|
||
let email | ||
try { | ||
email = getVariable(r, 'njs_acme_account_email') | ||
} catch { | ||
return r.return( | ||
500, | ||
"Nginx variable 'njs_acme_account_email' or 'NJS_ACME_ACCOUNT_EMAIL' environment variable must be set" | ||
) | ||
} | ||
|
||
let certificatePem | ||
let pkeyPem | ||
let renewCertificate = false | ||
let certInfo | ||
try { | ||
const certData = fs.readFileSync(certPath, 'utf8') | ||
const privateKeyData = fs.readFileSync(pkeyPath, 'utf8') | ||
|
||
certInfo = await readCertificateInfo(certData) | ||
// Calculate the date 30 days before the certificate expiration | ||
const renewalThreshold = new Date(certInfo.notAfter as string) | ||
renewalThreshold.setDate(renewalThreshold.getDate() - 30) | ||
|
||
const currentDate = new Date() | ||
if (currentDate > renewalThreshold) { | ||
renewCertificate = true | ||
} else { | ||
certificatePem = certData | ||
pkeyPem = privateKeyData | ||
} | ||
} catch { | ||
renewCertificate = true | ||
} | ||
|
||
if (renewCertificate) { | ||
const accountKey = await readOrCreateAccountKey( | ||
acmeAccountPrivateJWKPath(r) | ||
) | ||
// Create a new ACME client | ||
const client = new AcmeClient({ | ||
directoryUrl: acmeDirectoryURI(r), | ||
accountKey: accountKey, | ||
}) | ||
// client.api.minLevel = LogLevel.Debug; // display more logs | ||
client.api.setVerify(acmeVerifyProviderHTTPS(r)) | ||
|
||
// Create a new CSR | ||
const params = { | ||
altNames: serverNames.length > 1 ? serverNames.slice(1) : [], | ||
commonName: commonName, | ||
emailAddress: email, | ||
} | ||
|
||
const result = await createCsr(params) | ||
fs.writeFileSync(csrPath, toPEM(result.pkcs10Ber, 'CERTIFICATE REQUEST')) | ||
|
||
const privKey = (await crypto.subtle.exportKey( | ||
'pkcs8', | ||
result.keys.privateKey | ||
)) as ArrayBuffer | ||
pkeyPem = toPEM(privKey, 'PRIVATE KEY') | ||
fs.writeFileSync(pkeyPath, pkeyPem) | ||
log.info(`Wrote Private key to ${pkeyPath}`) | ||
|
||
// this is the only variable that has to be set in nginx.conf | ||
const challengePath = r.variables.njs_acme_challenge_dir | ||
|
||
if (challengePath === undefined || challengePath.length === 0) { | ||
return r.return( | ||
500, | ||
"Nginx variable 'njs_acme_challenge_dir' must be set" | ||
) | ||
} | ||
log.info('Issuing a new Certificate:', params) | ||
const fullChallengePath = joinPaths( | ||
challengePath, | ||
'.well-known/acme-challenge' | ||
) | ||
try { | ||
fs.mkdirSync(fullChallengePath, { recursive: true }) | ||
} catch (e) { | ||
log.error( | ||
`Error creating directory to store challenges at ${fullChallengePath}. Ensure the ${challengePath} directory is writable by the nginx user.` | ||
) | ||
|
||
return r.return(500, 'Cannot create challenge directory') | ||
} | ||
|
||
certificatePem = await client.auto({ | ||
csr: Buffer.from(result.pkcs10Ber), | ||
email: email, | ||
termsOfServiceAgreed: true, | ||
challengeCreateFn: async (authz, challenge, keyAuthorization) => { | ||
log.info('Challenge Create', { authz, challenge, keyAuthorization }) | ||
log.info( | ||
`Writing challenge file so nginx can serve it via .well-known/acme-challenge/${challenge.token}` | ||
) | ||
|
||
const path = joinPaths(fullChallengePath, challenge.token) | ||
fs.writeFileSync(path, keyAuthorization) | ||
}, | ||
challengeRemoveFn: async (_authz, challenge, _keyAuthorization) => { | ||
const path = joinPaths(fullChallengePath, challenge.token) | ||
try { | ||
fs.unlinkSync(path) | ||
log.info(`removed challenge ${path}`) | ||
} catch (e) { | ||
log.error(`failed to remove challenge ${path}`) | ||
} | ||
}, | ||
}) | ||
certInfo = await readCertificateInfo(certificatePem) | ||
fs.writeFileSync(certPath, certificatePem) | ||
log.info(`wrote certificate to ${certPath}`) | ||
} | ||
|
||
const info = { | ||
certificate: certInfo, | ||
renewedCertificate: renewCertificate, | ||
} | ||
|
||
return r.return(200, JSON.stringify(info)) | ||
} | ||
``` | ||
|
||
## Contributing | ||
|
||
Please see the [contributing guide](https://github.com/nginxinc/njs-acme-experemental/blob/main/CONTRIBUTING.md) for guidelines on how to best contribute to this project. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters