Skip to content

Commit

Permalink
README.md rework and docker-compose volume (#20)
Browse files Browse the repository at this point in the history
* 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
zsteinkamp authored Jul 11, 2023
1 parent 333dc46 commit 9c97edc
Show file tree
Hide file tree
Showing 3 changed files with 43 additions and 181 deletions.
216 changes: 40 additions & 176 deletions README.md
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

Expand All @@ -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`

Expand All @@ -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.
Expand All @@ -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/";
```
Expand All @@ -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];
```
Expand All @@ -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 {
Expand All @@ -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:

Expand Down Expand Up @@ -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.


Expand All @@ -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

Expand All @@ -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

Expand All @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ services:
- ./integration-tests/pebble/:/etc/pebble/
ports:
- 14000:443 # HTTPS ACME API
# - 14000:14000 # HTTPS ACME API
- 15000:15000 # HTTPS Management API
node:
build:
Expand All @@ -29,7 +28,7 @@ services:
- ./examples/nginx.conf:/etc/nginx/nginx.conf
- ./dev/nginx_wait_for_js:/nginx_wait_for_js
- node_dist:/usr/lib/nginx/njs_modules/
# - certs:/etc/nginx/njs-acme/
- certs:/etc/nginx/njs-acme/
environment:
- NJS_ACME_DIR=/etc/nginx/njs-acme/
- NJS_ACME_VERIFY_PROVIDER_HTTPS=false
Expand All @@ -40,7 +39,7 @@ services:
- 4443:443
healthcheck:
test: ["CMD", "curl", "-f", "http://proxy.nginx.com:8000/acme/auto"]
interval: 1m30s
interval: 90s
timeout: 90s
retries: 3
start_period: 10s
Expand Down
3 changes: 1 addition & 2 deletions examples/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@ http {

## Optional Variables
# js_var $njs_acme_dir /etc/nginx/njs-acme;
# js_var $njs_acme_challenge_dir /etc/nginx/njs-acme/challenge;
# js_var $njs_acme_account_private_jwk /etc/nginx/njs-acme/account_private_key.json;
# js_var $njs_acme_directory_uri https://pebble/dir;
# js_var $njs_acme_verify_provider_https false;
# `njs_acme_challenge_dir` is optional when `js_content acme.challengeResponse` is used to serve challenges;
# js_var $njs_acme_challenge_dir /etc/nginx/njs-acme/challenge; # must be an nginx variable, not an environment variable

js_set $dynamic_ssl_cert acme.js_cert;
js_set $dynamic_ssl_key acme.js_key;
Expand Down

0 comments on commit 9c97edc

Please sign in to comment.